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.
To watch every trigger in one place, subscribe to dotapp.catchall — that is the debug tap, not a business event.
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 |
Invent dotapp.all / persist only on dotapp.catchall |
Catchall is the debug tap. Keep named events for work. See below |
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.- Every
trigger()andtriggerWithVeto()exceptdotapp.catchallitself first fires catchall. A throw there skips the named event. - Listener returns on
trigger()are ignored. The opt-in stop istriggerWithVeto()+Veto— How trigger with veto works.
Debug tap: dotapp.catchall
This is not a separate kernel site. DotApp::trigger() always runs it first (DotApp 2.0) so you can see every trigger in one listener.
How the arguments are packed, the recursion guard, and a complete debug snippet:
Events and listeners — catchall.
| Event | When | Listener arguments |
|---|---|---|
dotapp.catchall |
Before every other trigger(). Not re-entered when the name is already catchall |
$result, then the original event name (lowercased), then that trigger’s extra arguments |
You will also see the rows below (router, SQL, dotapp.log, module lifecycle) plus every Shop name you fire.
Do not persist or send email from this listener. If you use Logger::use(), skip dotapp.log or you recurse.
A throw here skips the named event you were trying to debug.
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.
Why is there no kernel call site for catchall?
trigger() itself fires it. You do not search the kernel for trigger('dotapp.catchall') except the recursion-safe inner call.
Contract and sample: Events and listeners.
See also
- Events and listeners in DotApp PHP Framework
- How trigger with veto works in DotApp PHP Framework
- How independent listener routes work in DotApp PHP Framework
- How module initialization works in DotApp PHP Framework
- How to use the database in DotApp PHP Framework
- How routing works in DotApp PHP Framework
- Middleware in DotApp PHP Framework
- Error handling and return values in DotApp PHP Framework