AI blog · DotApp PHP Framework 2.0
Events and listeners in DotApp PHP Framework
Shop events are names you trigger on the kernel: $dotApp->trigger('shop.item.saved', $payload, $itemId).
Names are lowercased. trigger() returns the first payload unchanged — listener return values are ignored.
Listener exceptions propagate and abort remaining listeners, so wrap risky bodies in try/catch.
module.listeners.php is included before module.init.php. Put subscriptions there, not GET/POST routes.
This article is a complete Shop pair: listeners file plus a save handler that triggers after insert.
Common mistakes
| Wrong | Right |
|---|---|
Expect trigger() to return what the listener returned |
It returns the original $result. Listeners are side effects |
Register Shop pages in module.listeners.php |
Subscribe to events (and optional global Router::before). Routes stay in initialize() |
| Rely on mixed-case event names | Names are lowercased. Use one spelling |
| Let a listener throw and take down the request | Wrap the body. Exceptions abort the rest of the list |
on($route, $event, $cb) and assume it always registers |
Returns false and skips registration when the current request does not match that route |
When to fire an event
After Shop persisted something another module may care about (item saved, order paid). Do not use events as a replacement for a function call inside the same controller. Boot order: Module initialization. Middleware hooks are not events — Middleware.
API
| Call | Returns |
|---|---|
$dotApp->on($event, $callback) |
Subscription; $sub->off() unsubscribes |
$dotApp->trigger($event, $result, ...$data) |
The same $result you passed in |
$dotApp->hasListener($event) |
bool |
$dotApp->offevent($event) |
$this — drop that name |
Events::on($event, $cb) |
Always registers |
Events::on($routePattern, $event, $cb) |
false if the current request path does not match |
Events::on($method, $routePattern, $event, $cb) |
false on method or path mismatch |
dotapp.middleware is an alias of dotapp.router.resolve.
Module lifecycle names (payload is the module instance):
dotapp.module.{name}.init.start, .init.condition, .init.end,
.loading, .loaded, .install — {name} is lowercase (shop).
Complete Shop listeners and trigger
File: app/modules/Shop/module.listeners.php. Last line constructs the class.
<?php
namespace Dotsystems\App\Modules\Shop;
use Dotsystems\App\Parts\Logger;
class Listeners extends \Dotsystems\App\Parts\Listeners
{
public function register($dotApp)
{
$dotApp->on('shop.item.saved', function ($result, ...$data) {
try {
Logger::use()->warning('Item saved', ['payload' => $data]);
} catch (\Throwable $e) {
Logger::use()->error('shop.item.saved listener failed', ['msg' => $e->getMessage()]);
}
});
$dotApp->on('dotapp.module.shop.init.end', function ($module) {
Logger::use()->warning('Shop init ended', [
'name' => $module->modulename,
]);
});
}
}
new Listeners($dotApp);
After a successful insert in the controller, fire the event. Do not wait for listeners to “approve” the save.
$newId = null;
DB::module('RAW')->q(function ($qb) use ($title) {
$qb->raw(
'INSERT INTO shop_items (title, created_at) VALUES (:title, :created_at)',
['title' => $title, 'created_at' => date('Y-m-d H:i:s')]
);
})->execute(
function ($result, $db, $execution_data) use (&$newId) {
$newId = $execution_data['insert_id'] ?? $db->inserted_id();
},
function ($error) {
\Dotsystems\App\Parts\Logger::use()->error('item insert failed', $error);
}
);
if ($newId === null) {
return ['code' => 200, 'body' => ['status' => 0, 'message' => 'Save failed']];
}
\Dotsystems\App\DotApp::dotApp()->trigger('shop.item.saved', true, (int) $newId);
return ['code' => 200, 'body' => ['status' => 1, 'id' => $newId]];
Database callbacks: Error handling and return values. Channel save wrapping: Secure forms.
FAQ
How do I unsubscribe?
Keep the object $sub = $dotApp->on(...) and call $sub->off().
To drop every listener for a name: $dotApp->offevent('shop.item.saved').
Why did on($route, $event, $cb) return false?
The current HTTP path did not match $route at registration time, so the listener was not added.
Prefer the two-argument form inside module.listeners.php unless you truly want request-scoped registration.
Is there a job queue?
Not in this class. Listeners run in the same request. Keep them short. Log and return.
shop.item.saved vs Shop.Item.Saved?
Same bucket after lowercase. Pick one spelling in Shop and keep it.
May a global Router::before live in listeners?
Yes — that file loads first. Use it for auth gates if you want.
Do not put crcCheck() there if save handlers also call it — the token burns on the first pass.
Request lifecycle.
Gate class: Middleware.
Can a listener change $result?
Not through its return value. trigger ignores those returns. Change data the listener owns (logs, cache), not the caller’s return.