Skip to content

AI blog · DotApp PHP Framework 2.0

How module initialization works in DotApp PHP Framework

A request boots in a fixed order: index.php includes app/config.php, that file constructs new DotApp(), then load_modules() walks app/modules/. For each module, module.listeners.php is included first (if it exists). Then module.init.php instantiates Module. Inside Module::__construct the framework runs one-shot installation(), fires init.start, evaluates initializeCondition, calls initialize($dotApp) when allowed, then fires init.end. Routes and Config::module fallbacks belong in initialize(). Early global hooks belong in listeners. Production overrides belong in app/config.php. initializeRoutes() plus php dotapper.php --optimize-modules is how Shop stays lazy: it only fully initializes when the URL matches. This article is a complete Shop listeners file, a complete module.init.php, and the install path — not a teaser.

Common mistakes

Wrong Right
Register Shop routes in index.php or in listeners. Register routes in initialize($dotApp).
Put Router::get in module.listeners.php. Listeners: early hooks. Init: routes and fallbacks.
Skip new Module($dotApp); at the bottom of module.init.php. DotApper writes that line. Keep it. That is the constructor entry.
Return ['*'] from initializeRoutes() on a heavy Shop. Return ['/shop', '/shop/*'] and run --optimize-modules.
Expect an init.condition listener return value to skip init. trigger() ignores listener returns. Override initializeCondition() on the class.
Rely on renaming install.php as the only idempotency. Rename prevents a second run. Real DDL safety is your shop_installations table.
Hard-code production prefix only in the module folder. Fallbacks in initialize(), overrides in app/config.php.
Forget the namespace Dotsystems\App\Modules\Shop. Match the folder name. DotApper scaffolds it.

When this matters

You care about init when Shop must register routes, when a listener must run before routes exist, when you want lazy loading, or when install.php should create shop_* tables once. You do not re-implement boot in a controller. You do not edit app/parts/Module.php. You do not add a second bootstrap next to module.init.php. Two teams initialize in parallel: Shop’s initialize() never writes Users routes, and Users never writes Shop fallbacks. That is the team advantage of one module per product surface — see How to create a module in DotApp PHP Framework.

Boot: index.php → config.php → new DotApp → load_modules()

  1. index.php defines __ROOTDIR__ and includes app/config.php.
  2. app/config.php registers drivers, databases, secrets, then $dotApp = new \Dotsystems\App\DotApp().
  3. Unless maintenance is on, $dotApp->load_modules() runs.
  4. Optional event dotapp.load_modules.override can replace the scan. If no listener, the default scan runs.
  5. If app/modules/modulesAutoLoader.php exists (from --optimize-modules), only matching modules load. Otherwise every module directory loads.
  6. For each selected module: include module.listeners.php first, then include module.init.php / construct Module.
  7. Framework default routes (assets) register. $dotApp->davajhet() (run()) resolves the request.

Config file details: How app/config.php works in DotApp PHP Framework. Scaffold: How to create a module in DotApp PHP Framework.

Order: listeners first, then module.init.php

load_module_listeners() includes app/modules/Shop/module.listeners.php when the file exists. The listeners class extends \Dotsystems\App\Parts\Listeners and ends with new Listeners($dotApp);. register($dotApp) is the place for $dotApp->on(...) and global Router::before hooks that must exist before Shop’s routes. Then load_module() includes module.init.php. That file’s last line is new Module($dotApp);. During the listeners pass, __DOTAPP_MODULES_CAN_LOAD__ is not defined yet, so a premature Module construct returns before initialize(). Full init happens on the second construct after the flag is set. You do not need to handle that flag yourself — keep the two files in their roles.

Module::__construct events

Once modules are allowed to load, the constructor does this:

  1. installation() — if install.php exists, fire dotapp.module.shop.install, include it, rename it.
  2. dotapp.module.shop.init.start — always.
  3. initializeConditionAndListener() — URL patterns from initializeRoutes(), then initializeCondition($routeMatch). If a listener is bound to dotapp.module.shop.init.condition, that event fires, but trigger() returns the original payload unchanged. Listener return values are ignored.
  4. If the condition is truthy (or DotApper is running): load()dotapp.module.shop.loadinginitialize($dotApp)dotapp.module.shop.loaded.
  5. dotapp.module.shop.init.end — always, even when init was skipped.

Event names are lowercased on register and trigger. The payload is the module instance. After every selected module has loaded: dotapp.modules.loaded.

Event When
dotapp.load_modules.override Before the default module scan
dotapp.module.{name}.install Immediately before one-shot install.php
dotapp.module.{name}.init.start After installation(), before the condition
dotapp.module.{name}.init.condition Fires if a listener exists; cannot change the boolean via return value
dotapp.module.{name}.loading Just before initialize($dotApp)
dotapp.module.{name}.loaded Just after initialize($dotApp)
dotapp.module.{name}.init.end End of construct, always
dotapp.modules.loaded After all selected modules

initializeRoutes() and --optimize-modules

initializeRoutes() returns URL patterns. Default on the base class is ['*'] (load on every request). Shop should return its prefix:


public function initializeRoutes()
{
    return ['/shop', '/shop/*'];
}
    

Run from the project root:


php .\dotapper.php --optimize-modules
    

That writes app/modules/modulesAutoLoader.php. On later requests, load_modules() includes that file and only loads modules whose patterns match the current URL. initializeCondition($routeMatch) receives whether those patterns matched. Return truthy to run initialize(), falsy to skip heavy work (routes, fallbacks already in config, catalog boot). Re-run optimize after you change prefixes or add modules. A stale autoloader skips Shop on URLs it should own.

Method When it runs Return
initialize($dotApp) After the condition allows boot. Register routes and fallbacks here. void
initializeRoutes() Condition + --optimize-modules lazy map List of URL pattern strings. ['*'] = every request
initializeCondition($routeMatch) After pattern match, before initialize() Truthy to continue; falsy to skip initialize()
installation() Start of construct, before init.start void — runs install.php once
Module::optimize() DotApper --optimize-modules true, or the caught \Exception

installation() and install.php

If app/modules/Shop/install.php exists, installation() triggers dotapp.module.shop.install, require_onces the file, then renames it to installed_<md5>_install.php. That rename is only a one-shot guard. Versioned DDL still belongs in Installation.php with a shop_installations table. DB::migrate() is not implemented — do not call it. Preferred body of install.php:


<?php
use Dotsystems\App\Modules\Shop\Installation;

Installation::module('Shop')->install();
    

Full versioned installer walkthrough: How to create database migrations with Installation.php in DotApp PHP Framework.

settings() versus Config::module during init

In initialize() you set portable fallbacks with Config::module. Owner overrides are already in app/config.php from boot. $this->settings('apiUrl') reads app/modules/Shop/settings.php (value or null). That file is for facts Shop persists itself, not for prefix and secrets.

Complete module.listeners.php

File: app/modules/Shop/module.listeners.php. Loaded before init. No Shop GET/POST routes here.


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

use Dotsystems\App\Parts\Logger;
use Dotsystems\App\Parts\Router;

class Listeners extends \Dotsystems\App\Parts\Listeners
{
    public function register($dotApp)
    {
        $dotApp->on('shop.item.saved', function ($result, ...$data) {
            Logger::use('shop')->warning('Item saved', ['payload' => $data]);
        });

        $dotApp->on('dotapp.module.shop.init.end', function ($module) {
            Logger::use('shop')->warning('Shop init ended', [
                'name' => $module->modulename,
            ]);
        });

        Router::before(['POST'], ['/shop/*'], '#Shop:AuthGate@crc!');
    }
}

new Listeners($dotApp);
    

trigger('shop.item.saved', $result, $itemId) always returns the original $result. Listener exceptions abort remaining listeners — wrap risky bodies yourself. Route-scoped on($route, $event, $cb) returns false and does not register when the current request does not match.

Complete module.init.php

File: app/modules/Shop/module.init.php. Fallbacks, routes, lazy patterns, condition. Last line constructs the module.


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

use Dotsystems\App\Parts\Config;
use Dotsystems\App\Parts\Router;

class Module extends \Dotsystems\App\Parts\Module
{
    public function initialize($dotApp)
    {
        Config::module('Shop', 'prefix') ?? Config::module('Shop', 'prefix', '/shop');
        Config::module('Shop', 'itemsPerPage') ?? Config::module('Shop', 'itemsPerPage', 20);
        Config::module('Shop', 'enckey') ?? Config::module('Shop', 'enckey', bin2hex(random_bytes(16)));
        Config::module('Shop', 'public') ?? Config::module('Shop', 'public', true);

        if (Config::module('Shop', 'public') === false) {
            return;
        }

        $p = Config::module('Shop', 'prefix');

        Router::get($p . '/', 'Shop:Home@index!', Router::STATIC_ROUTE);
        Router::get($p . '/item/{id:i}', 'Shop:Home@item!');
        Router::post($p . '/contact', 'Shop:Contact@save!', Router::STATIC_ROUTE);
    }

    public function initializeRoutes()
    {
        return ['/shop', '/shop/*'];
    }

    public function initializeCondition($routeMatch)
    {
        return $routeMatch;
    }
}

new Module($dotApp);
    

Keep enckey as a module secret the owner overrides in app/config.php. Do not document or implement key-exchange internals in Shop. Generate local fallbacks with bin2hex(random_bytes(16)) and tell the owner to replace them. Routing verbs: How routing works in DotApp PHP Framework.

Gotchas

  • First matching route wins. Order registrations inside initialize() on purpose.
  • Router::hasRoute() is inverted relative to the English name. Prefer php dotapper.php --list-routes.
  • If initializeRoutes() during DotApper optimize does not return a one-dimensional list of strings, it throws \InvalidArgumentException.
  • A missing view later in the request returns "" and a log warning — that is render time, not init time. Still: do not assume init failure throws.
  • Do not leak encryption internals in listeners or init. Set keys; do not explain how to break them.

FAQ

Why both listeners and module.init.php?

Listeners run first so global hooks exist before Shop constructs. Routes and Config::module fallbacks stay in initialize() so lazy loading can skip them when the URL is not Shop.

How do I skip initialize() on unrelated URLs?

Return tight patterns from initializeRoutes() and return $routeMatch from initializeCondition(). Run --optimize-modules so unmatched modules are not even constructed fully.

Can I cancel init from an init.condition listener?

No. trigger() does not apply listener return values. Override initializeCondition() on Module.

Will install.php run on every request?

No. After the first successful include, the file is renamed. Put real idempotency in Installation.php anyway.

When is return ['*'] correct?

When Shop must boot on every URL (rare: a global widget). Prefer explicit /shop patterns.

What if I delete new Module($dotApp)?

The class is never constructed. No routes, no fallbacks, no install. Leave the line DotApper wrote.

Are Config::module overrides from app/config.php visible in initialize()?

Yes. app/config.php ran before load_modules(). The ?? fallback setter only fills keys that are still null.

Does event name case matter?

Names are lowercased. Register and trigger the same logical name; do not rely on mixed case.

See also