Drupalwoo ☉

Using Drupal’s Symfony event dispatcher for node hooks

Drupal developers have long relied on node hooks such as hook_node_insert(), hook_node_update(), and hook_node_delete() to react to content changes. These procedural extension points remain useful, particularly for small modules and codebases that follow Drupal’s traditional hook architecture.

The Symfony Event Dispatcher provides a service-based alternative. A custom module can subscribe to entity lifecycle events, inspect the changed node, and run application logic without placing every operation in a .module file. This approach fits naturally with dependency injection, reusable services, and modern Drupal development standards.

The distinction matters when maintaining a busy content platform. A news site in Sydney, a council portal in Brisbane, or an ecommerce catalogue serving customers across Melbourne may have several independent reactions to the same node update. Event subscribers can keep those responsibilities separate and easier to test.

Events do not replace node hooks in every situation. The right choice depends on Drupal core version, the event being handled, the amount of supporting service logic, and whether the behaviour belongs specifically to nodes or to entities more generally.

Approach Best suited to Main strength Main caution
hook_node_insert() Small, node-specific reactions Familiar and direct Procedural code can become crowded
hook_entity_insert() Generic entity handling Works across entity types Requires entity and bundle checks
Symfony entity event subscriber Service-oriented application logic Dependency injection and priorities Requires correct event names and registration
Custom dispatched event Communication between custom services Clear application contract Needs careful event design and documentation

How Drupal events relate to node hooks

A Drupal hook is discovered through the module system. For example, a module named editor_tools can implement editor_tools_node_insert() and Drupal will invoke it after a node has been stored. The function receives the node and can perform a narrowly defined action.

The Symfony dispatcher works through named events. A service subscribes to one or more event names, and Drupal calls the service method when those events are dispatched. The subscriber can receive an event object containing the entity, then delegate the real work to another service.

This distinction gives event subscribers a cleaner dependency boundary. A node hook can call \Drupal::service() as a quick solution, but constructor injection is preferable in maintainable code. Subscribers are already services, so loggers, configuration factories, entity type managers, HTTP clients, and custom managers can be injected through the constructor.

Drupal’s entity lifecycle events are particularly relevant here. Depending on the Drupal version and the lifecycle stage, useful events include pre-save, insert, update, and delete events. Always verify the constants available in the target core version rather than copying an example written for a different release.

Creating an event subscriber

Start by defining the subscriber as a service in the module’s *.services.yml file. The service must use the event_subscriber tag so Drupal can discover the class and ask it which events it handles.

services:
  editor_tools.node_subscriber:
    class: Drupal\editor_tools\EventSubscriber\NodeSubscriber
    arguments:
      - '@logger.channel.editor_tools'
    tags:
      - { name: event_subscriber }

The class implements EventSubscriberInterface. A subscriber normally declares its event map in getSubscribedEvents(), where each event points to a callback method and, optionally, a priority. Higher priorities run earlier than lower priorities. That order should be used deliberately, because relying on incidental module order can make content workflows difficult to predict.

<?php

namespace Drupal\editor_tools\EventSubscriber;

use Drupal\Core\Entity\EntityEvents;
use Drupal\Core\Entity\EntityEvent;
use Drupal\Core\Entity\EntityInterface;
use Drupal\node\NodeInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

final class NodeSubscriber implements EventSubscriberInterface {

  public function __construct(
    private readonly LoggerInterface $logger,
  ) {}

  public static function getSubscribedEvents(): array {
    return [
      EntityEvents::INSERT => 'onInsert',
      EntityEvents::UPDATE => 'onUpdate',
      EntityEvents::DELETE => 'onDelete',
    ];
  }

  public function onInsert(EntityEvent $event): void {
    $this->processNode($event->getEntity(), 'insert');
  }

  public function onUpdate(EntityEvent $event): void {
    $this->processNode($event->getEntity(), 'update');
  }

  public function onDelete(EntityEvent $event): void {
    $this->processNode($event->getEntity(), 'delete');
  }

  private function processNode(EntityInterface $entity, string $operation): void {
    if (!$entity instanceof NodeInterface || $entity->bundle() !== 'article') {
      return;
    }

    $this->logger->info('Article node @nid received @operation event.', [
      '@nid' => $entity->id(),
      '@operation' => $operation,
    ]);
  }

}

After adding or changing a service definition, rebuild Drupal’s cache. With Drush, that is usually drush cr. A missing cache rebuild is one of the most common reasons a correctly written subscriber appears not to run.

Filtering nodes safely

Entity events are broader than node hooks. An insert event may be fired for a user, taxonomy term, media item, paragraph, or custom content entity. The instanceof NodeInterface check prevents node-specific code from processing unrelated entities.

Bundle filtering is equally important. A module that publishes article nodes to an external service may need to ignore landing pages, staff profiles, and event records. Use $node->bundle() rather than checking a display label, because machine names remain stable when editorial labels change.

A robust subscriber should also account for revisions and translations. Content moderation workflows can save several revisions before publication, while a multilingual site may update individual translations. If the business action should happen only when content becomes published, an insert or update event alone may be too early. Inspect the moderation state and relevant field values before sending data to an API.

Avoid writing another entity during the same callback unless the workflow requires it. Saving a related entity can trigger further events, which may create recursion or unexpected duplicate processing. If a task is slow, such as indexing a large image collection or notifying an external CRM, queue the work instead of performing it during the web request.

Choosing the right lifecycle event

An insert event runs when a new entity is created. An update event runs when an existing entity is saved. Delete events are useful for removing corresponding records from search indexes, external systems, or custom tables. These stages roughly match the familiar node hook family, making migration from procedural hooks relatively straightforward.

A pre-save event is appropriate when values need to be adjusted before storage. For example, a module might calculate a search key, normalise a field, or set a default based on another value. Changes made at this stage should be carefully scoped, since altering fields during every save can cause unexpected revision changes or interfere with editorial tools.

If the requirement is specifically “run after a node is saved,” an entity insert or update event is generally clearer than a generic kernel request event. Kernel events such as KernelEvents::REQUEST and KernelEvents::RESPONSE describe the HTTP request lifecycle, not the content lifecycle. Using them for node persistence logic makes the code harder to reason about and may cause it to run when no node has changed.

For a module that needs maximum compatibility with older Drupal versions, a traditional hook may be the safer implementation. Event APIs and class names can vary between core releases, so check the API documentation and test against the exact version used by the site. This is especially relevant for managed hosting environments where production and development environments may not be upgraded simultaneously.

Dispatching custom events from node workflows

A custom event is useful when several parts of a project need to respond to one application-level action. Imagine a module that determines an article is ready for syndication. Instead of calling a feed exporter, analytics adapter, and notification service directly, it can dispatch a custom ArticleReadyEvent.

use Drupal\editor_tools\Event\ArticleReadyEvent;
use Drupal\node\NodeInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

final class ArticleWorkflow {

  public function __construct(
    private readonly EventDispatcherInterface $dispatcher,
  ) {}

  public function markReady(NodeInterface $node): void {
    $event = new ArticleReadyEvent($node);
    $this->dispatcher->dispatch($event, ArticleReadyEvent::NAME);
  }

}

Other services can subscribe to that application event without the workflow needing to know their class names. This creates a useful contract between the content workflow and its integrations. The custom event should document what has happened, what data is available, and whether subscribers may modify the event.

Custom events should not be added simply to wrap every existing hook. For a single reaction in a small module, hook_node_update() may be easier for another Drupal developer to recognise. Use a custom event when the event represents a meaningful domain action or when multiple independent consumers genuinely benefit from decoupling.

Testing and operating subscribers

Write unit tests for the subscriber’s decisions separately from integration tests for Drupal’s event registration. A unit test can provide a mocked node, configure its bundle and fields, call the subscriber method, and verify that the expected collaborator was used. This is much faster than installing a complete site for every assertion.

Kernel tests are valuable for confirming that the service is registered, the event is dispatched, and the subscriber behaves correctly with real entity storage. Test insert, update, and delete paths independently. Include a non-node entity and a node from an ignored bundle to prove that filtering works.

Logging is useful during development, but production logging should be purposeful. A high-volume site such as a national retailer can generate thousands of node saves through imports or migrations. Logging every event at notice level can fill watchdog storage quickly. Use debug-level messages for routine tracing and warning or error levels for failed integrations.

When deploying through a staging environment, test event subscribers with real editorial actions, Drush imports, moderation transitions, and scheduled jobs. A documented Pantheon staging workflow helps teams compare configuration and content behaviour before changes reach production. This is particularly useful for Australian organisations coordinating releases across AEST, ACST, and AWST business hours.

Events, hooks, and maintainable Drupal architecture

The Symfony dispatcher is a strong fit when node-related behaviour needs injected services, explicit dependencies, event priorities, or multiple independent listeners. It is especially helpful in larger custom modules where business logic should live in services rather than in procedural callbacks.

Hooks remain a valid Drupal tool. They are often the clearest option for a small, tightly scoped action, and many contributed modules continue to expose hook-based extension points. Replacing every hook with an event subscriber can add ceremony without improving the design.

A practical approach is to keep the event subscriber thin. Filter the entity, check the bundle and workflow state, then pass the node to a dedicated service. That service can handle indexing, notifications, data transformation, or queue creation. The result is easier to test and less likely to tie business rules to Drupal’s event object.

Before deploying, confirm the event class and constants for the project’s Drupal version, rebuild caches, test revisions and translations, and check whether imports trigger the same lifecycle as editorial saves. Used with those safeguards, Symfony event subscribers provide a modern and flexible way to implement node reactions while preserving Drupal’s extensible architecture.