The framework's event system is intentionally simple — it hooks into middleware execution and fires registered listeners after their associated middleware runs. That's the entire scope of it. If you need something more complex — application-level events, priority queues, stoppable events, async dispatch — use Symfony's event system directly. Both are available in the same project and there's no conflict between them.
Does:
Doesn't:
If your use case fits the "do something after this middleware runs" pattern, this system is clean and simple. If it doesn't fit that pattern, use Symfony's event system — it's already in the project and works normally.
The base class for all event listeners. Extend this and implement getListeners() to declare which middleware triggers your listener and which method handles it:
// App/EventListeners/ExampleEventListener.php
namespace App\EventListeners;
use App\Http\Middleware\api_example;
use PHP_SF\System\Classes\Abstracts\AbstractController;
use PHP_SF\System\Classes\Abstracts\AbstractEventListener;
use PHP_SF\System\Classes\Abstracts\Middleware;
use Symfony\Component\HttpFoundation\Request;
final class ExampleEventListener extends AbstractEventListener
{
public function getListeners(): array
{
return [
api_example::class => 'listener',
];
}
private function listener(
AbstractController $controller,
Middleware $middleware,
Request $request
): void {
// fires after api middleware executes on any matching request
}
}
getListeners() returns an array mapping middleware class names to method names on the listener class. The method receives whatever arguments the middleware dispatcher can match from the available context — controller, middleware instance, and request.
The dispatcher is what actually fires listeners. MiddlewareEventDispatcher extends it and is the only dispatcher used by the framework:
// src/Core/MiddlewareEventDispatcher.php
final class MiddlewareEventDispatcher extends AbstractEventsDispatcher
{
protected static array $eventListenersList = [];
}
Listeners are registered on the dispatcher statically via addEventListeners(). This is called in config/eventListeners.php before the kernel boots:
// config/eventListeners.php
PHP_SF\System\Core\MiddlewareEventDispatcher::addEventListeners(
\App\EventListeners\ExampleEventListener::class,
\App\EventListeners\AnotherEventListener::class,
);
addEventListeners() accepts any number of listener class names as variadic arguments.
Middleware classes implement EventSubscriberInterface and extend AbstractEventSubscriber indirectly through the Middleware base class. The subscriber's dispatchEvent() method is what the dispatcher calls — it checks whether the listener is registered for the current middleware class, resolves method parameters by type matching, and invokes the listener method.
You don't interact with AbstractEventSubscriber directly — it's wired into the middleware execution chain automatically.
When middleware executes, MiddlewareEventDispatcher is constructed with the middleware instance as the subscriber:
// Inside Middleware::__construct()
new MiddlewareEventDispatcher( $this, $this->request, $controller );
The dispatcher iterates through all registered listener classes and calls dispatchEvent() on the subscriber (the middleware) for each one. If the listener's getListeners() array contains the current middleware class as a key, the corresponding method is invoked.
Parameter resolution is done by type matching — the dispatcher looks at the listener method's parameter types and matches them against the available arguments (Request, AbstractController, Middleware):
// The dispatcher matches parameters by type, not by name
private function listener(
AbstractController $controller, // matched by type AbstractController
Middleware $middleware, // matched by type Middleware
Request $request // matched by type Request
): void { ... }
Parameter order doesn't matter — the dispatcher resolves by type. You can declare them in any order and even omit ones you don't need:
// Only need the request? Just declare that
private function listener( Request $request ): void { ... }
// Only need the controller?
private function listener( AbstractController $controller ): void { ... }
Each listener class fires at most once per request regardless of how many routes use the associated middleware. AbstractEventListener tracks execution state via a static flag:
// AbstractEventListener
final public static function markExecuted(): void
{
static::$isExecuted = true;
}
final public static function isExecuted(): bool
{
return static::$isExecuted;
}
Once a listener fires, markExecuted() is called and all subsequent dispatch attempts for that listener class are skipped. This means if three routes in a single request all use auth middleware and you have a listener on auth, it fires on the first match only.
This is intentional for the primary use cases — logging that a request was authenticated, updating a last-seen timestamp, incrementing a request counter. You want these to happen once per request, not once per middleware execution.
If you need a listener to fire multiple times per request (once per middleware invocation), this system isn't the right fit — use Symfony's event system instead.
final class AuthEventListener extends AbstractEventListener
{
public function getListeners(): array
{
return [
auth::class => 'onAuthMiddleware',
];
}
private function onAuthMiddleware(
Request $request,
AbstractController $controller
): void {
if ( auth::isAuthenticated() === false )
return;
rp()->rpush( sprintf(
'player:%d:request_log',
user()->getId()
), [
json_encode( [
'url' => $request->getRequestUri(),
'time' => time(),
] )
] );
}
}
final class LastSeenListener extends AbstractEventListener
{
public function getListeners(): array
{
return [
auth::class => 'updateLastSeen',
];
}
private function updateLastSeen(): void
{
if ( auth::isAuthenticated() === false )
return;
rp()->set(
sprintf( 'player:%d:last_seen', user()->getId() ),
time()
);
}
}
A single listener class can listen to multiple middleware classes:
final class ApiUsageListener extends AbstractEventListener
{
public function getListeners(): array
{
return [
api_example::class => 'onApiAccess',
cron_example::class => 'onCronAccess',
];
}
private function onApiAccess( Request $request ): void
{
rp()->incr( sprintf(
'stats:api_calls:%s',
date( 'Y-m-d' )
) );
}
private function onCronAccess( Request $request ): void
{
rp()->set( 'stats:last_cron_run', time() );
}
}
Note the fire-once guarantee applies per listener class, not per method — if a request triggers both api and cron middleware, both onApiAccess and onCronAccess will fire since they're associated with different middleware classes. The once-per-request limit applies to each middleware association independently.
In DEV_MODE, the footer template shows all listeners that fired during the current request for admin users:
// In footer.php
<?php if ( User::isAdmin() ) : ?>
<?php dump( AbstractEventsDispatcher::getDispatchedListeners() ) ?>
<?php endif ?>
getDispatchedListeners() returns an array keyed by subscriber class (middleware) with arrays of listener class names that fired:
[
'PHP_SF\Framework\Http\Middleware\auth' => [
'App\EventListeners\AuthEventListener',
'App\EventListeners\LastSeenListener',
]
]
This is useful for verifying listeners are firing on the right requests and not firing when they shouldn't.
Adding a new listener requires exactly two steps — if either is missing the listener silently does nothing:
1. Create the listener class in App/EventListeners/:
final class MyNewListener extends AbstractEventListener
{
public function getListeners(): array
{
return [
auth::class => 'handle',
];
}
private function handle( Request $request ): void
{
// your logic
}
}
2. Register it in config/eventListeners.php:
PHP_SF\System\Core\MiddlewareEventDispatcher::addEventListeners(
\App\EventListeners\ExampleEventListener::class,
\App\EventListeners\MyNewListener::class, // ← add here
);
That's it. No service tags, no yaml configuration, no compiler passes.
Use Symfony's EventDispatcherInterface when you need:
Symfony's event system is available in any service via constructor injection and in console commands. The two systems don't conflict — you can use both in the same project for different purposes.
// In a Symfony service — full event dispatcher available
final class GameTickService
{
public function __construct(
private readonly EventDispatcherInterface $dispatcher
) {}
public function tick(): void
{
// fire application-level events using Symfony's system
$this->dispatcher->dispatch( new GameTickEvent() );
}
}
Missing registration in config/eventListeners.php — the most common issue. A listener class that isn't registered will never fire. No error, no warning, complete silence. This is the first thing to check when a listener appears to do nothing.
Expecting the listener to fire multiple times per request — the fire-once guarantee means a listener fires at most once regardless of how many routes use the associated middleware. If you need per-invocation firing, use Symfony's event system.
Heavy operations in listeners — listeners fire during middleware execution, which is part of the request handling critical path. Slow database queries, external API calls, or large cache operations in a listener affect every request that hits the associated middleware. Keep listener logic fast — queue heavy work via the Redis pipeline or RabbitMQ.
Wrong parameter types in listener methods — parameter resolution is by type matching. If a parameter type doesn't match any of the available arguments (Request, AbstractController, Middleware), ArgumentCountError is thrown with a message listing what was available. Declare only parameter types the dispatcher can actually provide.
Assuming listener fires after middleware blocks — listeners fire during middleware construction via new MiddlewareEventDispatcher( $this, ... ) in Middleware::__construct(). This happens before result() is called. If the middleware blocks the request, the listener has already fired. Don't assume the request was allowed through just because the listener executed.