AI blog · DotApp PHP Framework 2.0
How trigger with veto works in DotApp PHP Framework
Ordinary Events::trigger() is a post-success bus. Listener return values are ignored — they are not a vote.
When Shop must let another loaded module stop a reversible action before persist, it calls
Events::triggerWithVeto() and checks the returned Dotsystems\App\Parts\Veto.
Only that object stops dispatch. false, null, strings, and arrays stay ignored, so old listeners cannot accidentally veto.
Ordinary trigger() ignores even a returned Veto, so existing modules stay compatible.
This article is a complete Shop delete: the veto contract, the Veto class, and an Audit subscriber.
Common mistakes
| Wrong | Right |
|---|---|
Treat return false as a veto on trigger() |
trigger() always returns the original payload. Use triggerWithVeto() |
Replace a post-success .hook with a veto event |
Veto is before the action. The hook still fires after success |
Name it shop.item.saved or module.shop.item_delete.hook |
module.{lowercase_modulename}.{action_name}.veto |
Send $veto->message() or details() to the browser |
Map code() to your own product copy. Message is internal |
| Fire veto after the row is already gone | Call it immediately before the reversible persist |
| Put secrets, tokens, or request bodies on the payload | Ids, counts, flags only — same leak law as hooks |
| Expect an unloaded Audit listener to veto | Cover the Shop request in Audit Listeners::initializeRoutes() |
When to use triggerWithVeto
Use it when the owner of the action deliberately allows another module to refuse it: delete a template still in use, archive an invoice another module still references, drop a user that owns locked records.
Do not use it for ordinary saves, pager clicks, or “maybe someday”. Those stay silent, or they fire a post-success
module.{mod}.{name}.hook after the work is done —
Events and listeners.
API
| Call | Returns |
|---|---|
Events::trigger($event, $result, ...$data) |
The same $result. Listener returns ignored, including a Veto |
Events::triggerWithVeto($event, $result, ...$data) |
First Veto, or null if every listener allowed the action |
new Veto($code, $message = '', $details = []) |
Immutable object. Invalid $code throws \InvalidArgumentException |
$veto->code() |
Stable lowercase identifier for the caller’s switch |
$veto->message() |
Internal description for logs — not an automatic client string |
$veto->details() |
Copy of safe extra data (ids, counts). No secrets |
Names are lowercased. dotapp.catchall fires first on triggerWithVeto() the same way it does on trigger().
A throw in catchall skips the named listeners. Listener exceptions on the named event still propagate and abort the rest of the list.
The first Veto wins — later listeners do not run.
The Veto class
File: app/parts/Veto.php. Namespace Dotsystems\App\Parts. It is final.
$code must match /^[a-z][a-z0-9._-]{0,63}$/ (lowercase identifier, up to 64 characters).
Compare codes in PHP. Do not parse message() as a protocol.
use Dotsystems\App\Parts\Veto;
$veto = new Veto('template.in_use', 'Template is referenced.', [
'template_id' => 17,
]);
$veto->code(); // 'template.in_use'
$veto->message(); // internal only
$veto->details(); // ['template_id' => 17]
Complete Shop delete with veto
Fire immediately before delete. Handle Veto|null in the owner. Persist only when the result is null.
Document the name, timing, payload, and allowed codes in app/modules/Shop/.hooks under a Veto contracts heading.
use Dotsystems\App\Parts\Events;
use Dotsystems\App\Parts\Veto;
$payload = ['item_id' => (int) $itemId];
// Veto: module.shop.item_delete.veto
// Why: Audit or Inventory may still reference this row and must stop the delete.
// About: Shop is about to delete a catalog item.
// Params: item_id (int). No row body, no secrets.
// Use: in-use checks in other modules before persist.
$veto = Events::triggerWithVeto('module.shop.item_delete.veto', $payload);
if ($veto instanceof Veto) {
return [
'code' => 200,
'body' => [
'status' => 0,
'code' => $veto->code(),
'message' => 'This item cannot be deleted.',
],
];
}
// Persist only after every listener allowed the action.
Subscriber in another module’s module.listeners.php. Return new Veto(...) or return nothing.
Cover the Shop request in that listener’s initializeRoutes() or the callback is not even registered —
Independent listener routes.
use Dotsystems\App\Parts\Events;
use Dotsystems\App\Parts\Veto;
Events::on('module.shop.item_delete.veto', function ($result) {
$itemId = (int) ($result['item_id'] ?? 0);
if ($itemId > 0 && AuditStore::hasRequiredHistory($itemId)) {
return new Veto('audit.history_required', 'Audit history still references this item.', [
'item_id' => $itemId,
]);
}
return null;
});
Compatibility with trigger()
trigger('module.shop.item_delete.veto', $payload) still runs those listeners as side effects and returns $payload unchanged.
A returned Veto is discarded. That is intentional: old callers and old modules do not gain a silent stop.
Opt in with triggerWithVeto() at the owner call site.
FAQ
Why is return false ignored?
Years of listeners already return false, null, or leftover scalars. Treating those as a stop would change every existing module.
Only instanceof Veto is explicit.
Do I still fire a .hook after a successful delete?
Yes, if another module should log or sync the completed delete. Veto is the pre-check. The hook is the post-success notice.
What does the browser see?
Whatever Shop maps from $veto->code(). The kernel does not print message() or details().
Does catchall see veto events?
Yes. triggerWithVeto() fires dotapp.catchall first with ($result, $eventname, ...$data).
Do not persist from catchall.
Events and listeners — catchall.
The veto never fired. Why?
The subscriber’s listener file was not loaded for that URL. Give it its own
Listeners::initializeRoutes() covering the producer request, then run
php dotapper.php --optimize-modules.
See also
- Events and listeners 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
- Error handling and return values in DotApp PHP Framework
- Official documentation