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, then load() when allowed (loading → load_libraries() → initialize($dotApp) → loaded), 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.
Listeners have a separate map: Listeners::initializeRoutes() can wake callbacks without running initialize().
Old modules that omit that method inherit the module map. An old optimizer file that only exports $modules still boots.
Independent listener walkthrough: How independent listener routes work.
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. Listener masks may differ from module masks. |
Return ['*'] from a heavy Shop so another module can subscribe. |
Give the subscriber listener-only routes. Keep Shop’s initialize() on Shop URLs. |
Skip new Module($dotApp); at the bottom of module.init.php. |
DotApper writes that line. Keep it. That is the constructor entry. |
Return ['*'] or a bare /{path*} from initializeRoutes() on a heavy public module. |
Return this module’s prefixes. A public catch-all is /{path*}{not:/admin*|…} on the wake string, then --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()
index.phpdefines__ROOTDIR__and includesapp/config.php.app/config.phpregisters drivers, databases, secrets, thennew \Dotsystems\App\DotApp().- Unless maintenance is on,
DotApp::DotApp()->load_modules()runs. - Optional event
dotapp.load_modules.overridecan replace the scan. If no listener, the default scan runs. - If
app/modules/modulesAutoLoader.phpexists (from--optimize-modules), the kernel matches two maps: listener masks, then module masks. A v1 file that only has$modulesstill works — listeners reuse that map. Otherwise every module directory is considered. - Matching
module.listeners.phpfiles load first. Then matchingmodule.init.phpfiles /Moduleconstructs run. - Framework default routes (assets) register.
DotApp::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::DotApp());.
register($dotApp) keeps the framework signature; subscribe with Events::on(...) and optional 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::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:
installation()— ifinstall.phpexists, firedotapp.module.shop.install, include it, rename it.dotapp.module.shop.init.start— always.initializeConditionAndListener()— URL patterns frominitializeRoutes(), theninitializeCondition($routeMatch). If a listener is bound todotapp.module.shop.init.condition, that event fires, buttrigger()returns the original payload unchanged. Listener return values are ignored.- If the condition is truthy (or DotApper is running):
load()→dotapp.module.shop.loading→load_libraries()→initialize($dotApp)→dotapp.module.shop.loaded. 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 |
Start of load(), before load_libraries() and initialize($dotApp) — canonical Extender hook |
dotapp.module.{name}.loaded |
After initialize($dotApp) — too late for Extender |
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. Current format (v2) exports $modules, $listeners, and $modulesAutoLoaderVersion = 2.
On later requests, load_modules() includes that file, registers matching listeners, then fully initializes matching modules.
A leftover v1 file with only $modules remains compatible: listeners share the module map.
Listeners::initializeRoutes() may return a different list so Audit can subscribe on Shop’s API without booting Audit’s pages —
Independent listener routes.
Omit that method and the listener inherits Module::initializeRoutes() exactly.
initializeCondition($routeMatch) receives whether the module patterns matched. Return truthy to run initialize(), falsy to skip heavy work (routes, fallbacks already in config, catalog boot).
A public catch-all must carry {not:/admin*|…} on the wake string — the same text the optimizer stores.
Do not wake on /{path*} and then skip /admin only in initializeCondition.
How URL {not:} selectors work.
Re-run optimize after you change prefixes, listener masks, 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 |
Module::initializeRoutes() |
Condition + --optimize-modules module map |
List of URL pattern strings. ['*'] = every request |
Listeners::initializeRoutes() |
Listener wake-up map (optimizer v2 $listeners) |
List of URL pattern strings, or omit / return null to inherit the module map |
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.
After tables exist, Shop controllers use QueryBuilder or named RAW (WHERE id = :iduser plus ['iduser' => $id]):
How to use the database in DotApp PHP Framework.
$qb->raw() counts every ?, including column comments — never COMMENT 'SMS?' in installer SQL.
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\DotApp;
use Dotsystems\App\Parts\Events;
use Dotsystems\App\Parts\Logger;
class Listeners extends \Dotsystems\App\Parts\Listeners
{
public function initializeRoutes()
{
return ['/shop', '/shop/*', '/api/v1/auth/Shop', '/api/v1/auth/Shop/*'];
}
public function register($dotApp)
{
Events::on('shop.item.saved', function ($result, ...$data) {
Logger::use('shop')->warning('Item saved', ['payload' => $data]);
});
Events::on('dotapp.module.shop.init.end', function ($module) {
Logger::use('shop')->warning('Shop init ended', [
'name' => $module->modulename,
]);
});
}
}
new Listeners(DotApp::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.
Pre-action stop: Trigger with veto.
Listener-only masks: Independent listener routes.
Every trigger() except dotapp.catchall itself first fires that debug tap — see
Events and listeners.
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\DotApp;
use Dotsystems\App\Parts\Config;
use Dotsystems\App\Parts\Router;
use Dotsystems\App\Parts\Translator;
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);
Config::module('Shop', 'locale') ?? Config::module('Shop', 'locale', 'en_us');
Translator::setDefaultLocale('en_us');
Translator::loadLocaleFile('Shop:sk_sk.json', 'sk_sk');
Translator::loadLocaleFile('Shop:de_de.json', 'de_de');
Translator::setLocale((string) Config::module('Shop', 'locale'));
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::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.
The channel working key is separate: app.c_enc_key, a per-session key, extra keys, and key update. The framework builds it.
How DotApp PHP Framework protects the browser-to-PHP channel.
Routing verbs: How routing works in DotApp PHP Framework.
Load locale JSON in this same initialize():
How translations and i18n work in DotApp PHP Framework.
Gotchas
- First matching route wins. Order registrations inside
initialize()on purpose. Router::hasRoute()is inverted relative to the English name. Preferphp 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.
-
Do not put
crcCheck()on a globalRouter::beforeif handlers also call it. The first success burns the one-time token; the second call is HTTP 400. Request lifecycle — crcCheck once.
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 Module::initializeRoutes() and return $routeMatch from initializeCondition().
If another module must still subscribe on those URLs, give that module’s listener its own masks — do not wake Shop everywhere.
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::DotApp())?
The class is never constructed. No routes, no fallbacks, no install. Leave the construct line. DotApper still writes new Module($dotApp) because the include has $dotApp in scope — new Module(DotApp::DotApp()) is the same instance and the preferred form.
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.
Where do I register Extender::extend()?
In Listeners::register() only subscribe
Events::on('dotapp.module.{owner}.loading', …) and call extend() inside that
callback with a controller string. Cover the owner’s known URLs on
Listeners::initializeRoutes(). Matching listeners run before any module constructs, so the
subscription exists before the owner boots. .loading fires only if the owner actually loads,
still before load_libraries() and initialize().
Direct extend() in register() is not canonical — the listener route can match
while the owner’s initializeCondition later skips load.
Do not wait for dotapp.module.{owner}.loaded — that fires after the owner’s initialize().
Do not use init.start — that fires before the condition.
Do not call extend() from this module’s initialize() — map order can run the owner first.
How Extender works.
See also
- Built-in events and database triggers in DotApp PHP Framework
- How independent listener routes work in DotApp PHP Framework
- How Extender works in DotApp PHP Framework
- How trigger with veto works in DotApp PHP Framework
- Events and listeners in DotApp PHP Framework
- How translations and i18n work in DotApp PHP Framework
- How to create a module in DotApp PHP Framework
- How app/config.php works in DotApp PHP Framework
- How URL {not:} selectors work in DotApp PHP Framework
- How routing works in DotApp PHP Framework
- How to create database migrations with Installation.php in DotApp PHP Framework
- How to use the database in DotApp PHP Framework
- Official documentation