Event Listeners Without YAML Configuration
Anyone who has registered event listeners in Symfony via services.yaml with tags and method names knows the pain of maintaining several files for a single class. The PHP attribute #[AsEventListener] eliminates this YAML configuration entirely, the listener is declared directly on the class or method, type-safe and immediately readable.
Table of Contents
- 1. The problem with YAML configuration for event listeners
- 2. The #[AsEventListener] attribute in detail
- 3. Handling kernel events with #[AsEventListener]
- 4. Multiple events in a single listener class
- 5. Controlling priorities and execution order
- 6. Defining and subscribing to custom events
- 7. Stopping event propagation
- 8. EventSubscriber vs. AsEventListener: which one when?
- 9. Comparison: YAML configuration vs. PHP attribute
- 10. Summary
- 11. FAQ
1. The problem with YAML configuration for event listeners
In Symfony, event listeners were traditionally registered via services.yaml: a tag entry with kernel.event_listener, the event name, the method and optionally a priority. That sounds manageable as long as you only have a few listeners. In real projects with dozens of listeners, however, configuration files emerge that are barely navigable anymore, and every time a method is renamed, both the PHP class and the YAML file need to be updated. If you forget the YAML file, the listener is silently disabled, without any error message.
The underlying problem is the decoupling of implementation and configuration: the PHP class contains the logic, the YAML file contains the metadata, and both have to be kept in sync manually. With PHP 8.0 and the introduction of native attributes, this picture changed. Symfony has consistently used attributes since version 6.0 to pull configuration directly into the code, and #[AsEventListener] is one of the clearest examples of this. The attribute makes YAML completely unnecessary for event listeners and keeps declaration and logic in the same place.
A second aspect is readability: anyone who opens a class and sees the attribute #[AsEventListener] immediately knows that it is a listener, which event it listens to and with which priority it is called, without searching through services.yaml. This makes code reviews faster and onboarding new developers easier. Symfony's Dependency Injection container processes the attribute at compile time and generates the same internal tag configuration that used to be written manually in YAML, the developer just no longer sees it.
2. The #[AsEventListener] attribute in detail
The attribute #[AsEventListener] from the namespace Symfony\Component\EventDispatcher\Attribute can be applied to both classes and individual methods. In its simplest form, the attribute directly on the class with no parameters, Symfony expects an __invoke method, and the event name is derived from the type-hint of that method's parameter. This is the most compact variant: one class, one event, no additional configuration besides the attribute.
Applied at method level, #[AsEventListener] is more flexible. The event parameter specifies the event name explicitly, method specifies the method to call (the method name itself by default), priority controls the execution order and dispatcher allows registration on an event dispatcher other than the default one. All parameters are optional, you only specify what deviates from the default derivation. The attribute is repeatable: multiple #[AsEventListener] attributes on the same method or class are possible and register the listener for several events at once.
For Symfony to recognize the attribute, autoconfigure: true must be active in the container, which has been the default in every Symfony project since version 4.4. Anyone who has set autoconfigure: false must add the tag manually, but gets a clear overview of all registered listeners from bin/console debug:event-dispatcher. For projects with many listeners, this command is the most important debugging tool: it shows the event name, class, method and priority in a clear format.
<?php
declare(strict_types=1);
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Simplest form: attribute on class, __invoke method, event derived from type-hint.
* No YAML configuration needed, autoconfigure: true handles registration.
*/
#[AsEventListener]
final class MaintenanceModeListener
{
public function __construct(
private readonly bool $maintenanceMode,
) {}
/**
* Check maintenance mode on every incoming request.
* Event type is derived automatically from the parameter type-hint.
*/
public function __invoke(RequestEvent $event): void
{
if (!$this->maintenanceMode) {
return;
}
// Only affect master requests, not sub-requests
if (!$event->isMainRequest()) {
return;
}
// Redirect to maintenance page or return 503
// $event->setResponse(new Response('Maintenance', 503));
}
}
// Equivalent YAML that this attribute REPLACES, never write this again:
// services:
// App\EventListener\MaintenanceModeListener:
// tags:
// - { name: kernel.event_listener, event: kernel.request, method: __invoke }
3. Handling kernel events with #[AsEventListener]
Symfony's HTTP kernel dispatches a defined set of events that cover the entire request-response cycle: kernel.request, kernel.controller, kernel.controller_arguments, kernel.view, kernel.response, kernel.finish_request, kernel.exception and kernel.terminate. All of these events are defined as constants in the KernelEvents class. With #[AsEventListener] and the parameter event: KernelEvents::REQUEST, the binding is type-safe and refactoring-friendly, instead of the magic string 'kernel.request', you use the constant.
Particularly common is kernel.exception for centralized error handling. An #[AsEventListener] listener on this event receives an ExceptionEvent object that holds the exception and the current request. The listener can set a response that Symfony uses as the final answer, without the exception ever reaching the normal error handler. This enables API-specific JSON error responses alongside HTML error pages for browser requests, controlled by the Accept header.
For kernel.response, security headers are a typical use case: Content-Security-Policy, Strict-Transport-Security and X-Frame-Options are set centrally in the listener instead of being repeated in every controller. The #[AsEventListener] decorator on the onKernelResponse method with event: KernelEvents::RESPONSE makes this class's responsibility immediately clear, no need to check YAML, no surprises regarding execution order.
<?php
declare(strict_types=1);
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Handles kernel events: API error responses and security headers.
* Multiple #[AsEventListener] attributes on one class, no YAML needed.
*/
final class HttpKernelListener
{
// priority: 20 ensures this runs before Symfony's default exception handler
#[AsEventListener(event: KernelEvents::EXCEPTION, priority: 20)]
public function onException(ExceptionEvent $event): void
{
$request = $event->getRequest();
// Only handle API requests (Accept: application/json)
if (!str_contains($request->headers->get('Accept', ''), 'application/json')) {
return;
}
$exception = $event->getThrowable();
$event->setResponse(new JsonResponse([
'error' => $exception->getMessage(),
'code' => $exception->getCode(),
'type' => (new \ReflectionClass($exception))->getShortName(),
], 500));
}
#[AsEventListener(event: KernelEvents::RESPONSE)]
public function onResponse(ResponseEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
// Add security headers to every response centrally
$response = $event->getResponse();
$response->headers->set('X-Frame-Options', 'DENY');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
}
}
4. Multiple events in a single listener class
With #[AsEventListener] at method level, a single class can handle several events without having to be an EventSubscriber. This is especially useful when several events belong together thematically and use the same dependencies. An authentication listener could subscribe to both security.interactive_login and security.logout, both methods need the same audit logging service, and there is no duplication of constructor dependencies.
The attribute is repeatable: you write it multiple times over the same method or over different methods of the same class. Symfony creates a separate listener entry in the container for each attribute instance. The class itself is only instantiated once, the Dependency Injection container returns the same instance for all events, unless the class is explicitly defined as not-shared. This saves memory and avoids redundant constructor calls on every request.
There is an important design question here: when is a class with several event methods still coherent, and when should it be split up? The rule of thumb is: if all events share the same business context (e.g. all user-related events), a single class makes sense. If events come from different business domains, they belong in separate listener classes. #[AsEventListener] makes both equally easy, the decision rests purely on business cohesion, not on technical configuration constraints.
5. Controlling priorities and execution order
In Symfony, the priority of an #[AsEventListener] determines the order in which listeners for the same event are called. Higher numbers mean earlier execution, a listener with priority 100 is called before one with priority 0, which in turn runs before one with priority -100. By default, every listener has priority 0. Symfony's own kernel.request router listener has priority 32, the security listener has 8. Anyone who wants to react before the router needs a priority greater than 32.
The priority system is especially important for listeners that depend on each other. A listener that extracts authentication data from the request and attaches it to a request attribute must run before the listener that reads this data. With #[AsEventListener(event: KernelEvents::REQUEST, priority: 50)] for the first and #[AsEventListener(event: KernelEvents::REQUEST, priority: 10)] for the second, the execution order is documented and enforced, without any external dependency documentation.
bin/console debug:event-dispatcher kernel.request lists all listeners registered for this event in their actual execution order, sorted by priority. This is the most important tool for diagnosing listener conflicts: if a listener unexpectedly does not run because another one stops propagation, or if the order is wrong, this command shows the entire chain at a glance.
6. Defining and subscribing to custom events
Not all events in Symfony come from the kernel. Custom domain events, such as OrderPlacedEvent, UserRegisteredEvent or ProductPublishedEvent, follow the same patterns and benefit just as much from #[AsEventListener]. A custom event class typically extends Symfony\Contracts\EventDispatcher\Event and carries the relevant domain data as readonly properties. The event dispatcher is injected into the service via DI, and dispatch($event) is called, done.
The listener for the custom event carries the attribute #[AsEventListener(event: OrderPlacedEvent::class)], the event name is the FQCN of the event class itself. Symfony resolves this at compile time and registers the listener correctly. This has a decisive advantage over magic strings: if the event class is renamed or moved, the code immediately breaks with a compile error, instead of silently no longer calling a listener. The combination of a typed event and #[AsEventListener] makes the entire event system refactoring-safe.
For larger projects, it is worth introducing a central event class per business domain that holds all the event constants for that domain. This way, events can also be named as strings, UserEvents::REGISTERED instead of the FQCN, keeping full control over the event name without depending on the class structure. Both approaches work equally well with #[AsEventListener]; the choice is a team decision about naming conventions.
<?php
declare(strict_types=1);
namespace App\Event;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Dispatched when a new order is successfully placed.
* Immutable: all data set at construction, no setters.
*/
final class OrderPlacedEvent extends Event
{
public function __construct(
public readonly int $orderId,
public readonly string $customerEmail,
public readonly float $totalAmount,
public readonly \DateTimeImmutable $placedAt = new \DateTimeImmutable(),
) {}
}
// --- Listener subscribing to this custom event ---
namespace App\EventListener;
use App\Event\OrderPlacedEvent;
use App\Service\MailerService;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
/**
* Sends order confirmation email when an order is placed.
* Event name is derived from the FQCN, refactoring-safe.
*/
#[AsEventListener(event: OrderPlacedEvent::class)]
final class OrderConfirmationListener
{
public function __construct(
private readonly MailerService $mailer,
) {}
/**
* Handle the order placed event and dispatch confirmation email.
*/
public function __invoke(OrderPlacedEvent $event): void
{
$this->mailer->sendOrderConfirmation(
email: $event->customerEmail,
orderId: $event->orderId,
total: $event->totalAmount,
);
}
}
7. Stopping event propagation
When a listener finishes processing an event and wants to prevent further listeners from being called, it calls $event->stopPropagation(). This is a standard feature of the Symfony EventDispatcher and works regardless of whether the listener was registered via YAML or via #[AsEventListener]. The typical use case: a high-priority listener checks whether a special condition is met (e.g. maintenance mode), sets a response and stops propagation, all further listeners for this event are skipped.
Stopping propagation should be used with caution, because it creates implicit dependencies between listeners: one listener relies on being called, but another one with higher priority can prevent that. bin/console debug:event-dispatcher does not show whether a listener can stop propagation, you have to read that from the code. Clear documentation in the listener itself, e.g. in a PHPDoc comment, is especially important here. With #[AsEventListener], at least the priority is directly visible on the listener, which makes analysis easier.
8. EventSubscriber vs. AsEventListener: which one when?
Before #[AsEventListener], the EventSubscriberInterface was the recommended way to handle multiple events in a single class: the static method getSubscribedEvents() returns an array with event names, methods and priorities. This still works in Symfony 6 and 7, but #[AsEventListener] is the clearer alternative in many cases. The essential difference: EventSubscriber classes are harder to test, because getSubscribedEvents() is a static method that implicitly configures event dispatching. With #[AsEventListener] methods, you can call the same method directly in a test, without involving the dispatcher.
EventSubscriber remains useful when the event configuration is dynamic or can vary at runtime, which is not possible with PHP attributes, because attributes are evaluated at compile time. For static, testable event handling with a clear responsibility, #[AsEventListener] is the more modern choice. In new Symfony projects, the attribute is the preferred approach for new listeners, while existing EventSubscribers do not necessarily need to be migrated, both approaches coexist without any problem in the same project.
| Feature | YAML configuration | #[AsEventListener] | EventSubscriber |
|---|---|---|---|
| Configuration location | services.yaml, separate file | Directly in the PHP code | getSubscribedEvents() static |
| Type safety | Magic strings, no IDE help | FQCN or KernelEvents constant | Magic strings possible |
| Testability | Method directly testable | Method directly testable | Static method complicates isolation |
| Multiple events | Multiple tag entries | Repeatable attribute | getSubscribedEvents array |
| Dynamic config | Possible via factory | Not possible (compile time) | Possible (PHP method) |
9. Comparison: YAML configuration vs. PHP attribute
The practical difference becomes clear when migrating an existing listener from YAML to #[AsEventListener]: you remove the tag entry from services.yaml, add the attribute to the PHP class, and clean up a configuration line at the same time. Clearing the cache is enough to re-register the listener. The command bin/console debug:event-dispatcher afterwards shows the same output as before, the same event name, the same method, the same priority. For the container compiler, it is transparent whether the registration comes from YAML or from an attribute.
Migrating from YAML to #[AsEventListener] is worthwhile not only for readability reasons but also for safety reasons: a listener that references, in YAML, a method name that was renamed in PHP stays registered in the container and calls a non-existent method at runtime, with an error that only becomes visible at request time. With #[AsEventListener] directly on the method, the link is inseparable: the method exists exactly when the attribute is active.
Mironsoft
Symfony development, event architecture and backend optimization
Want to modernize your Symfony event architecture?
We migrate existing YAML configurations to PHP attributes, design clean event hierarchies and integrate custom events into your Symfony stack, with full test coverage and priority documentation.
Event audit
Analysis of all registered listeners, priority conflicts and orphaned YAML entries in your existing project
Migration
Replacing YAML tags with #[AsEventListener] attributes, with verification via debug:event-dispatcher
Custom events
Designing and implementing domain events, type-safe, immutable and refactoring-friendly
10. Summary
The attribute #[AsEventListener] in Symfony makes YAML configuration for event listeners completely unnecessary. The declaration of event name, method and priority happens directly in the PHP code, type-safe, refactoring-friendly and immediately readable for any developer without looking at configuration files. Symfony processes the attribute at container compile time and internally generates the same tag configuration that used to be maintained manually. The result is less configuration overhead, fewer sources of error from desynchronization between YAML and PHP, and a cleaner codebase.
The biggest practical advantage is colocation: anyone who opens the listener class immediately sees all events, methods and priorities, without having to switch between several files. bin/console debug:event-dispatcher remains the central debugging tool for getting an overview of all registered listeners. New Symfony projects should use #[AsEventListener] as the default for all event listeners; existing projects can approach the migration step by step, since both approaches coexist within one project.
#[AsEventListener] in Symfony, the essentials at a glance
No more YAML
#[AsEventListener] directly on a class or method, Symfony registers the listener automatically when autoconfigure: true is active.
Type-safe events
Event name as FQCN or KernelEvents constant, IDE support and refactoring safety instead of magic strings.
Priorities
priority: in the attribute controls the execution order. bin/console debug:event-dispatcher shows all listeners sorted.
Repeatable
Multiple #[AsEventListener] on one class or method are possible, a single class can subscribe to as many events as needed.