Events

Festi provides a event-driven architecture that allows different parts of the application to communicate efficiently. Events are triggered throughout the execution of a Festi application, and you can subscribe to these events to execute specific logic when they occur.

The Core primarily acts as an Event Bus, dispatching events that can be listened to and handled by plugins, DGS (Data Grid Store), themes, and other system components.

Event Class Structure

  • Each event should be represented by a dedicated class.
  • Define a EVENT_* constant to act as the unique identifier for the event.
  • Use the constructor to initialize all required event context.
  • Expose data through public methods. Use references (&) when mutation is expected.

Example: MenuEvent

class MenuEvent extends FestiEvent
{
    public const EVENT_ON_PREPARE_MENU_ITEMS = "event_on_prepare_menu_items";

    public const TARGET_ITEMS_KEY_NAME = "items";
    public const TARGET_AREA_KEY_NAME = "area";
    public const TARGET_ACTIVE_ITEM_KEY_NAME = "activeItem";
    public const TARGET_CURRENT_ITEM_KEY_NAME = "currentItem";

    public function __construct(
        string $type,
        array &$items,
        ?string $area = null,
        ?string $activeItem = null,
        ?string $currentItem = null
    )
    {
        $target = [
            static::TARGET_ITEMS_KEY_NAME => &$items,
            static::TARGET_AREA_KEY_NAME => $area,
            static::TARGET_ACTIVE_ITEM_KEY_NAME => $activeItem,
            static::TARGET_CURRENT_ITEM_KEY_NAME => $currentItem
        ];

        parent::__construct($type, $target);
    }

    public function &getItems(): array
    {
        return $this->getTargetValueByKey(static::TARGET_ITEMS_KEY_NAME);
    }

    public function getArea(): ?string
    {
        return $this->getTargetValueByKey(static::TARGET_AREA_KEY_NAME);
    }

    public function getActiveItem(): ?string
    {
        return $this->getTargetValueByKey(static::TARGET_ACTIVE_ITEM_KEY_NAME);
    }

    public function getCurrentItem(): ?string
    {
        return $this->getTargetValueByKey(static::TARGET_CURRENT_ITEM_KEY_NAME);
    }
}

Dispatching the Event:

$event = new MenuEvent(
    MenuEvent::EVENT_ON_PREPARE_MENU_ITEMS,
    $items,
    'admin',
    'settings',
    'settings_general'
);

$this->core->dispatchEvent($event);

Listening to the Event:

$this->core->addEventListener(MenuEvent::EVENT_ON_PREPARE_MENU_ITEMS, function (MenuEvent $event) {
    // Access the items and modify them as needed
});

System Events

Core::EVENT_ON_AFTER_INIT

This event is triggered after Core has been initialized and all plugins have been initialized, meaning all the init.php files of the plugins have been called.

For example, if we need to use a constant declared in the init.php of another plugin in our plugin:

init.php:


assert($this instanceof Core);

$this->addEventListener(Core::EVENT_ON_AFTER_INIT, function () {
    $this->addEventListener(IRpc::EVENT_ON_TOKEN_LOGIN, function (FestiEvent &$event) {
        Core::getInstance()->getPluginInstance('GoogleApi')->onInterceptLoginByToken($event);
    });
});

IRpc is declared in another plugin, and if IRpc::EVENT_ON_TOKEN_LOGIN is called outside of the Core::EVENT_ON_AFTER_INIT event, there's a high chance you'll get a Fatal Error.

Core::EVENT_ON_CREATE_STORE

After a DGS is created, the Core::EVENT_ON_CREATE_STORE event is triggered. It can be used to modify all DGS in the system or specific ones.

Target: * store - reference to the DGS

$stores = [];

$this->core->addEventListener(Core::EVENT_ON_CREATE_STORE, function (FestiEvent &$event) use (&$stores) {
    $stores[] = &$event->getTargetValueByKey('store');
});

Core::EVENT_PLUGIN_INIT

This event is triggered during the initialization of a plugin, before the plugin's onInit method is called.

Target: * plugin - reference to the plugin

Core::EVENT_ON_RESPONSE

This event is triggered before the response is sent to the request.

Target = Response

$this->core->addEventListener(Core::EVENT_ON_RESPONSE, function (FestiEvent &$event) {
    $response = $event->getTarget();
    assert($response instanceof Response);
    $response->setOverride(true);
    $this->core->removeEventListenersByType(Core::EVENT_ON_RESPONSE);
});

Cache Events

Festi ships a cache hook, not a cache. Core dispatches two events and stores nothing itself, so a project can serve framework data from Memcached, Redis, APCu or anything else by subscribing a listener. Without a registered listener every read is a miss and the value is loaded exactly as before — caching is entirely opt-in and changes nothing until you switch it on.

Framework data currently routed through the bus:

Group What it holds
CacheEvent::GROUP_SETTINGS The system settings map (festi_settings)
CacheEvent::GROUP_URL_RULES The url rules of one routing area

The events

Event Meaning
CacheEvent::EVENT_ON_GET_CACHE_VALUE Core is asking whether you hold a value
CacheEvent::EVENT_ON_SET_CACHE_VALUE Core loaded a value you did not hold

Target keys, all exposed through typed accessors: TARGET_KEY_KEY_NAME, TARGET_GROUP_KEY_NAME, TARGET_VALUE_KEY_NAME, TARGET_IS_HIT_KEY_NAME, TARGET_EXPIRATION_KEY_NAME.

The group never takes part in identifying an entry — everything that distinguishes one entry from another is already in the key. Treat the group as a policy handle: it is how you give url rules a different lifetime from settings, or purge one family of keys at once.

The hit contract

A listener holding the entry calls $event->setValue($value) — exactly once. A listener that does not hold it calls nothing.

setValue() marks the hit itself; there is no setHit(). Never infer a hit from the value. null and [] are values a project can legitimately cache — an area with no routes really does cache as [] — and treating either as "not found" sends every request back to the database forever.

Backends cannot express this distinction on their own: Memcached::get() returns false both for a miss and for a stored false. Wrap the value, as the example below does.

A complete listener

use Festi\Core\Cache\CacheEvent;
use Festi\Core\Cache\ICacheListener;

class ProjectCacheListener implements ICacheListener
{
    private array $_ttl = [
        CacheEvent::GROUP_URL_RULES => 300,
        CacheEvent::GROUP_SETTINGS => 60,
    ];

    public function __construct(private Cacher $_cacher)
    {
    }

    public function onGetCacheValueHandler(CacheEvent &$event): void
    {
        $entry = $this->_cacher->get($event->getKey(), $event->getGroup());

        // The wrapper is what lets a cached null or [] survive the round
        // trip: the backend cannot tell either of them from a miss.
        if (!is_array($entry) || !array_key_exists('value', $entry)) {
            return;
        }

        $event->setValue($entry['value']);
    }

    public function onSetCacheValueHandler(CacheEvent &$event): void
    {
        $entry = [
            'value' => $event->getValue(),
        ];

        $ttl = $this->_ttl[$event->getGroup()] ?? 0;

        $this->_cacher->set(
            $event->getKey(),
            $entry,
            $event->getGroup(),
            $ttl
        );
    }
}

Implementing ICacheListener is optional — any callable registered on the two events works — but it lets you subscribe in one call and has the handler names checked by the engine instead of typed as strings. addEventListener() returns false silently for a name that does not resolve, which otherwise turns a typo into a cache that is quietly never consulted.

Registering it

// plugins/MyProject/init.php
assert($this instanceof Core);

$adapter = new MemcachedAdapter($memcached);
$cacher = new Cacher($adapter, $tenantId.':');
$listener = new ProjectCacheListener($cacher);

$this->addCacheListener($listener);

Register in init.php, or at Core::EVENT_ON_AFTER_INIT at the latest. Do not register from a Core::EVENT_ON_REQUEST handler. That event fires after the settings and url rules have already been loaded, so a listener registered there is never consulted and the cache silently does nothing.

Multi-tenancy

Core puts the table prefix in the key and nothing more. If one prefix serves several tenants across different databases, pass a per-tenant key prefix to your backend, as in the snippet above. Core cannot know the tenant and does not guess.

Caching your own values

The bus is not limited to framework data:

$loader = function (): array {
    return $this->object->getExpensiveReport();
};

$report = $this->core->loadCachedValue('report.monthly', $loader);

loadCachedValue() runs the loader only on a miss and offers the result to the cache. Keep cached values plain — arrays, strings, numbers. Closures, database connections and open resources cannot be serialised, and values passed around by reference stop propagating mutations once they have been through a cache.

ISystemPlugin::EVENT_ON_BEFORE_REQUEST_PLUGIN_METHOD

This event is triggered before a plugin method is called by reference.

Target: * params - reference to the array of parameters being passed to the method * response - reference to the Response instance * plugin - name of the plugin being called * method - name of the method being called

Writing an Interceptor for All Plugin Requests

We need to ensure that users of type Student have access only to their institution for all requested methods:

class SchoolPlugin extends DisplayPlugin
{

    public function onRequestInterceptor(FestiEvent &$event)
    {
        $params = $event->getTargetValueByKey('params');

        if (App::isStudent()) {
            if (!array_keyExists(1, $params)) {
                throw new NotFoundException("Undefined company ID");
            }

            $idRequestedCompany = $params[1];
            App::validateCompanyPermission($idRequestedCompany);
        }

        return true;
    }
}

init.php:


$this->addEventListener(
    ISystemPlugin::EVENT_ON_BEFORE_REQUEST_PLUGIN_METHOD,
    function (FestiEvent &$event) {
        Core::getInstance()->getPluginInstance('School')->onRequestInterceptor($event);
    }
);

Create Custom Event and Pass Through the Core

You can create a custom event by extending FestiEvent and pass it through the Core, allowing other parts of your project to listen for and handle the event.

Here’s an example of creating a custom event:

class NotFoundContentEvent extends FestiEvent
{
    private string $_url;
    private Response $_response;
    private bool $isFound = false;
    public function __construct(Response &$response, string $url)
    {
        parent::__construct($this::class);

        $this->_url = $url;
        $this->_response = &$response;
    }

    public function getUrl(): string
    {
        return $this->_url;
    }
    public function setFound(bool $isFound): void
    {
        $this->isFound = $isFound;
    }

    public function isFound(): bool
    {
        return $this->isFound;
    }
}
````

To dispatch the custom event through Core, use the following code:

```php
$event = new NotFoundContentEvent($response, $url);
$this->core->dispatchEvent($event);

if (!$event->isFound()) {
    throw new NotFoundException();
}

return true;

Once the event has been dispatched, you can subscribe to it from anywhere in your project, such as `init.php`` in any plugin, or from any other location:

assert($this instanceof Core);

$this->addEventListener(Core::EVENT_ON_AFTER_INIT, function () {

    Core::getInstance()->addEventListener(NotFoundContentEvent::class, function (NotFoundContentEvent &$event) {
        // Handle the custom event
    });
});

Cross-Plugin Events via Core

When you need to link one plugin's event to another, subscribe to the expected event in the plugin's init.php:

class PluginA
{
    public function onUpdate(Response &$response, ?int $idUser = null)
    {
        ...
        $event = new FestiEvent(PLUGIN_A_EVENT_UPDATE, $target);
        $this->core->dispatchEvent($event);
        ...
    }
}

PluginB init.php:

$this->addEventListener(
    ISystemPlugin::EVENT_ON_BEFORE_REQUEST_PLUGIN_METHOD,
    function (FestiEvent &$event) {
        $plugin = Core::getInstance()->getPluginInstance('PluginB');

        Core::getInstance()->addEventListener(
            PLUGIN_A_EVENT_UPDATE,
            function (FestiEvent &$event) use ($plugin) {
                $plugin->onUpdatePluginA($event);
            }
        );
    }
);

Theme Events

In the event’s $target, you will find: * link - the URL that the logo leads to * logo - the logo's src attribute * content - if specified, $target['content'] will be rendered within the logo

init.php:

<?php

$this->addEventListener(
    Core::EVENT_ON_REQUEST,
    function () {
        $companiesPlugin = Core::getInstance()->getPluginInstance('Companies');

        Core::getInstance()->getSystemPlugin()->addEventListener(
            ITheme::EVENT_THEME_ON_PREPARE_LOGO,
            [&$companiesPlugin, 'onPrepareCompanyLogo']
        );
    }
);

CompaniesPlugin.php:

<?php

class CompaniesPlugin extends StagePlugin
{
    public function onPrepareCompanyLogo(FestiEvent &$event)
    {
        $target = &$event->getTarget();

        // ...

        if (is_null($company['logo'])) {
            return;
        }

        // ...

        $target['logo'] = $logo;
    }
}

Event Best Practices

Follow these practices to ensure your custom event classes remain robust, consistent, and easy to maintain.

Do

  • Create a named class per event domain (e.g., UserEvent, MenuEvent).
  • Use constants like EVENT_ON_* for clarity and type safety.
  • Always pass event data via the constructor and access it through typed methods.
  • Use references when event handlers need to mutate shared data (like modifying items in a menu).
  • Favor consistency across all event types and use logical naming for keys.
  • Always declare event handler signatures with the event object passed by reference (FestiEvent &$event).

Reuse Built-in FestiEvent Methods

Use FestiEvent::stopPropagation() and isPropagationStopped() for cancellation logic. Do not create custom cancel() / isCancelled() methods when the base class already provides this functionality:

// Bad — custom cancellation
class MyEvent extends FestiEvent
{
    private bool $_cancelled = false;
    public function cancel(): void { $this->_cancelled = true; }
    public function isCancelled(): bool { return $this->_cancelled; }
}

// Good — use built-in propagation control
$event->stopPropagation();

if ($event->isPropagationStopped()) {
    return;
}

Avoid

  • Avoid passing generic arrays without a defined structure.
  • Avoid using anonymous events without a clear class definition.
  • Avoid tightly coupling unrelated concerns inside a single event class.
  • Avoid using raw strings as keys in target access—prefer class constants.
  • Avoid creating custom cancellation methods when stopPropagation() / isPropagationStopped() already exist.