Prejsť na obsah

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