Zum Inhalt springen

AI blog · DotApp PHP Framework 2.0

How independent listener routes work in DotApp PHP Framework

A module used to wake as one unit: if the URL matched Module::initializeRoutes(), both module.listeners.php and initialize() ran. The loader now keeps two maps. Listener masks decide when callbacks register. Module masks decide when routes and fallbacks boot. Audit can listen on Shop’s API without constructing Audit’s admin pages. Shop can still skip initialize() on URLs it does not own. Omit Listeners::initializeRoutes() and the listener inherits the module map — old modules and old optimizer files stay compatible. This article is a complete Audit listener with its own routes, plus the v2 modulesAutoLoader.php format.

Common mistakes

Wrong Right
Return ['*'] from a heavy module so a hook exists everywhere Give the listener a tight mask. Keep module routes on the module’s prefix
Put Router::get in module.listeners.php That file only registers callbacks. Pages stay in initialize()
Query, log, or HTTP while the listeners file is included register() must only attach Events::on / Router::before
Change prefixes and skip --optimize-modules Stale modulesAutoLoader.php skips the listener on URLs it should own
Expect a veto/hook to run when the listener file never loaded Cover the producer URL in Listeners::initializeRoutes()
Assume an old cache file is invalid A v1 file with only $modules still works. Listeners then share that map

Two maps, listeners first

load_modules() always registers matching listeners, then initializes matching modules. That order is the same with or without app/modules/modulesAutoLoader.php. Full boot chain: How module initialization works.

  1. If a listener is bound to dotapp.load_modules.override, that listener replaces the default scan. Do not subscribe “just to log”.
  2. Otherwise, if modulesAutoLoader.php exists, the kernel reads $modules and, when $modulesAutoLoaderVersion >= 2, the separate $listeners map.
  3. Every module whose listener masks match the current URL includes module.listeners.php.
  4. Then every module whose module masks match includes module.init.php / constructs Module.
  5. Without the cache file, each listeners class decides at runtime via willInitialize(), then the same split runs for full init.

A listener that does not define initializeRoutes() returns null. The optimizer and the runtime then copy Module::initializeRoutes(). That is the compatibility path for every module written before this update.

Listeners::initializeRoutes()

Method Return Meaning
Module::initializeRoutes() List of URL masks When initialize() may run (routes, fallbacks)
Listeners::initializeRoutes() List of URL masks, or null null = inherit the module map. A list = listener-only wake-up
Listeners::resolvedInitializeRoutes($moduleRoutes) Validated string list What the optimizer writes into $listeners

Both lists must be a one-dimensional array of strings. Anything else throws \InvalidArgumentException during optimize. ['*'] wakes that part on every URL — only for a real global hook (firewall, HUD), and only after you accept the cost.

Optimizer v2 and the old file

From the project root:


php .\dotapper.php --optimize-modules
    

Module::optimize() now writes three assignments into app/modules/modulesAutoLoader.php:


$modules = [ /* Module::initializeRoutes() per module */ ];
$listeners = [ /* Listeners::resolvedInitializeRoutes() per module */ ];
$modulesAutoLoaderVersion = 2;
    

$modules stays so an older kernel can still boot. A new kernel that finds $modulesAutoLoaderVersion >= 2 and an array $listeners uses the split map (missing listener keys fall back to the module entry). A hand-written or leftover v1 file that only defines $modules is still valid: listeners reuse that same map. Re-run optimize after you add a module, change prefixes, or add listener-only masks.

During optimize the kernel defines __DOTAPPER_OPTIMIZER__ so constructing Listeners does not call register() — it only reads routes.

Complete Audit listener with its own routes

File: app/modules/Audit/module.listeners.php. Audit’s admin UI can stay asleep on /dacore/Audit. The listener still wakes on Shop’s authenticated API, where Shop fires hooks and vetoes.


<?php
namespace Dotsystems\App\Modules\Audit;

use Dotsystems\App\DotApp;
use Dotsystems\App\Parts\Events;
use Dotsystems\App\Parts\Veto;
use Dotsystems\App\Modules\Audit\Libraries\AuditStore;

class Listeners extends \Dotsystems\App\Parts\Listeners
{
    /**
     * Wake this listener only where Shop can fire the subscribed names.
     * Omit this method to inherit Module::initializeRoutes() exactly.
     *
     * @return array<int, string> Listener route masks.
     */
    public function initializeRoutes()
    {
        return ['/api/v1/auth/Shop', '/api/v1/auth/Shop/*'];
    }

    public function register($dotApp)
    {
        Events::on('module.shop.sms_sent.hook', function ($result, ...$data) {
            $userId = (int) ($result['user_id'] ?? 0);
            if ($userId < 1) {
                return;
            }
            AuditStore::recordSms($userId, $result);
        });

        Events::on('module.shop.item_delete.veto', function ($result) {
            $itemId = (int) ($result['item_id'] ?? 0);
            if ($itemId > 0 && AuditStore::hasRequiredHistory($itemId)) {
                return new Veto('audit.history_required', 'Audit history still references this item.', [
                    'item_id' => $itemId,
                ]);
            }
            return null;
        });
    }
}

new Listeners(DotApp::DotApp());
    

Shop’s module map can stay ['/Shop', '/Shop/*', '/api/v1/auth/Shop', '/api/v1/auth/Shop/*', ...]. Audit’s initialize() can stay on ['/dacore/Audit', '/dacore/Audit/*']. The two files no longer have to agree. Veto contract: How trigger with veto works.

Without modulesAutoLoader.php

The split still exists. Each Listeners construct calls willInitialize():

  • If initializeRoutes() returned a list, match those masks (or ['*']).
  • If it returned null, the class falls back to the module’s own willInitialize() / initializeCondition() path — same as before this update.

Keep register() cheap anyway. Including the file on a matching URL must not query or write.

FAQ

When should I omit initializeRoutes() on Listeners?

When the callbacks only matter on the same URLs as the module’s pages. That is the default, and it is what old modules already do.

Can a sleeping module veto Shop?

Only if its listener loaded. Full initialize() can stay asleep. The listener map must include the Shop request that fires the veto.

Do I have to regenerate the autoloader today?

After this update, yes if you add listener-only masks. If you change nothing, a v1 $modules file keeps the old coupled behaviour and remains compatible.

Is ['*'] on the listener the same as on the module?

It only wakes that part. A ['*'] listener with a tight module map registers callbacks everywhere without registering Shop routes on every URL. Still expensive — prefer explicit prefixes.

Why listeners before initialize()?

So a listener can subscribe (or even module('Cart')->load()) before the producer finishes initialize(). Matching listeners run as a complete pass first.

See also