Prejsť na obsah

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:

  1. If the name is not dotapp.catchall, it calls trigger('dotapp.catchall', $result, $eventname, ...$data) and runs those listeners first.
  2. Then it runs listeners registered for $eventname.
  3. 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