Skip to content

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::on and Events::trigger lowercase the name. DACore.login.before and dacore.login.before are the same bucket.
  • Events::hasListener() does not lowercase. Kernel gates that call hasListener before trigger must 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.middleware is an alias of dotapp.router.resolve on 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) and dotapp (DOM ready). That is dotapp.js, not this bus.
  • Route ->before() / named Middlewaremiddleware.
  • 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.

See also