The framework runs two event dispatchers in parallel, each fed by its own auto-discovery mechanism. They look identical from the subscriber's side — both register against Symfony\Component\EventDispatcher\EventSubscriberInterface — but they are wired to different dispatchers and so receive different events:
| Directory | Discovery | Dispatcher | What it covers |
|---|---|---|---|
App/EventSubscriber/ |
PHP_SF PhpSfEventDispatcher::addSubscriberDirectory() (called from public/index.php) + Symfony autoconfig via the App\: resource in config/services.yaml |
PHP_SF's private EventDispatcher + Symfony's EventDispatcher (the kernel.event_subscriber tag) |
PHP_SF /api/* and /page/* routes dispatched by Router::route(), plus Symfony controller requests handled by the framework |
There is only one subscriber directory in the template — App/EventSubscriber/. PHP_SF registers it via addEventSubscriberDirectory() (for events fired by PhpSfEventDispatcher during PHP_SF's own dispatch path), and Symfony's autoconfig registers every EventSubscriberInterface it finds under App\: as a kernel.event_subscriber on Symfony's own dispatcher (the services.yaml exclude path is '../App/EventListeners/' plural, which does not match the real App/EventSubscriber/ singular — so Symfony autoconfig scans it too). A subscriber placed in App/EventSubscriber/ therefore sees both event streams:
PHP_SF\System\Router fires events through PhpSfEventDispatcher, so subscribers in App/EventSubscriber/ see them.RouterListener (Symfony controllers in App/Http/SymfonyControllers/) fires events through Symfony's HttpKernel, so the same subscribers also see those.For documentation on the Symfony interface itself, see the Symfony EventDispatcher docs. The rest of this page documents the two PHP_SF-side pieces that are framework-specific.
App/EventSubscriber/PhpSfEventDispatcher (vendor/nations-original/sf/src/Core/PhpSfEventDispatcher.php) lazily instantiates a Symfony EventDispatcher and registers every EventSubscriberInterface implementation it finds in the directories registered via addEventSubscriberDirectory().
The framework already registers its own vendor/nations-original/sf/app/EventSubscriber/ for built-in subscribers. To add the application's directory, chain addEventSubscriberDirectory() on the Kernel builder in public/index.php:
$kernel = ( new PHP_SF\System\Kernel )
->addTranslationFiles( __DIR__ . '/../translations' )
->addControllers( __DIR__ . '/../App/Http/Controller' )
->addEventSubscriberDirectory( __DIR__ . '/../App/EventSubscriber' )
->setApplicationUserClassName( User::class )
->addTemplatesDirectory( 'templates', 'App\View' );
Multiple calls accumulate; each adds one more directory. The Kernel returns $this from each add*() call so the chain stays fluent.
Discovery is a recursive scan: any file matching *.php is parsed for a namespace ...; class|final class ...; declaration, and the resulting FQCN is registered if it implements Symfony\Component\EventDispatcher\EventSubscriberInterface. The classes must already be autoloadable via Composer PSR-4 when discovery runs.
In DEV_MODE === false, the discovered class list is cached via ca()->get('phpsf:event_subscribers') (APCu or Redis depending on adapter). After deploying new subscriber classes to production, run bin/console app:cache:clear.
In DEV_MODE === true, discovery runs on every request — new subscribers are picked up without cache clearing.
// App/EventSubscriber/RequestLoggingSubscriber.php
namespace App\EventSubscriber;
use PHP_SF\System\Core\PhpSfContext;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class RequestLoggingSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onRequest', 10],
];
}
public function onRequest( RequestEvent $event ): void
{
if ( ! $event->isMainRequest() ) {
return;
}
$context = PhpSfContext::current();
if ( $context === null ) {
return;
}
$route = $context->getRoute();
$request = r(); // Symfony Request — not exposed on PhpSfContext
// log the request, update counters, etc.
}
}
That's all that's required — the subscriber is found automatically on the next request.
App\: resource in services.yamlconfig/services.yaml registers every class under App/ as a service (with the per-path excludes in the _defaults: autoconfigure: true block). Any class that implements EventSubscriberInterface is automatically tagged kernel.event_subscriber and instantiated against Symfony's EventDispatcher — no manual wiring.
Note on the exclude path: the exclude path in services.yaml is '../App/EventListeners/' (plural) — but the actual directory is App/EventSubscriber/ (singular). The exclude is a no-op against the wrong path; Symfony autoconfig still scans the real App/EventSubscriber/ directory. This should be fixed on the framework side — the generated services.yaml template needs to reference App/EventSubscriber/ (singular, the real directory) instead of App/EventListeners/ (plural, which does not exist on disk).
For application subscribers, the App/EventSubscriber/ directory is registered against both dispatchers — PHP_SF's PhpSfEventDispatcher via addEventSubscriberDirectory(), and Symfony's autoconfig via the App\: resource. One class handles both event streams without needing separate copies.
Router::route() and Response::send() are the only sites that fire events through PhpSfEventDispatcher. The full list (from vendor/nations-original/sf/src/Router.php and vendor/nations-original/sf/src/Core/Response.php):
| Event | Constant | Where it fires |
|---|---|---|
kernel.request |
KernelEvents::REQUEST |
fired by Router::route() after route match, before middleware |
kernel.controller |
KernelEvents::CONTROLLER |
fired by Router::route() after middleware passes, before the controller method |
kernel.controller_arguments |
KernelEvents::CONTROLLER_ARGUMENTS |
fired by Router::route() after controller resolution, before argument binding |
kernel.response |
KernelEvents::RESPONSE |
fired by Router::sendRouteMethodResponse() (normal flow) and Router::route() (exception path) |
kernel.exception |
KernelEvents::EXCEPTION |
fired by Router::route() around the dispatch try/catch |
kernel.finish_request |
KernelEvents::FINISH_REQUEST |
fires only when the response has been sent, gated on PhpSfContext::current() being non-null |
kernel.terminate |
KernelEvents::TERMINATE |
same gate |
PHP_SF does not dispatch any RouterEvents::* constants — those do not exist in this framework's request lifecycle.
Subscriber priority is the standard Symfony tuple: [method, priority]. Higher priority runs first on kernel.request / kernel.controller; lower runs first on kernel.response. Priorities are scoped to the dispatcher (Symfony's vs PHP_SF's) — a Symfony-tagged subscriber's priority and a PHP_SF subscriber's priority do not interact.
Custom Symfony controllers under App/Http/SymfonyControllers/ and any Symfony-routed request under App/Http/Controller/ run inside Symfony's HttpKernel. The relevant events are the standard Symfony kernel events — kernel.request, kernel.controller, kernel.controller_arguments, kernel.response, kernel.finish_request, kernel.terminate, kernel.exception — plus any bundle-specific event (Symfony\Component\Mailer\Event\MessageEvent, etc.).
Symfony also dispatches kernel.request for routes that PHP_SF later handles (the Symfony kernel is the outermost). A subscriber registered with Symfony therefore sees BOTH Symfony-controller traffic and the outermost hook for PHP_SF traffic — but it does not see kernel.controller / kernel.controller_arguments / kernel.response for PHP_SF routes, because those events are dispatched by PhpSfEventDispatcher, not by Symfony. If you need to observe the full PHP_SF dispatch sequence, register the subscriber in App/EventSubscriber/.
For a request matched by PHP_SF's Router::route() (the typical /page/* and /api/* request), events fire in this order:
kernel.request — Symfony kernel fires it first (the outermost wrapper, before PHP_SF's Router even runs).kernel.request — Router::route() fires it via PhpSfEventDispatcher after the route is matched and PhpSfContext is set. PHP_SF subscribers see this.kernel.controller — Router::route() fires it after middleware passes.kernel.controller_arguments — Router::route() fires it after the controller is resolved.kernel.response — Router::sendRouteMethodResponse() fires it after the controller returns a response.kernel.finish_request — Response::send() fires it.kernel.terminate — Response::send() fires it.If the controller throws an unhandled exception, step 5 is replaced by:
5'. kernel.exception fires; if the listener calls setResponse() on the event, kernel.response fires for that response instead and the loop exits at step 7. Otherwise the exception is re-thrown.
PHP_SF\System\Core\PhpSfContext is a static value object set by PHP_SF's Router when a route is matched. It is available during and after request dispatch — from PHP_SF's kernel.request through kernel.terminate.
$context = PhpSfContext::current();
Returns null if no route has been matched yet (e.g. if called before PHP_SF's kernel.request fires, or outside a PHP_SF-dispatched request).
Available methods:
| Method | Returns | Description |
|---|---|---|
getRoute() |
route object | The matched route — same object as Router::$currentRoute |
getMiddleware() |
middleware config or null |
The middleware configuration for the matched route |
getKernel() |
Kernel |
The framework kernel instance |
current() |
?PhpSfContext (static) |
The current context, or null outside PHP_SF dispatch |
set() |
void (static) |
Replaces the current context — called by PHP_SF's router |
There is no getRequest() method — get the current Symfony Request via the global r() helper instead. PhpSfContext is only meaningful for PHP_SF-dispatched events; Symfony-dispatched controllers do not populate it (their context lives on the Request attribute bag).
| Use case | Mechanism |
|---|---|
| Logging, headers, auditing, request-id stamping for PHP_SF routes | App/EventSubscriber/ (PHP_SF auto-discovery) |
| Authorization, token gate, exception mapping for Symfony controller routes | App/EventListener/ (Symfony autoconfigure) |
| Cross-cutting concerns that must run for both PHP_SF and Symfony-controller routes (CSRF, security headers) | App/EventSubscriber/ — Symfony's autoconfig picks it up via the App\: resource and registers it on its own dispatcher as well, so the same class covers both paths |
| Application-level events fired by services (order placed, payment processed) | Inject Symfony\Component\EventDispatcher\EventDispatcherInterface and dispatch directly — neither auto-discovery mechanism is involved |
Subscriber in the wrong directory — App/EventSubscriber/ is the only subscriber directory the template ships with. PHP_SF's discovery scans it for PhpSfEventDispatcher registration, and Symfony autoconfig (via the App\: resource) scans it for kernel.event_subscriber registration on Symfony's own dispatcher — so one class covers both streams. There is no App/EventListener/ directory.
Stale production cache — in production, new subscribers in App/EventSubscriber/ won't fire on the PHP_SF dispatcher until the cache is cleared with bin/console app:cache:clear. If a subscriber works in dev but not in production, this is the first thing to check. Symfony autoconfig picks up the new subscriber immediately on the next request (services are re-discovered).
Class not autoloadable — PHP_SF discovery checks is_a($fqcn, EventSubscriberInterface::class, true), which requires the class to already be autoloadable. Since classes live under PSR-4 autoload, this branch is effectively unreachable.
PhpSfContext is null — PhpSfContext::current() returns null outside of PHP_SF-dispatched requests (Symfony controllers, console commands, tests that don't go through Router::init()). Always check for null before using the context.
Heavy operations in request subscribers — subscribers on kernel.request and kernel.controller run during the critical request path. Keep them fast. Queue heavy work via RabbitMQ or defer it to kernel.terminate / kernel.finish_request, which fire after the response has been sent.