The framework uses Symfony's standard EventSubscriberInterface for its event system. Subscribers are discovered automatically by scanning registered directories — no manual registration is required. The framework fires standard Symfony kernel events at each stage of request dispatch, and your subscribers receive them through the same mechanism Symfony uses everywhere else.
For Symfony's own documentation on writing event subscribers, see the Symfony EventDispatcher documentation.
When PhpSfEventDispatcher is first used (lazily on first dispatch, or on boot in production), it recursively scans all registered subscriber directories for PHP class files. For each file it finds, it checks is_a($fqcn, EventSubscriberInterface::class, true). Classes that implement EventSubscriberInterface are registered automatically.
Classes must already be autoloadable (via Composer PSR-4 autoloading) before discovery runs. No manual registration, no service tags, no YAML configuration.
When (DEV_MODE === false) the discovered class list is cached via APCu/Redis under the key phpsf:event_subscribers. The cache is populated once and reused for all subsequent requests. Clear it with app:cache:clear after deploying new subscriber classes.
Create a class in App/EventSubscriber/, implement EventSubscriberInterface, and declare which events it handles in getSubscribedEvents():
// 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',
];
}
public function onRequest( RequestEvent $event ): void
{
$context = PhpSfContext::current();
if ( $context === null )
return;
$route = $context->getRoute();
$request = $context->getRequest();
// log the request, update counters, etc.
}
}
That's all that's required. The subscriber is found automatically on the next request.
The framework automatically scans Platform/app/EventSubscriber/ for its own built-in subscribers. To register your application's subscriber directory, call addEventSubscriberDirectory() on the kernel during boot in public/index.php:
$kernel = ( new PHP_SF\Kernel() )
->addTranslationFiles( __DIR__ . '/../translations' )
->addControllers( __DIR__ . '/../App/Http/Controller' )
->addEventSubscriberDirectory( __DIR__ . '/../App/EventSubscriber' )
->setHeaderTemplateClassName( header::class )
->setFooterTemplateClassName( footer::class )
->setApplicationUserClassName( User::class )
->addTemplatesDirectory( 'templates', 'App\View' );
You can call this method multiple times to register additional directories:
->addEventSubscriberDirectory( __DIR__ . '/../App/EventSubscriber' )
->addEventSubscriberDirectory( __DIR__ . '/../modules/payments/EventSubscriber' )
The framework router fires standard Symfony KernelEvents at each stage of request dispatch. All of these are available to any registered subscriber.
| Event | Constant | When it fires |
|---|---|---|
kernel.request |
KernelEvents::REQUEST |
After route is matched, before middleware runs |
kernel.controller |
KernelEvents::CONTROLLER |
After middleware passes, before controller method is called |
kernel.controller_arguments |
KernelEvents::CONTROLLER_ARGUMENTS |
After controller is resolved, before arguments are bound |
kernel.response |
KernelEvents::RESPONSE |
After controller method returns a response, before it is sent |
kernel.finish_request |
KernelEvents::FINISH_REQUEST |
After the response is sent |
kernel.terminate |
KernelEvents::TERMINATE |
After the response is sent and output is flushed |
kernel.exception |
KernelEvents::EXCEPTION |
When an unhandled Throwable is thrown during route dispatch |
These are the same Symfony kernel events documented in the Symfony HttpKernel documentation. Use the standard Symfony event classes (RequestEvent, ControllerEvent, ResponseEvent, etc.) for the corresponding listener method signatures.
PHP_SF\System\Core\PhpSfContext is a static value object set by the Router when a route is matched. It is available during and after request dispatch — from kernel.request through kernel.terminate.
Access the current context via:
$context = PhpSfContext::current();
Returns null if no route has been matched yet (e.g. if called before 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 |
getRequest() |
Request |
The current Symfony Request object |
getKernel() |
Kernel |
The framework kernel instance |
Usage inside a subscriber:
public function onController( ControllerEvent $event ): void
{
$context = PhpSfContext::current();
if ( $context === null )
return;
$route = $context->getRoute();
$request = $context->getRequest();
if ( str_starts_with( $route->url, '/api/' ) ) {
// API-specific handling
}
}
In development (DEV_MODE === true), subscriber directories are scanned on every request. This ensures new subscribers are picked up immediately without any cache clearing.
In production (DEV_MODE === false), the discovered subscriber class list is cached via ca()->get('phpsf:event_subscribers') — backed by APCu or Redis depending on the configured cache adapter. The cache is populated on the first request after deployment and reused for all subsequent requests.
After deploying new subscriber classes to production, run:
bin/console app:cache:clear
The framework's PhpSfEventDispatcher handles events fired by the Router during HTTP request dispatch — the kernel events listed above. These fire on every PHP_SF-routed request.
Use PhpSfEventDispatcher subscribers (via App/EventSubscriber/) when you need to hook into the request lifecycle for PHP_SF routes: logging, modifying responses, auditing access, injecting request context.
Use Symfony's EventDispatcherInterface (injected via constructor) when you need to fire and handle application-level events from services, console commands, or non-HTTP contexts — for example: entity persisted, payment processed, email sent, job queued. Symfony's dispatcher is fully available in any service and there is no conflict between the two systems.
// In a Symfony service — application-level events use Symfony's dispatcher directly
final class OrderService
{
public function __construct(
private readonly EventDispatcherInterface $dispatcher
) {}
public function placeOrder( Order $order ): void
{
// business logic ...
$this->dispatcher->dispatch( new OrderPlacedEvent( $order ) );
}
}
Subscriber not in a registered directory — if the subscriber class is not in a directory registered via addEventSubscriberDirectory(), it will never be discovered. No error, no warning. Check that the directory is registered.
Class not autoloadable — discovery checks is_a($fqcn, EventSubscriberInterface::class, true), which requires the class to already be autoloadable. If the namespace in the file header doesn't match the PSR-4 mapping in composer.json, the class will be silently skipped. Run composer dump-autoload after adding new subscriber classes.
Stale production cache — in production, new subscribers won't fire until the cache is cleared with app:cache:clear. If a subscriber works in dev but not in production, this is the first thing to check.
PhpSfContext is null — PhpSfContext::current() returns null outside of PHP_SF-dispatched requests (e.g. in Symfony-only routes, console commands, or 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, which fires after the response has been sent.