Best Practices

A set of guidelines for developing a project using the Framework.

Extending Entity for Standalone Classes

Rule: Classes that do not extend another framework base class (e.g. AbstractPlugin, DataAccessObject, Store, ValuesObject) may extend Entity, but it is not required.

When extending Entity is useful

  • You need validation helpers: getPreparedData(), getExtendData() (see Validation)
  • You need event dispatching (Entity extends EventDispatcher)
  • You want consistency with framework classes: Core, Template, Store, DefaultUser, ValuesObject all extend Entity

Standalone classes that do not need validation or events are not required to extend Entity.

Example

// Standalone class without a framework parent → may extend Entity if validation/events needed
class PaymentProcessor extends Entity
{
    public function process(array $request): array
    {
        $errors = [];
        $fields = [
            'amount' => [
                Entity::OPTION_KEY_TYPE => Entity::FIELD_TYPE_FLOAT,
                Entity::OPTION_KEY_REQUIRED => true
            ],
            'currency' => [
                Entity::OPTION_KEY_TYPE => Entity::FIELD_TYPE_STRING,
                Entity::OPTION_KEY_DEFAULT => 'USD'
            ],
        ];

        $data = $this->getPreparedData($request, $fields, $errors);

        if ($errors) {
            return [
                'status' => 'error',
                'errors' => $errors
            ];
        }

        // process...
        return [
            'status' => 'ok'
        ];
    }
}

Classes that extend AbstractPlugin, ObjectPlugin, DataAccessObject, Store, etc. already inherit from Entity through that hierarchy.

Do Not Modify the Core for New Actions or Fields

Rule: Do not update the framework core when you need a new action or field type. Create it in your project instead. If it proves stable and valuable, it can be proposed for inclusion in the core in the future.

Why?

  • Keeps your project upgradeable without merge conflicts
  • Lets you iterate and validate the feature before core adoption
  • Core additions require broader use cases and maintenance commitment

Best Practice

  • Implement custom DGS actions or field types in your project (e.g. in a plugin or shared library)
  • Extend or compose existing core classes rather than patching them
  • When the feature is proven and widely useful, open an issue or PR to contribute it to the core

Bad Practice: Executing SQL queries directly from event callbacks triggered by UI interactions.

Why is this bad?

  • Performance Issues: Event-driven SQL queries may lead to unnecessary database load, slowing down the application.
  • Unclear Data Flow: Mixing database logic with UI events reduces code maintainability and readability.
  • Scalability Problems: Hardcoding SQL queries within UI event handlers makes it harder to scale and optimize the system.

Best Practice

  • Use events in UI and Theme layers only for modifying the interface or retrieving pre-processed data.
  • Keep data processing and database interactions within dedicated data service layers or plugins.
  • Store necessary data beforehand and let UI events access only pre-fetched data, reducing real-time database calls.

Using assert for HTML Validation (Unless Specifically Testing HTML)

Why is this bad?

  • Difficult Test Maintenance: Asserting raw HTML makes test cases harder to read and maintain.
  • Unstable Tests: Even minor changes in HTML structure (e.g., attribute order) can break tests, despite unchanged functionality.
  • Overuse of Regular Expressions: Regex is unreliable for checking HTML structure and leads to brittle tests.

Best Practice

  • If you need to check HTML structure, use an HTML parser instead of raw assert statements.
  • If testing functionality, verify data outputs or API responses rather than HTML markup.
  • Minimize reliance on UI elements in unit tests to ensure long-term stability.

Avoiding SQL in Loops and use SQL in Plugins

One of the most common performance mistakes is placing SQL queries inside loops. This leads to inefficient database access and slows down the system. Festi strongly discourages direct SQL queries in plugin methods. Instead, all database logic should be moved to an Object class that extends DataAccessObject.

/**
 * @urlRule /users/getActiveTimers/
 * @return bool
 */
public function onGetActiveTimersPost(Response &$response, Request $request) {
    try {
        $body = json_decode($request->getJson(), true);
        $childId = isset($body['child_id']) ? (int)$body['child_id'] : null;

        if (!$childId) {
            $response->status = 'error';
            $response->message = 'child_id is required';
            $response->setStatus(400);
            return false;
        }

        $timerRows = $this->core->db->getAll("SELECT id FROM timers WHERE child_id = $childId");
        $timerIds = array_map(fn($row) => $row['id'], $timerRows);

        $activeTimers = [];

        foreach ($timerIds as $timerId) {
            $timerId = (int)$timerId;

            $timerDetails = $this->core->db->getAll("SELECT * FROM timers WHERE id = $timerId");
            if (!$timerDetails) {
                continue;
            }

            $logs = $this->db->getRows("SELECT * FROM timer_logs WHERE timer_id = $timerId");

            $mappedLogs = [];
            $timerInactive = false;

            foreach ($logs as $log) {
                if ($log['action'] === 'STOP_TIMER') {
                    $timerInactive = true;
                }

                $mappedLogs[] = [
                    'id'         => (string)$log['id'],
                    'user_id'    => (string)$log['user_id'],
                    'action'     => (string)$log['action'],
                    'activity_id'=> isset($log['activity_id']) ? (string)$log['activity_id'] : null,
                    'created'    => $log['created_at'],
                    'updated_at' => $log['updated_at'],
                ];
            }

            if (!$timerInactive) {
                $activeTimers[] = [
                    'timer_id'   => (string)$timerId,
                    'timer_type' => $timerDetails['type'],
                    'child_id'   => (string)$timerDetails['child_id'],
                    'created_by' => (string)$timerDetails['created_by'],
                    'logs'       => $mappedLogs
                ];
            }
        }

        $response->status = 'success';
        $response->message = 'Successfully retrieved active timers';
        $response->timers = $activeTimers;
        return true;
    } catch (Exception $e) {
        \FestiUtils::addLogMessage("Error retrieving active timers: " . $e->getMessage());
        $response->status = 'error';
        $response->message = 'An error occurred';
        $response->setStatus(500);
        return false;
    }
}

Why is this bad?

  • N+1 Query Problem: Each loop iteration triggers additional database queries, causing exponential increase in query volume.
  • Poor Performance: Multiple small queries are less efficient than a single optimized query.
  • Network Overhead: Each database connection incurs network latency, significantly slowing down application response time.
  • Server Load: Excessive database calls increase load on both the application and database servers.
  • Scalability Issues: Performance degrades dramatically as data volume grows.

Best Practice

class TimerObject extends DataAccessObject
{
    public function getActiveTimersForChild(int $childId): array
    {
        $timers = $this->db->getRows("SELECT * FROM timers WHERE child_id = ?", [$childId]);
        $timerIds = array_column($timers, 'id');

        if (empty($timerIds)) {
            return [];
        }

        $placeholders = implode(',', array_fill(0, count($timerIds), '?'));
        $logs = $this->db->getRows("SELECT * FROM timer_logs WHERE timer_id IN ($placeholders)", $timerIds);

        $logsByTimer = [];
        foreach ($logs as $log) {
            $logsByTimer[$log['timer_id']][] = $log;
        }

        $activeTimers = [];
        foreach ($timers as $timer) {
            $timerId = $timer['id'];
            $timerLogs = $logsByTimer[$timerId] ?? [];

            $timerInactive = false;
            $mappedLogs = [];

            foreach ($timerLogs as $log) {
                if ($log['action'] === 'STOP_TIMER') {
                    $timerInactive = true;
                }

                $mappedLogs[] = [
                    'id'          => (string)$log['id'],
                    'user_id'     => (string)$log['user_id'],
                    'action'      => (string)$log['action'],
                    'activity_id' => isset($log['activity_id']) ? (string)$log['activity_id'] : null,
                    'created'     => $log['created_at'],
                    'updated_at'  => $log['updated_at'],
                ];
            }

            if (!$timerInactive) {
                $activeTimers[] = [
                    'timer_id'   => (string)$timerId,
                    'timer_type' => $timer['type'],
                    'child_id'   => (string)$timer['child_id'],
                    'created_by' => (string)$timer['created_by'],
                    'logs'       => $mappedLogs
                ];
            }
        }

        return $activeTimers;
    }
}

// In your plugin class, use:

/**
 * @urlRule /users/getActiveTimers/
 * @return bool
 */
public function onGetActiveTimersPost(Response &$response, Request $request) {
    try {
        $body = json_decode($request->getJson(), true);
        $childId = isset($body['child_id']) ? (int)$body['child_id'] : null;

        if (!$childId) {
            $response->status = 'error';
            $response->message = 'child_id is required';
            $response->setStatus(400);
            return false;
        }

        $activeTimers = $this->object->timer->getActiveTimersForChild($childId);

        $response->status = 'success';
        $response->message = 'Successfully retrieved active timers';
        $response->timers = $activeTimers;

        return true;
    } catch (Exception $e) {
        \FestiUtils::addLogMessage("Error retrieving active timers: " . $e->getMessage());
        $response->status = 'error';
        $response->message = 'An error occurred';
        $response->setStatus(500);
        return false;
    }
}

In your plugin class, use:

$activeTimers = $this->object->timer->getActiveTimersForChild($childId);
  • Only two SQL queries total (not N+1).
  • Database logic encapsulated in Object class.
  • Plugin remains clean and focused on handling requests.

Design Plugins Around Business Abstractions, Not Vendor Names

Rule: When a plugin integrates with an external provider, the plugin's public API should describe the business capability, not the current vendor. Keep provider-specific classes behind interfaces and dedicated service/config layers.

Why?

  • Reduces coupling between business features and a concrete vendor API
  • Makes refactoring or replacing a provider cheaper in the future
  • Keeps plugin facades small and focused on orchestration
  • Prevents provider-specific names from leaking into queue types, worker methods, and other plugins

Best Practice

  • Name the plugin and its public methods by business purpose, for example CrmBridgePlugin, createLead(), crm_lead
  • Put provider-specific HTTP, OAuth, and payload mapping into dedicated service classes such as ZohoCrmService
  • Use interfaces for the plugin-facing contract, for example ICrmBridgeFacade or ICrmService
  • Let other plugins depend on the interface or high-level facade, not on Zoho... classes directly
  • Keep unrelated provider products in separate plugins or bounded contexts when they solve different business problems, for example CRM vs Help Desk

Example

Instead of coupling the whole project to a provider-specific plugin like ZohoPlugin, expose a business-level facade and hide Zoho details behind it:

interface ICrmBridgeFacade
{
    public function isEnabled(): bool;

    public function createLead(array $leadData): string;
}

class CrmBridgePlugin extends DisplayPlugin implements ICrmBridgeFacade
{
    private ICrmService $_crmService;

    public function createLead(array $leadData): string
    {
        return $this->_getCrmService()->createLead($leadData);
    }
}

class ZohoCrmService implements ICrmService
{
    public function createLead(array $leadData): string
    {
        // OAuth, HTTP request, payload mapping...
    }
}

class LeadsPlugin extends DisplayPlugin
{
    protected function getCrmFacade(): ICrmBridgeFacade
    {
        return $this->core->getPluginInstance('CrmBridge');
    }

    public function onQueueItemWithTypeCrmLead(
        array $data,
        int $idQueueItem
    ): void
    {
        $remoteID = $this->getCrmFacade()->createLead($data);
    }
}

In this design, LeadsPlugin knows only about a CRM bridge. It does not know whether the implementation is Zoho today or another CRM tomorrow. Only the provider-specific service and config classes need to change.

Combining DAO Search Filters with FIELD Constants

When building a $search array, never pass raw column-name strings. Reference the corresponding FIELD_* constant on the relevant ValuesObject and concatenate the operator suffix when needed. This keeps column names typo-safe and survives column renames.

use Plugins\Contents\Domain\Model\ContentValuesObject;

$search = [
    ContentValuesObject::FIELD_STATUS            => ContentValuesObject::STATUS_ACTIVE,
    ContentValuesObject::FIELD_URL.'&IS NOT'     => null,
    ContentValuesObject::FIELD_CREATE_DATE.'&>=' => '2026-01-01',
];

$rows = $this->object->search($search);

For the full list of operator suffixes, see Festi ObjectDB. For the ValuesObject pattern itself, see ValuesObjects.md.