Events
The event manager is a list of callables per event name, run in priority order when that name fires.
Pop\Application::run() fires five of them at fixed points, which is how authentication, CORS handling,
request logging and audit trails attach to a request without any of them living in a controller. You can fire
your own names too, and the same manager handles both.
A listener fires at a point; a middleware handler wraps the dispatch. Reach for an event when the work happens at a known moment and does not need to see the other side of the dispatch, and for middleware when it does.
The Application Lifecycle Events#
Six names, fired by run() in this order. Each listener's first parameter is the application object, except
app.error, where it is the throwable and the application comes second.
| Event | When it fires | First parameter |
|---|---|---|
app.init |
at the top of run(), before anything else |
$application |
app.route.pre |
before the router matches | $application |
app.dispatch.pre |
after the match, before the dispatchable runs | $application |
app.dispatch.post |
after the dispatchable returns | $application |
app.error |
on any Throwable escaping run() |
$exception |
app.shutdown |
last, from a finally — see below |
$application |
app.init fires inside run(), not in the constructor — an application that is built but never run fires
nothing. app.dispatch.pre is the useful one: the router has matched, so
$application->router()->getDispatchable() is the controller about to run, and the request has not been
handled yet. That's where the scaffolded CORS preflight listener lives.
<?php
namespace App\Http\Event;
use Pop\Application;
class Audit
{
public static function record(Application $application): void
{
if (!$application->router()->hasDispatchable()) {
return;
}
$application->getService('logger')->info(
$application->router()->getDispatchableClass() . '::' . $application->router()->getAction()
);
}
}
app.dispatch.pre fires whether or not a route matched, which is why the listener above checks
hasDispatchable() first. app.dispatch.post fires at the end of the same block, and run()'s default
$exit of true means the 404, 405 and maintenance responses call exit() before reaching it — so reach
for app.shutdown when you want teardown that runs on every path.
Teardown with app.shutdown#
app.shutdown fires last, from a finally inside run(), so it runs however the request ended — a normal
dispatch, a listener that aborted, or a Throwable on its way out. It is the hook for work that has to happen
either way: flushing a buffered log, closing a handle, recording a request's duration.
$app->on('app.shutdown', function(Pop\Application $application) {
$application->getService('logger')->info('request complete', [
'route' => $application->router()->getRouteMatch()?->getOriginalRoute()
]);
});
On the error path it fires after app.error, so a listener there sees whatever the error listener already
recorded. On an AbortException it fires even though nothing was dispatched.
The one thing it cannot survive is exit(), which PHP does not run finally blocks past. That covers
run()'s default $exit of true, where the router's own 404, 405 and maintenance responses exit before
returning, and any action that calls exit() or die() itself. Run with run(false) and send the response
yourself if you want shutdown listeners to fire on those paths too.
Registering a Listener#
on() on the application takes a name, an action and an optional priority, and forwards to the event
manager. An action is anything Pop\Utils\CallableObject accepts, which is the same set of forms the
service locator takes:
$app = new Pop\Application();
$app->on('app.dispatch.pre', 'App\Http\Event\Audit::record', 100);
$app->on('app.dispatch.pre', function(Pop\Application $application) { /* ... */ });
$app->on('app.dispatch.post', ['App\Http\Event\Audit', 'record']);
A 'App\Http\Event\Audit->record' string constructs the class and calls the method on the new instance,
and a bare 'App\Http\Event\Audit' constructs it and passes the parameters to the constructor. Static
methods are the common choice because a listener rarely has state worth keeping.
Config files carry listeners too, as a list of name/action/priority arrays. The wrapping key is a list,
not a map, so two listeners for the same event are two entries:
<?php
return [
'events' => [
[
'name' => 'app.dispatch.pre',
'action' => 'App\Http\Event\Audit::record',
'priority' => 100
],
[
'name' => 'app.dispatch.post',
'action' => 'App\Http\Event\Audit::flush'
]
]
];
An entry missing either name or action is skipped silently. Prefer on() inside load() for anything
conditional — attached only for HTTP, only in production — and the config array for listeners that always
apply. A module's own config takes the same events key; see Modules.
Both routes end at the same object. $app->events() is the Pop\Event\Manager, on() and off() on the
application forward to it unchanged, and events()->has('app.error') answers whether anything is listening
for a name at all:
$app = new Pop\Application();
var_dump($app->events()->has('app.error')); // false
$app->on('app.error', 'App\Http\Event\Audit::record');
var_dump($app->events()->has('app.error')); // true
Priorities#
on()'s third argument orders listeners for one event name, and higher runs first. The default is 0,
so a listener given 1 runs before an unprioritized one and a listener given -1 runs after.
$app = new Pop\Application();
$app->on('app.dispatch.pre', 'App\Http\Event\Audit::authenticate', 100);
$app->on('app.dispatch.pre', 'App\Http\Event\Audit::authorize', 50);
$app->on('app.dispatch.pre', 'App\Http\Event\Audit::record');
Give any two listeners whose order matters distinct priorities — including one you add and one a module
added before you. The queue behind an event name is an SplPriorityQueue, which leaves order among equal
priorities unspecified.
What a Listener Receives#
Listeners are called positionally, and the parameter list is the event's own values followed by two more:
$result, the previous listener's return value, and $event, the event object itself. Declare as many as
you need and stop:
$app = new Pop\Application();
$app->on('app.route.pre', function(Pop\Application $application, mixed $result, Pop\Event\AbstractEvent $event) {
// $result is the value returned by the previous app.route.pre listener
});
$app->on('app.error', function(\Throwable $exception, Pop\Application $application, mixed $result) {
error_log($exception->getMessage());
});
app.error carries two values of its own, so $result is its third parameter and $event its fourth. Every
other lifecycle event carries one, so $result is second and $event third.
The first listener to run receives false as $result, since the chain starts from an empty array of
previous results. Test for the value you expect rather than for emptiness.
The full list of return values for an event is available afterward from the manager, in the order the listeners ran:
$app = new Pop\Application();
$app->on('order.placed', function() { return 'first'; }, 10);
$app->on('order.placed', function() { return 'second'; });
$app->trigger('order.placed');
var_dump($app->events()->getResults('order.placed')); // ['first', 'second']
Stopping and Aborting#
A listener that has done everything the event needs can halt the rest of the chain by calling
stopPropagation() on the event object. Remaining listeners for that name do not run; the request carries on
normally.
$app = new Pop\Application();
$app->on('app.dispatch.pre', function(Pop\Application $application, mixed $result, Pop\Event\AbstractEvent $event) {
$event->stopPropagation();
}, 100);
Stopping the whole request is a different call. Pop\Event\AbortException thrown from any listener makes
run() return immediately: nothing is dispatched, app.error is not triggered — the exception is
caught by name and swallowed on purpose — and app.shutdown still fires.
$app = new Pop\Application();
$app->on('app.route.pre', function(Pop\Application $application) {
if ($application->isDown()) {
throw new Pop\Event\AbortException('Application is in maintenance mode.');
}
});
Because nothing is dispatched, a listener aborting a request is responsible for the response itself — send it
before you throw. Any other exception from a listener behaves like an exception from anywhere else in
run(): app.error fires and the exception is rethrown, which
Error Handling covers in full.
Firing Your Own Events#
trigger() fires any name with any parameters, and there's nothing special about the app.* names — they
are the five the framework happens to fire. Your own events use the same manager, the same priorities and the
same result chaining:
$app = new Pop\Application();
$app->on('order.placed', function(int $orderId, float $total, Pop\Application $application) {
// ...
});
$app->trigger('order.placed', ['orderId' => 1024, 'total' => 39.95]);
The array keys are documentation, not binding: parameters arrive positionally in array order, and
Application::trigger() appends the application itself to the end of your array if it is not already in
there. $result and $event follow it, so the signature above is the full one.
A model or a service that wants to announce something has no application handle of its own, and Pop\App is
the way to reach one — including the case where there is no application at all, which is what the null-safe
call covers:
Pop\App::get()?->trigger('order.placed', ['orderId' => 1024, 'total' => 39.95]);
That keeps the class usable in a test or a worker that never built an application, and it keeps the listeners out of the model. Models covers where that boundary sits.
Removing a listener is off(), and it takes back whatever you registered — a closure, a string, an
[$object, 'method'] array or a Pop\Utils\CallableObject you built yourself. on() wraps the action in a
CallableObject on the way in and off() normalizes your argument the same way before comparing, so the
wrapper never gets in the way:
$app = new Pop\Application();
$audit = function(int $orderId) { /* ... */ };
$app->on('order.placed', $audit);
$app->on('order.placed', 'App\Http\Event\Audit::record');
$app->off('order.placed', $audit);
$app->trigger('order.placed', ['orderId' => 1024]); // only Audit::record runs
The comparison is on the callable itself and is strict, so it matches on identity. Two closures with the
same body are two objects, and a listener registered as [$reporter, 'flush'] is removed by that same
instance and not by another of the same class.
Hand off() the same expression you handed on(). The comparison is against the callable as written, so
'App\Audit::record' and ['App\Audit', 'record'] name the same method but do not match each other.
off() reports nothing either way: an action that matches no listener, or a name nothing is listening for,
is a silent no-op.
Typed Events#
Pop\Event\Manager is a PSR-14 EventDispatcherInterface and ListenerProviderInterface, and the five
lifecycle hooks each have an event class: Pop\Event\InitEvent, RoutePreEvent, DispatchPreEvent,
DispatchPostEvent and ErrorEvent. listen() registers against the class instead of the name, and the
listener receives the event object as its only argument:
$app = new Pop\Application();
$app->events()->listen(Pop\Event\DispatchPreEvent::class, function(Pop\Event\DispatchPreEvent $event) {
$application = $event->application();
});
$app->events()->listen(Pop\Event\ErrorEvent::class, function(Pop\Event\ErrorEvent $event) {
error_log($event->exception()->getMessage());
});
Both styles reach the same dispatch() call, so registering either way works for the five lifecycle events —
and typed listeners for an event run before its name-indexed listeners, whatever the priorities on either
side. Matching is by exact class, with no inheritance walk, so listening on Pop\Event\AbstractEvent matches
nothing.
One asymmetry is worth knowing. trigger() always builds a generic Pop\Event\Event, so
$app->trigger('app.dispatch.pre') from your own code reaches every on('app.dispatch.pre', ...) listener
and no listen(DispatchPreEvent::class, ...) listener at all. Only run() constructs the typed classes.
The event manager exposes more than this page covers — getListenersForEvent(), and dispatching a
StoppableEventInterface object of your own — see the popphp README.
See Also#
- Applications & Bootstrap — where
on()calls belong inload() - Configuration — the
eventsconfig key - Error Handling — what
app.erroris for and what rethrows after it - Middleware — the other way to wrap a request, and when to prefer it
- Modules — a module contributing listeners to the same manager
- popphp README — the full event manager API surface