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::SAVE is config.save)

  • the event object, a class that extends Drupal\Component\EventDispatcher\Event and holds the context (the saved configuration, the route collection, the response)

  • the dispatcher, the event_dispatcher service, which implements Symfony’s EventDispatcherInterface

  • the subscribers, services tagged event_subscriber whose class implements EventSubscriberInterface

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.

sequenceDiagram participant S as SyncService (meridian_collection) participant D as event_dispatcher participant A as AuditSubscriber (priority 10) participant C as CacheSubscriber (priority 0) S->>D: dispatch(ObjectSyncedEvent, "meridian_collection.object_synced") D->>A: onObjectSynced(event) A-->>D: returns D->>C: onObjectSynced(event) C-->>D: returns D-->>S: the same event object, possibly modified

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

#[Hook('entity_presave')] on a method of a class in src/Hook

Class implementing EventSubscriberInterface, tagged event_subscriber in *.services.yml

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 stopPropagation()

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

ModuleHandlerInterface::invokeAll() or alter()

EventDispatcherInterface::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

Symfony\Component\HttpKernel\KernelEvents

REQUEST, CONTROLLER, VIEW, RESPONSE, FINISH_REQUEST, TERMINATE, EXCEPTION

Request redirects, response headers, exception handling, post-response work

Drupal\Core\Routing\RoutingEvents

STATIC (routing.static), DYNAMIC (routing.route_dynamic), ALTER (routing.route_alter), FINISHED (routing.route_finished)

Alter or add routes while the route table is rebuilt

Drupal\Core\Config\ConfigEvents

SAVE, DELETE, RENAME, IMPORT, IMPORT_VALIDATE, STORAGE_TRANSFORM_EXPORT, STORAGE_TRANSFORM_IMPORT

React to configuration changes, validate or transform imports and exports

Drupal\Core\Config\ConfigCollectionEvents

COLLECTION_INFO, SAVE_IN_COLLECTION, DELETE_IN_COLLECTION, RENAME_IN_COLLECTION

Register or follow configuration collections such as language overrides

Drupal\Core\Entity\EntityTypeEvents

CREATE, UPDATE, DELETE

Entity type definitions installed, changed or removed

Drupal\Core\Field\FieldStorageDefinitionEvents

CREATE, UPDATE, DELETE

Field storage definitions changed

Drupal\migrate\Event\MigrateEvents

PRE_IMPORT, POST_IMPORT, PRE_ROW_SAVE, POST_ROW_SAVE, PRE_ROLLBACK and more

Hook into a migration run (see Migrate API)

Drupal\user\Event\UserEvents

FLOOD_BLOCKED_IP, FLOOD_BLOCKED_USER

React to flood-control blocks

Drupal\Core\Session\AccountEvents

SET_USER

The current user changed

Drupal\Core\Render\RenderEvents

SELECT_PAGE_DISPLAY_VARIANT

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.');
    }
  }

}

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::SAVE fires 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 with drush 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');
    }
  }

}

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 }

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],
  ];
}

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']

Design notes:

  • The event class extends Drupal\Component\EventDispatcher\Event. Drupal 9.1 deprecated extending the old Symfony Event class; 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');
  }

}

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());
  }

}

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::SAVE and KernelEvents::REQUEST fire constantly; filter by configuration name, route name or isMainRequest() first.

  • The route is null in a REQUEST subscriber. 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, or dispatch() was called with the arguments in the old order.

  • Slow responses. Work done in RESPONSE runs before the response is sent; move non-critical work to KernelEvents::TERMINATE or a queue.

  • Wrong cache behaviour. Subscribers that change output per user or per query must add cache contexts, or use CacheableResponseInterface responses 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 _permission or _access requirements 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_subscriber tag and RouteSubscriberBase have worked the same way since Drupal 8.

  • Drupal 9.1 deprecated the Symfony Event base class for custom events in favour of Drupal\Component\EventDispatcher\Event.

  • Drupal 10.1 added the core autowiring aliases that make autowire: true practical; #[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 legacy GetResponseEvent family.

References