AI blog · DotApp PHP Framework 2.0
Built-in events and database triggers in DotApp PHP Framework
The kernel already fires a small set of events on boot, routing, SQL, logging, and module lifecycle.
You do not invent those names. You subscribe in module.listeners.php with Events::on and treat them as side effects — debug SQL, log a 404, notice that Shop finished initialize().
How the bus works (lowercase names, trigger() returns the first argument unchanged) is in
Events and listeners.
This article is the catalog: every PHP event the core actually triggers, plus ORM observe(), which is a different system.
Common mistakes
| Wrong | Right |
|---|---|
Invent db.query / eloquent.saved / kernel.boot |
Use the names below. The kernel does not fire anything else |
Subscribe to dotapp.load_modules.override “just to log” |
If that name has a listener, the default module loader does not run |
Subscribe to dotapp.invalidate.csrf.url.token for debug |
Any listener replaces the built-in CSRF path. Leave it alone |
Events::hasListener('DACore.login.before') |
hasListener does not lowercase. Check dacore.login.before |
Treat Entity observe('saved') as an Events name |
ORM observers live on that entity instance only |
Log dotapp.databaser.execute in production without a guard |
It fires on every statement. Gate it with debug, or log errors only |
Rules that apply to every name
Events::onandEvents::triggerlowercase the name.DACore.login.beforeanddacore.login.beforeare the same bucket.Events::hasListener()does not lowercase. Kernel gates that callhasListenerbeforetriggermust see the exact stored key — always lowercase when you check.- The listener signature is
function ($result, ...$data). The first argument is whatever the kernel passed as$result. Returns are ignored. - Exceptions abort later listeners. Wrap debug bodies in
try/catch. dotapp.middlewareis an alias ofdotapp.router.resolveon register only.
Boot and modules
| Event | When | Listener arguments |
|---|---|---|
dotapp.load_modules.override |
Before the module scan, only if a listener already exists | $dotApp, modules directory path |
dotapp.modules.loaded |
After every selected module has loaded | $module_asked (what the kernel was asked to load) |
dotapp.module.{name}.init.start |
Module constructor, after installation() |
The module instance |
dotapp.module.{name}.init.condition |
Gated by hasListener with the mixed-case module folder name |
$result (bool), then the module instance |
dotapp.module.{name}.loading |
Once, just before initialize() |
The module instance |
dotapp.module.{name}.loaded |
Once, just after initialize() |
The module instance |
dotapp.module.{name}.init.end |
End of the constructor (even if initialize() was skipped) |
The module instance |
dotapp.module.{name}.install |
A one-shot install.php is about to run |
The module instance |
{name} is the module folder, then lowercased by trigger — Shop becomes dotapp.module.shop.init.end.
dotapp.modules.loaded is the hook generated listeners already use: decide whether to claim GET / if nobody else did.
Do not subscribe to load_modules.override for tracing.
The kernel skips modulesAutoLoader.php and the directory scan when that name has any listener. You would have to load every module yourself.
init.condition is a trap. The gate is hasListener('dotapp.module.' . $modulename . '.init.condition') with the folder spelling (Shop), while Events::on stores dotapp.module.shop.init.condition.
Those strings do not match, so a normal subscription never runs. Override initializeCondition() on the module class instead.
Details: module initialization.
Router
| Event | When | Listener arguments |
|---|---|---|
dotapp.router.resolve |
Start of routing, every request | $path, $method |
dotapp.router.resolve.404 |
No route matched | None (first argument is null) |
A 404 listener wins over Router::errorHandle(404, …). If you only want to log, do not send a body and exit — you would swallow the default 404 page.
DACore registers a 404 listener only when hasListener('dotapp.router.resolve.404') is still false.
use Dotsystems\App\Parts\Events;
use Dotsystems\App\Parts\Logger;
Events::on('dotapp.router.resolve', function ($path, $method) {
Logger::use()->warning('route', ['path' => $path, 'method' => $method]);
});
Database (the ones you were missing)
These are not MySQL CREATE TRIGGER objects. The PDO and mysqli drivers fire PHP events around every execute().
Same names on both drivers.
| Event | When | Listener arguments |
|---|---|---|
dotapp.db.driver.set |
A driver is selected (lazy create of pdo / mysqli / custom) | The Databaser instance, then the driver name |
dotapp.databaser.execute |
After prepare/bind, immediately before execute() |
['query' => string, 'bindings' => array] |
dotapp.databaser.execute.success |
Cache hit, or a successful execute | Rows / ORM value / cached value, then $execution_data |
dotapp.databaser.execute.error |
Prepare or execute failed and an error callback exists | ['error' => …, 'errno' => …], then $execution_data |
After a live execute, $execution_data also has affected_rows, insert_id, num_rows, and (mysqli) result.
A cache hit still fires execute.success, but the success callback receives an empty execution bag — AIRULES already warns about that.
If there is no error callback, a failed prepare throws instead of firing execute.error.
Events::on('dotapp.databaser.execute', function ($data) {
Logger::use()->warning('sql', [
'query' => $data['query'] ?? '',
'bindings' => $data['bindings'] ?? [],
]);
});
Events::on('dotapp.databaser.execute.error', function ($error, $data) {
Logger::use()->error('sql failed', [
'error' => $error,
'query' => $data['query'] ?? '',
]);
});
That is the debug hook. Keep it off in production or you will write one log line per query.
CRUD itself still uses DB::module('RAW') callbacks — How to use the database.
ORM observe() — not Events
Optional DB::module('ORM') entities have observe($event, callable) on that instance.
Names: creating, saving, updating, saved, created, updated, deleting, deleted.
The callback receives the entity. Nothing is published on the Events bus. Another module cannot listen unless you also Events::trigger yourself.
Logger and CSRF
| Event | When | Listener arguments |
|---|---|---|
dotapp.log |
Every Logger::use()->log() (before level filters) |
$level, $message, $context, logger name, driver |
dotapp.invalidate.csrf.url.token |
Only if hasListener is already true during crcCheck() |
The Request object |
dotapp.log is useful to tee messages into another sink. Do not log from that listener with the same Logger::use() without a guard — you can recurse.
The CSRF name is a replacement hook, not a trace point.
If any listener exists, crcCheck() skips the built-in token check and returns whatever trigger returns (the Request object, which is truthy).
MUST NOT subscribe for debugging. You would disable CSRF.
Not PHP Events
- Browser boot:
dotapp-register(early) anddotapp(DOM ready). That isdotapp.js, not this bus. - Route
->before()/ namedMiddleware— middleware. - Your own
shop.item.saved— you trigger those. The kernel does not. - DACore names (
dacore.login.before, page render, permissions refresh) — catalogued in the DACore article How DACore built-in events work when that section is on.
FAQ
Is this a MySQL TRIGGER?
No. MySQL triggers live in the schema. These are PHP listeners around execute(). Do not CREATE TRIGGER from a module unless you own that DBA decision.
Why is hasListener false after I subscribed?
You probably passed mixed case. Store and check lowercase: Events::hasListener('dotapp.databaser.execute').
Can I rewrite the SQL from execute?
No. trigger ignores listener returns. Log, metrics, or abort the request yourself. Do not expect the driver to pick up a new query string.
Why is execution_data empty on success?
Query cache hit. The driver still fires execute.success with the cached rows as the first argument.