Events and Subscribers
|
This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production. This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages. |
Drupal has two extension mechanisms that let one module react to something another part of the system did: hooks
(see Hooks and OOP hooks) and events. Events come from the Symfony
EventDispatcher component that Drupal has used since version 8. This page explains how the dispatcher works, which
core events exist, how to write a subscriber for the Meridian Museum’s meridian_collection module, how to
choose priorities, how to define and dispatch your own event, how to test all of it, and when an event is a better
choice than a hook.
The page was checked on 2026-10-30 against Drupal 11.4.x (Drush 13/14, PHP 8.3+). The subscriber and dispatcher
mechanics have been stable since Drupal 8. What changed recently is the surroundings: hooks became classes with
#[Hook] attributes (Drupal 11.1), constructor injection with autowiring replaced \Drupal:: calls in classes, and
the official documentation page for events is now labelled for Drupal 10.3+ and 11.0+.
What an event is and who uses it
An event is a named notification, plus an object that carries the data. A subscriber is a method that is called when that notification is dispatched. The dispatcher keeps the registry of subscribers, sorts them by priority and calls them in order. Four pieces make up the system:
-
the event name, a unique string, normally held in a class constant (
ConfigEvents::SAVEisconfig.save) -
the event object, a class that extends
Drupal\Component\EventDispatcher\Eventand holds the context (the saved configuration, the route collection, the response) -
the dispatcher, the
event_dispatcherservice, which implements Symfony’sEventDispatcherInterface -
the subscribers, services tagged
event_subscriberwhose class implementsEventSubscriberInterface
Developers use events to react to the HTTP request cycle, to alter routes, to follow configuration changes and to let other modules extend a workflow they define. Site builders never see events directly, but many contributed modules (redirect, domain, group, metatag) are built on them.
Hooks versus events
The official page Subscribe to and dispatch events compares the two systems. Classic procedural hooks are found
by function name during a cache rebuild, allow one implementation per module, and are ordered by module weight rather
than by the event itself. Events are registered in the compiled service container, allow any number of subscribers per
module, and carry an explicit priority. The same page calls events a better system in most ways. Hooks are not gone,
though: since Drupal 11.1 they are methods with #[Hook] on autowired classes, and core still dispatches most of
its entity, form and theme extension points as hooks.
| Aspect | Hook (Drupal 11.1+ OOP) | Event subscriber |
|---|---|---|
Declaration |
|
Class implementing |
Discovery |
Attribute scanning, cached with the container |
Service tag, compiled into the container |
Several per module |
Yes, with OOP hooks (several methods may use the same hook) |
Yes |
Ordering |
Module weight, plus the ordering attributes added in 11.2 |
Numeric priority per event |
Data passed |
Positional arguments, often by reference |
One event object, modified through its methods |
Can stop later listeners |
No |
Yes, with |
Typical use |
Entity CRUD, form alter, theme and render alters, install and update |
Request and response handling, routing, configuration, migrations, module-defined extension points |
Dispatch |
|
|
A practical rule: if core offers a hook for what you need (for example hook_entity_presave), use the hook. If core
offers an event (routing, config, kernel), use a subscriber. If you are designing an extension point in your own
module, prefer an event class, because a typed object is easier to document, test and evolve than a list of
by-reference arguments. Older books such as Drupal 10 Development Cookbook (2023) show subscribers whose
services call \Drupal::messenger() or \Drupal::database() directly and declare no types; the pattern still
works, but current code injects its collaborators through the constructor, as the examples below do.
Core events you can subscribe to
The API reference topic Events on api.drupal.org lists the event name classes shipped with Drupal 11.x. The ones that matter most to a site’s custom module are summarised here.
| Event name class | Constants (examples) | Typical use |
|---|---|---|
|
|
Request redirects, response headers, exception handling, post-response work |
|
|
Alter or add routes while the route table is rebuilt |
|
|
React to configuration changes, validate or transform imports and exports |
|
|
Register or follow configuration collections such as language overrides |
|
|
Entity type definitions installed, changed or removed |
|
|
Field storage definitions changed |
|
|
Hook into a migration run (see Migrate API) |
|
|
React to flood-control blocks |
|
|
The current user changed |
|
|
Choose the page display variant |
Many other modules define their own event classes (Layout Builder, Locale, JSON:API, Block Content), so the first stop when looking for an extension point is the same API reference topic.
Kernel events
Drupal 11 is built on Symfony’s HttpKernel, so every page request fires the standard kernel events. The common ones
are KernelEvents::REQUEST (before routing finishes), KernelEvents::CONTROLLER, KernelEvents::VIEW (a controller
returned a render array), KernelEvents::RESPONSE (adjust headers or content), KernelEvents::EXCEPTION and
KernelEvents::TERMINATE (after the response was sent, for slow work). A frequent pitfall concerns REQUEST: the
router sets the matched route during this event, so a subscriber that reads the route name through the
current_route_match service only sees it when its priority is 32 or lower, as the official page points out.
Routing events
Route subscribers alter the route collection while the router is rebuilt. Instead of listening to
RoutingEvents::ALTER yourself, extend Drupal\Core\Routing\RouteSubscriberBase and implement alterRoutes(); the base
class registers the right event and priority for you. STATIC carries routes from static sources such as YAML files and
PHP attributes, DYNAMIC adds routes derived from existing ones, ALTER modifies routes from any module before they
are saved, and FINISHED signals the end of route building. The first three pass a RouteBuildEvent; FINISHED passes
a plain event. Route subscribers run only on a router rebuild, so after changing one run drush cache:rebuild.
Configuration events
ConfigEvents::SAVE and ConfigEvents::DELETE pass a Drupal\Core\Config\ConfigCrudEvent. Its getConfig() returns
the Config object (its getName() is the configuration name) and isChanged($key) tells whether a key changed in
this save. ConfigEvents::IMPORT and IMPORT_VALIDATE pass a ConfigImporterEvent; throwing a
ConfigImporterException from an IMPORT_VALIDATE subscriber aborts the import, which is how a site can refuse a
configuration set that would break it. The STORAGE_TRANSFORM_* events let a module (Config Split is the best-known
example, see Configuration management) alter what is exported or imported.
Entity events
Core does not dispatch events for entity create, update and delete. Those moments are covered by hooks
(hook_entity_insert, hook_entity_presave, hook_entity_delete and the per-type variants), described in
Entity API and Hooks and OOP hooks.
Core’s EntityTypeEvents and FieldStorageDefinitionEvents concern the definitions of entity types and fields,
not the entities themselves. Contributed code fills the gap: the Entity Events project provides abstract subscriber
classes for insert, update, delete, presave and predelete, but its project page now recommends the core OOP hooks
instead, since Drupal 11.1 made hooks usable in classes. For new code, write a #[Hook] method for entity
lifecycle moments and keep subscribers for the events in the table above.
Writing a subscriber
A subscriber is a class that implements Symfony\Component\EventDispatcher\EventSubscriberInterface and is registered
as a service with the event_subscriber tag. Its static getSubscribedEvents() method returns an array mapping an
event name to a method name, or to a method name and a priority.
Meridian’s collection module reacts when a curator saves the module settings: if the base URL of the collections API changes, cached object listings must be invalidated.
# modules/custom/meridian_collection/meridian_collection.services.yml
services:
Drupal\meridian_collection\EventSubscriber\SettingsSubscriber:
autowire: true
tags:
- { name: event_subscriber }
The service id is the class name, so no class: key is needed, and autowire: true asks the container to build the
constructor arguments from type hints.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\EventSubscriber;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Config\ConfigCrudEvent;
use Drupal\Core\Config\ConfigEvents;
use Drupal\Core\Logger\LoggerChannelInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Invalidates cached collection listings when the API settings change.
*/
final class SettingsSubscriber implements EventSubscriberInterface {
public function __construct(
#[Autowire(service: 'logger.channel.meridian_collection')]
private readonly LoggerChannelInterface $logger,
) {}
public static function getSubscribedEvents(): array {
return [
ConfigEvents::SAVE => 'onConfigSave',
];
}
public function onConfigSave(ConfigCrudEvent $event): void {
$config = $event->getConfig();
if ($config->getName() !== 'meridian_collection.settings') {
return;
}
if ($event->isChanged('api_base_url')) {
Cache::invalidateTags(['collection_object_list']);
$this->logger->notice('Collections API URL changed; listing caches invalidated.');
}
}
}
Source: Subscribe to and dispatch events
Points worth knowing about this example:
-
The handler receives exactly one argument, the event object. Its type hint documents which event class it expects.
-
ConfigEvents::SAVEfires for every configuration object in the site, so the first line filters by name. A subscriber without such a guard runs on each save of any configuration and slows installs and imports. -
The logger channel service is declared in
meridian_collection.services.yml; see Services and dependency injection for how to define it. -
getSubscribedEvents()is static and runs while the container is compiled, so it cannot use injected services and its result is cached. After adding or changing a subscriber, rebuild the container withdrush cache:rebuild. -
Subscribers are instantiated lazily, when the event is first dispatched, so a heavy constructor does not slow requests that never fire the event.
-
Using
\Drupal::messenger(),\Drupal::database()or similar inside a subscriber, as older tutorials do, makes the class hard to test. Inject the services instead.
The Symfony #[AsEventListener] attribute, which registers a listener without a services entry, is a different
mechanism. Drupal core’s issue queue tracks support for it and a contributed module provides it, but the official
events page still documents only tagged EventSubscriberInterface services, which is what this page uses.
A response subscriber
Kernel events work the same way. This subscriber keeps the curator-only synchronisation screen out of search engine
indexes by adding an X-Robots-Tag header.
services:
Drupal\meridian_collection\EventSubscriber\NoIndexSubscriber:
autowire: true
tags:
- { name: event_subscriber }
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\EventSubscriber;
use Drupal\Core\Routing\CurrentRouteMatch;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class NoIndexSubscriber implements EventSubscriberInterface {
public function __construct(
private readonly CurrentRouteMatch $routeMatch,
) {}
public static function getSubscribedEvents(): array {
return [
KernelEvents::RESPONSE => ['onResponse', -10],
];
}
public function onResponse(ResponseEvent $event): void {
if (!$event->isMainRequest()) {
return;
}
$name = (string) $this->routeMatch->getRouteName();
if (str_starts_with($name, 'meridian_collection.sync')) {
$event->getResponse()->headers->set('X-Robots-Tag', 'noindex, nofollow');
}
}
}
Source: Subscribe to and dispatch events
isMainRequest() skips sub-requests (embedded controllers, ESI), which would otherwise be processed twice. Mutating a
response in a subscriber must stay compatible with page caching: this one only adds a header based on the route, which
is already part of the cache context of the response. A subscriber that varies output by user, cookie or query
argument must add the matching cache metadata, otherwise Drupal’s page cache serves one visitor’s variant to another.
See Render API and cache metadata.
A route subscriber
To restrict a route defined by another module, extend RouteSubscriberBase. Meridian’s curators use the JSON:API
collection endpoint through a permission of their own.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\EventSubscriber;
use Drupal\Core\Routing\RouteSubscriberBase;
use Symfony\Component\Routing\RouteCollection;
final class CollectionRouteSubscriber extends RouteSubscriberBase {
protected function alterRoutes(RouteCollection $collection): void {
if ($route = $collection->get('entity.collection_object.edit_form')) {
$route->setRequirement('_permission', 'edit collection objects');
}
}
}
services:
Drupal\meridian_collection\EventSubscriber\CollectionRouteSubscriber:
tags:
- { name: event_subscriber }
Source: RouteSubscriberBase
Altering a route that does not exist returns null from get(), so always test the result. Route alterations
interact with access checking: changing a requirement affects access to the page, so treat a route subscriber as
security-sensitive code (see Access control and permissions in code).
Priorities and propagation
A subscriber’s priority is an integer. Higher numbers run first, the default is 0, and negative numbers run after the default ones. To set it, replace the method name with an array of method name and priority:
public static function getSubscribedEvents(): array {
return [
ConfigEvents::SAVE => ['onConfigSave', 100],
ConfigEvents::DELETE => ['onConfigDelete', -100],
];
}
Source: Subscribe to and dispatch events
To bind several methods to one event, wrap them in an outer array, each as its own [method, priority] pair:
KernelEvents::RESPONSE => [
['addHeaders', 10],
['logSlowRequests', -50],
],
When priorities are equal, the official documentation says the subscriber from the module with the lower weight runs first, and within a services file, definition order applies. It also advises always setting an explicit priority when order matters, because the tie-breaking rules may change in a minor release.
A handler may call $event->stopPropagation() to prevent lower-priority subscribers from running. Use it sparingly,
because it silently disables other modules' subscribers; it is mainly for events that are "answered" once, such as
a request redirect or exception handler. isPropagationStopped() tells whether it happened.
Core’s own subscribers use numeric priorities too, so a subscriber’s priority is relative to them. For REQUEST, the
rule quoted earlier (32 or lower to see the matched route) is the one to remember. When an unexplained ordering problem
appears, list the registered listeners with a debugging tool such as the Devel module’s Drush command for events,
rather than guessing.
Defining and dispatching a custom event
When your own module offers an extension point, define an event class. The collection synchronisation service imports objects from the museum’s collections-management API; other modules (an audit log, a search indexer) should react after each object is stored.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Event;
use Drupal\Component\EventDispatcher\Event;
/**
* Dispatched after a collection object was created or updated by the sync.
*/
final class ObjectSyncedEvent extends Event {
public const NAME = 'meridian_collection.object_synced';
public function __construct(
public readonly string $accessionNumber,
public readonly bool $isNew,
private array $tags = [],
) {}
public function addCacheTag(string $tag): void {
$this->tags[] = $tag;
}
public function getCacheTags(): array {
return $this->tags;
}
}
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
use Drupal\Core\Cache\Cache;
use Drupal\meridian_collection\Event\ObjectSyncedEvent;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
final class CollectionSyncService {
public function __construct(
private readonly EventDispatcherInterface $eventDispatcher,
) {}
public function afterStore(string $accession, bool $isNew): void {
$event = new ObjectSyncedEvent($accession, $isNew);
$this->eventDispatcher->dispatch($event, ObjectSyncedEvent::NAME);
Cache::invalidateTags($event->getCacheTags());
}
}
services:
Drupal\meridian_collection\CollectionSyncService:
arguments: ['@event_dispatcher']
Source: Subscribe to and dispatch events
Design notes:
-
The event class extends
Drupal\Component\EventDispatcher\Event. Drupal 9.1 deprecated extending the old SymfonyEventclass; older tutorials still do. -
Since Symfony 4.3
dispatch()takes the event object first and the name second. Code that passes the name first is Drupal 8 era and fails on current versions. -
Give the event name a module prefix, and keep it in a constant on the event class so subscribers do not repeat string literals.
-
Make the event object the API: readonly public properties for inputs, methods for what subscribers may change. The dispatcher passes the same object to every subscriber, so changes made by one are visible to the next, and the dispatching code reads the result afterwards, as
afterStore()does with the cache tags. -
Dispatching is synchronous. A slow subscriber slows the code that dispatches. For heavy work, have the subscriber enqueue a job (see Queue, batch and cron APIs).
-
An exception in a subscriber propagates to the dispatcher caller. Catch and log errors in subscribers that must not break the main task.
A subscriber for the new event looks like any other:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\EventSubscriber;
use Drupal\meridian_collection\Event\ObjectSyncedEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
final class GalleryTagSubscriber implements EventSubscriberInterface {
public static function getSubscribedEvents(): array {
return [
ObjectSyncedEvent::NAME => ['onObjectSynced', 10],
];
}
public function onObjectSynced(ObjectSyncedEvent $event): void {
$event->addCacheTag('meridian_gallery_list');
}
}
Source: Subscribe to and dispatch events
Document the event in your module’s README or in the class docblock, listing the name, the class and what subscribers may do. That documentation is the contract other developers rely on.
Testing subscribers
Unit-test a subscriber by constructing it and calling its handler with a hand-built event. For wiring (is the service
tagged, does the event reach it), use a Kernel test. PHPUnit 11 in Drupal uses attributes, not the @group annotations
shown in older books.
<?php
declare(strict_types=1);
namespace Drupal\Tests\meridian_collection\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\meridian_collection\Event\ObjectSyncedEvent;
use PHPUnit\Framework\Attributes\Group;
#[Group('meridian_collection')]
final class ObjectSyncedEventTest extends KernelTestBase {
protected static $modules = ['meridian_collection'];
public function testSubscriberAddsGalleryTag(): void {
$event = new ObjectSyncedEvent('2026.0042', TRUE);
$this->container->get('event_dispatcher')
->dispatch($event, ObjectSyncedEvent::NAME);
$this->assertContains('meridian_gallery_list', $event->getCacheTags());
}
}
Source: Automated testing
See Automated testing for the full test pyramid.
Common errors and pitfalls
-
The subscriber never runs. The tag is missing or misspelt (
event_subscriber, with an underscore), the services file is not named<module>.services.yml, or the cache was not rebuilt. -
The handler runs for the wrong thing.
ConfigEvents::SAVEandKernelEvents::REQUESTfire constantly; filter by configuration name, route name orisMainRequest()first. -
The route is
nullin aREQUESTsubscriber. Priority above 32, see above. -
The type error
Argument #1 must be of type Event. A handler type hint does not match the event class that is dispatched, ordispatch()was called with the arguments in the old order. -
Slow responses. Work done in
RESPONSEruns before the response is sent; move non-critical work toKernelEvents::TERMINATEor a queue. -
Wrong cache behaviour. Subscribers that change output per user or per query must add cache contexts, or use
CacheableResponseInterfaceresponses with metadata. -
Using an event where a hook is required. Entity lifecycle moments are hooks, not events (see above).
Security notes
-
A subscriber runs with the privileges of the code that dispatched the event, on every request for kernel events. Keep request-time subscribers cheap and free of secrets.
-
A route subscriber that changes
_permissionor_accessrequirements changes who can open a page. Review it like an access policy, and cover it with a functional test that checks anonymous, member and curator access. -
Data in an event object may come from the visitor (a request, a form, an imported row). Validate and escape it before using it in queries, output or file paths.
-
Never put API keys in event classes or configuration objects that are exported; read them from environment variables or the Key module.
Version notes
-
Subscribers, the
event_subscribertag andRouteSubscriberBasehave worked the same way since Drupal 8. -
Drupal 9.1 deprecated the Symfony
Eventbase class for custom events in favour ofDrupal\Component\EventDispatcher\Event. -
Drupal 10.1 added the core autowiring aliases that make
autowire: truepractical;#[Autowire]lets you pick a specific service by id. -
Drupal 11.1 introduced OOP hooks with
#[Hook]; 11.2 added ordering and removal attributes. Both reduce the need for contributed entity-event modules. -
Drupal 10 reaches end of life on 2026-12-09 and Drupal 12.0.0 is planned for the week of 2026-12-07; it moves to Symfony 8, so subscribers should use only the Symfony contracts types (
Symfony\Contracts\EventDispatcher\EventDispatcherInterface) and current event classes (ResponseEvent,RequestEvent), not the removed legacyGetResponseEventfamily.
References
-
Subscribe to and dispatch events (drupal.org)
-
Events (API reference topic) (api.drupal.org)
-
RoutingEvents (api.drupal.org)
-
RouteSubscriberBase (api.drupal.org)
-
Automated testing (drupal.org)
-
Entity Events (contributed project)
-
The EventDispatcher Component (Symfony)
-
Built-in Symfony Events (Symfony)