AI blog · DotApp PHP Framework 2.0
Events and listeners in DotApp PHP Framework
Shop events are names you trigger on the Events facade: Events::trigger('shop.item.saved', $payload, $itemId).
Names are lowercased. trigger() returns the first payload unchanged — listener return values are ignored.
They are not a veto. The opt-in stop is Events::triggerWithVeto() plus a Veto object —
How trigger with veto works.
Listener exceptions propagate and abort remaining listeners, so wrap risky bodies in try/catch.
Every trigger() and triggerWithVeto() except dotapp.catchall itself first fires dotapp.catchall so you can watch every event in one place (debug).
module.listeners.php is included before module.init.php. Put subscriptions there, not GET/POST routes.
Listeners may declare their own URL masks with Listeners::initializeRoutes() so a subscriber wakes without booting its module —
Independent listener routes.
This article is a complete Shop pair: listeners file plus a save handler that triggers after insert.
Common mistakes
| Wrong | Right |
|---|---|
$dotApp->on(...) / $dotApp->trigger(...) |
Events::on / Events::trigger. Need the kernel? DotApp::DotApp() |
Expect trigger() to return what the listener returned |
It returns the original $result. Listeners are side effects. Opt-in stop: triggerWithVeto() |
Treat return false as a veto |
Ignored on both APIs. Only new Veto($code, $message, $details) stops triggerWithVeto() |
| Use Events to replace a method result (a quote, a total) | Extender::extend plus the owner’s exists() / call() — How Extender works |
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 |
Persist, email, or cache only on dotapp.catchall |
Keep a named event (shop.item.saved). Catchall is the debug tap |
Logger::use() from catchall without skipping dotapp.log |
That log line fires dotapp.log, which re-enters catchall. Skip that name, or use error_log |
| Let a catchall listener throw | A throw there skips the named event. Never rethrow |
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 |
|---|---|
Events::on($event, $callback) |
Subscription; $sub->off() unsubscribes. Always registers |
Events::trigger($event, $result, ...$data) |
The same $result you passed in. Listener returns ignored |
Events::triggerWithVeto($event, $result, ...$data) |
First Veto, or null. Ordinary returns still ignored |
Events::hasListener($event) |
bool |
Events::offevent($event) |
Drop every listener for that name |
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).
Full kernel catalog (SQL execute, 404, logger, CSRF hook):
Built-in events and database triggers.
Debug tap for every name: dotapp.catchall.
Pre-action stop: Trigger with veto.
Opt-in stop: triggerWithVeto and Veto
trigger() will never become a vote. When Shop must let another loaded module refuse a delete (or similar reversible action), it calls
Events::triggerWithVeto('module.shop.item_delete.veto', $payload) before persist.
The return is Veto|null. Only new \Dotsystems\App\Parts\Veto($code, $message, $details) short-circuits.
false from an old listener is still ignored. Ordinary trigger() ignores a Veto too, so old call sites stay safe.
The subscriber must be loaded on that request — give it listener-only routes if its module pages live elsewhere.
Complete Shop delete: How trigger with veto works.
Debug every trigger: dotapp.catchall
Added in DotApp 2.0 so you can see every trigger in one listener.
Both Events::trigger($eventname, $result, ...$data) and Events::triggerWithVeto(...) lowercase the name, then:
- If the name is not
dotapp.catchall, it callstrigger('dotapp.catchall', $result, $eventname, ...$data)and runs those listeners first. - Then it runs listeners registered for
$eventname. - It returns the original
$result. Listener returns are ignored in both steps.
Triggering dotapp.catchall itself does not re-enter catchall (that is the recursion guard).
Do not invent a second “all events” name. This is the one.
| You call | Catchall listener receives | Named listener receives |
|---|---|---|
Events::trigger('shop.item.saved', true, $id) |
function ($result, $firedName, ...$data) → true, 'shop.item.saved', $id |
function ($result, ...$data) → true, $id |
Use catchall to trace “what fired on this request?”.
Keep business work on the named event.
A throw in catchall aborts the inner trigger, so the named listeners never run.
Catchall also sees kernel names (router, SQL, logger, module lifecycle) — it is noisy. Gate it or leave it off in production.
If you log with Logger::use(), skip $firedName === 'dotapp.log' or you recurse. error_log does not hit the bus.
Optional debug block inside register() in app/modules/Shop/module.listeners.php. Remove it before production.
Events::on('dotapp.catchall', function ($result, $firedName, ...$data) {
try {
if ($firedName === 'dotapp.log') {
return;
}
error_log('DotApp event ' . $firedName);
} catch (\Throwable $e) {
// never rethrow — a throw here skips the named event
}
});
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\DotApp;
use Dotsystems\App\Parts\Events;
use Dotsystems\App\Parts\Logger;
class Listeners extends \Dotsystems\App\Parts\Listeners
{
public function register($dotApp)
{
Events::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()]);
}
});
Events::on('dotapp.module.shop.init.end', function ($module) {
Logger::use()->warning('Shop init ended', [
'name' => $module->modulename,
]);
});
}
}
new Listeners(DotApp::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\Parts\Events::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 = Events::on(...) and call $sub->off().
To drop every listener for a name: Events::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 on trigger(). That API ignores those returns. Change data the listener owns (logs, cache), not the caller’s return.
The only structured stop is triggerWithVeto() + Veto —
How trigger with veto works.
What is dotapp.catchall?
The debug tap. Every trigger() except catchall itself fires it first, with ($result, $eventname, ...$data).
Subscribe once to see every name. Do not put persist or email only there. Details: Debug every trigger.
Why did shop.item.saved not run?
A dotapp.catchall listener threw. Catchall runs first. An exception there skips the named event. Never rethrow from catchall.
See also
- How trigger with veto works in DotApp PHP Framework
- How Extender works in DotApp PHP Framework
- How independent listener routes work in DotApp PHP Framework
- Built-in events and database triggers in DotApp PHP Framework
- How module initialization works in DotApp PHP Framework
- Middleware in DotApp PHP Framework
- Controllers and Response in DotApp PHP Framework
- Error handling and return values in DotApp PHP Framework
- How to create a module in DotApp PHP Framework
- Official documentation