Events¶
Listeners are callables registered during bootstrap. Custom events can carry any payload:
use function Naf\{event, log};
event()->listen('product.created', function (object $product) {
log()->info('Product created: {id}', ['id' => $product->id]);
});
// After your application creates a product:
event()->dispatch('product.created', $product);
listen($event, $listener, $priority = 0) runs higher priorities first.
dispatch() returns an array of listener results. Use closures or [ListenerClass::class, 'handle'];
NAF constructs class-based listeners through the default container.
A complete custom event flow¶
An event connects application work to optional reactions. The code doing the work must dispatch it; registering a listener alone never causes it to run. This example records an import result without a database or external delivery.
Start from Your first application. Add these complete files, then run
composer dump-autoload and php bin/events-demo.php from the project root.
<?php
declare(strict_types=1);
namespace App\Listeners;
use Psr\Log\LoggerInterface;
final class RecordImport
{
public function __construct(private LoggerInterface $logger) {}
public function handle(string $source, int $count): string
{
$this->logger->info('Imported {count} records from {source}.', [
'count' => $count,
'source' => $source,
]);
return 'logged';
}
}
<?php
declare(strict_types=1);
use App\Listeners\RecordImport;
use function Naf\event;
require dirname(__DIR__) . '/bootstrap.php';
event()->listen('app.catalog.imported', [RecordImport::class, 'handle'], priority: 10);
event()->listen('app.catalog.imported', static function (string $source, int $count): string {
echo "Imported {$count} records from {$source}.", PHP_EOL;
return 'reported';
});
$results = event()->dispatch('app.catalog.imported', 'catalog.csv', 3);
echo implode(', ', $results), PHP_EOL;
Expect Imported 3 records from catalog.csv. followed by logged, reported.
The class listener receives the configured PSR-3 logger through constructor injection and
writes to logs/app.log. Dispatch arguments become listener arguments in the same order.
Priority 10 runs before priority 0; results follow that execution order.
Choose event names with an application or package prefix and keep their payload contract
stable, so an unrelated plugin listener cannot accidentally receive different arguments.
Listeners run synchronously in the dispatching process. An exception stops dispatch and propagates to the caller; the event manager does not undo earlier writes or deliveries. Keep required business rules in the application service, dispatch after the relevant work succeeds, and use Queue when a reaction should run in a worker.
Isolated listeners¶
Since v0.2.13, event()->isolated()->dispatch() runs every listener on its own. An
exception in one listener is logged as Event listener failed with the event name, the
listener (class and method, or the closure's file and line) and the exception; the remaining
listeners still run. If the logger fails, reporting falls back to PHP's error_log().
EventManager::isolated() returns a Naf\Core\IsolatedDispatcher.
It returns the results of the listeners that succeeded, in execution order, and accepts
names, payloads and event objects exactly like dispatch(). isolated() only changes this
one call; event()->dispatch() itself keeps propagating exceptions:
event()->isolated()->dispatch('app.catalog.imported', 'catalog.csv', 3);
event()->isolated()->dispatch(new OrderShipped('A-1'));
Use it for notifications about work that has already happened, where one broken listener
must not stop the others. Keep guards on dispatch(): an isolated dispatch also catches an
abort() or an authentication exception and only logs it, so the request would continue.
Object events¶
Since v0.2.6 the event may be an object, in which case its class is the name and the object itself is the payload:
final class OrderShipped
{
public function __construct(public string $order) {}
}
event()->listen(OrderShipped::class, fn(OrderShipped $shipped) => log()->info($shipped->order));
event()->dispatch(new OrderShipped('A-1'));
listen() accepts the class-name string. Event objects need no interface or base class;
existing string events can continue to be used alongside object events.
An event class defines the payload shape and supports IDE navigation and refactoring.
PHP does not verify class existence when evaluating SomeClass::class; a misspelled class
name there still produces a string. Static analysis can detect it, and constructing a
missing class with new fails. Keep listener names and dispatched classes consistent.
String names and event class names share one key space. Replacing a custom string name with a class name changes the event key; update its listeners in the same change.
Priorities, class listeners, return values, isolated()->dispatch() and dispatchForResponse()
behave identically either way.
Request lifecycle¶
The following events describe the normal HTTP path. Payload order is part of the API.
| Event | Payload | When |
|---|---|---|
request.start |
$request |
After registering the request in App::handle() (called by run()) |
route.matching |
$uri, $method |
Before searching routes |
route.matched |
$route array |
After finding a match |
route.not_found |
$uri, $method |
When no registered path matches |
controller.calling |
$request, $controller, $action |
Before invoking the handler; controller is null for a closure |
controller.called |
$request, $controller, $action, $response |
After invoking the handler |
exception |
$exception |
When request handling throws |
response.send |
$response |
During response finalization |
response.header |
$response |
Before sending HTTP headers |
response.body |
$response |
Before writing the body, after headers |
response.end |
$response |
After writing the body |
With Framework 0.2.12+, request handling through handle() runs the request, route,
controller and exception hooks, followed by response.send during finalization.
response.header, response.body and response.end run only during emission, for example
through app()->run().
Event::REQUEST_END (request.end) and Event::REQUEST_BODY (request.body) remain defined
as deprecated compatibility constants in Framework 0.2.12+. The core does not dispatch them;
there is no replacement body/end event. Use request.start to observe the registered request.
There is no response.sending event.
Events used by the published plugins¶
Installed plugins register listeners on these events. Your own listeners run alongside them; listeners with the same priority run in registration order.
| Package | Event | What the listener does |
|---|---|---|
naf/form |
controller.calling |
Checks the CSRF token and aborts with 400; see Forms |
naf/i18n |
request.start |
Selects the language from ?lang, the lang cookie or Accept-Language |
naf/i18n |
response.header |
Returns the response with a lang cookie when the language was chosen or changed |
naf/mcp |
response.body |
Enables implicit flushing for text/event-stream responses |
naf/rbac |
dispatches Naf\Rbac\Events\GrantsChanged |
An object event after grants change; see RBAC |
Framework 0.2.9+ chains response.header listeners through EventManager::dispatchResponse().
Each listener receives the response returned by the previous listener. Application headers
and the language cookie from naf/i18n therefore survive together, provided listeners modify
the response they receive. A return value other than ResponseInterface leaves it unchanged.
Framework 0.2.8 and older
Every header listener receives the original response and only the last returned replacement survives. An application header listener can discard the i18n language cookie, or vice versa. Update the framework, or add those headers in controllers or through the web server.
Change a response¶
PSR-7 responses are immutable: withHeader() returns a new response. Return it from a
response.header listener:
use Naf\Core\Event;
use Psr\Http\Message\ResponseInterface;
use function Naf\event;
event()->listen(Event::RESPONSE_HEADER, function (ResponseInterface $response) {
return $response->withHeader('X-Application', 'My NAF app');
});
The returned response becomes the next header listener's argument. Listeners run in priority
order, with equal priorities preserving registration order. Returning a response from
controller.called or response.send does not replace the response being sent.
The exception hook uses dispatchForResponse(): it selects the last returned response
without chaining replacements. See Errors and aborting.
The response-body and response-end events are too late to change headers. Raw fatal-error emission can bypass this normal event path; do not depend on these listeners for cleanup that must run after every possible PHP failure.