Skip to main content
All posts
Drupal PHP Architecture Sep 9, 2026 · 3 min read

Drupal Custom Module Development: Patterns I Actually Use

After nine years of Drupal module development, here are the architectural patterns, service container approaches, and testing strategies I reach for on every project.

Article

Services Over Procedural Code

The single biggest quality-of-life improvement in Drupal 8+ over Drupal 7 was the service container. Instead of global functions and static calls, logic lives in injectable services.

I define a service for any non-trivial business logic:

# mymodule.services.yml
services:
  mymodule.content_processor:
    class: Drupal\mymodule\ContentProcessor
    arguments:
      - '@entity_type.manager'
      - '@logger.factory'

And inject it via constructor injection in controllers, form handlers, and other services. The benefits — testability, single responsibility, clear dependency graphs — are worth the extra file.

Event Subscribers Over Hooks (When Appropriate)

Drupal still uses hooks extensively, and for many things (altering forms, modifying entity queries, reacting to cron) hooks are the right tool. But for reacting to Drupal core events, Symfony event subscribers are cleaner and more explicit:

class ContentAccessSubscriber implements EventSubscriberInterface {
    public static function getSubscribedEvents(): array {
        return [KernelEvents::REQUEST => ['onRequest', 20]];
    }

    public function onRequest(RequestEvent $event): void {
        // Logic here
    }
}

Event subscribers are easier to test, easier to reason about, and their priority system is more explicit than hook weight.

Entity API: Always Use the Storage Layer

One of the most common mistakes I see in inherited Drupal codebases is direct database queries for entity data:

// Bad — bypasses entity cache, access checks, field API
$result = \Drupal::database()->query('SELECT nid FROM {node} WHERE type = :type', [':type' => 'article']);

Always use the entity storage layer:

// Good
$nids = \Drupal::entityQuery('node')
  ->condition('type', 'article')
  ->condition('status', 1)
  ->accessCheck(TRUE)
  ->execute();

$nodes = \Drupal::entityTypeManager()->getStorage('node')->loadMultiple($nids);

The entity layer handles caching, access checks, and field value formatting. Bypassing it creates subtle bugs that only appear in multi-role environments or when cache invalidation matters.

Cache Tags: Be Explicit

Drupal's cache tag system is one of its superpowers, and most custom modules underutilise it. Any custom render array or response that depends on entity data should declare its cache tags explicitly:

$build['#cache']['tags'] = Cache::mergeTags(
    $node->getCacheTags(),
    ['node_list:article']
);

The node_list:article tag gets invalidated whenever any article node is created, updated, or deleted — so your render array automatically invalidates without manual intervention.

Kernel Tests for Services, Functional Tests for User Flows

The test type hierarchy I use:

  • Unit tests (PHPUnit, no Drupal bootstrap): for pure PHP logic, value objects, utility functions
  • Kernel tests (KernelTestBase, partial Drupal bootstrap): for services, hooks, entity operations — fastest test type that still verifies Drupal integration
  • Functional tests (BrowserTestBase, full bootstrap): for end-to-end user flows, form submissions, access control

Most custom module logic should be covered by Kernel tests. They're fast enough to run on every commit and cover the majority of real-world failure modes.

A Kernel test that takes 500ms gives you more confidence than a Functional test that takes 5 seconds and tests the same underlying logic.

The Module Structure I Always Start With

mymodule/
  src/
    Controller/
    Form/
    Plugin/Block/
    Service/
    EventSubscriber/
  tests/
    src/
      Unit/
      Kernel/
      Functional/
  mymodule.info.yml
  mymodule.services.yml
  mymodule.module        # Hooks only — business logic in services
  mymodule.routing.yml
  mymodule.links.menu.yml

The .module file should be thin — just hook implementations that delegate immediately to service calls. Logic belongs in classes, not procedural functions.