B A C K
Drupal Module Development: A Practical Guide for Enterprise Projects

Drupal Module Development: A Practical Guide for Enterprise Projects

Knowledge

The most expensive mistake we see on enterprise Drupal projects is a custom module written for a problem that contrib already solved. Before you write a single line, search drupal.org. Even when an existing contrib module is not a 100% fit, configuring or extending it through a thin adapter is almost always cheaper than maintaining a parallel module.

This article is the playbook we apply on every Drupal engagement at Softescu: when a custom module makes sense, how to structure it in modern Drupal 10/11, the right way to wire it together with services, configuration, tests, and the common performance traps to avoid.

When a custom module is the right call — and when it is not

A custom module is the right answer when:

  • The business logic is genuinely domain-specific and does not belong in a generic solution — insurance premium calculation, regulatory reporting, industry-specific workflows.
  • An existing contrib module makes fundamental architectural choices that conflict with the project's requirements.
  • You need an integration with an internal system that will never be public.

Anti-pattern: a custom module for "phone-number validation" when telephone plus field_validation already covers it. We have taken over projects where three generations of developers each wrote a new custom module for the same use case — none of them tested. Search before you write.

Module structure in modern Drupal (10/11)

A modern Drupal module is layered, declared in info.yml and wired together through the service container. The minimum structure:

my_module/
  my_module.info.yml         # module metadata
  my_module.module           # hooks (keep this thin)
  my_module.services.yml     # service definitions
  my_module.routing.yml      # routes
  my_module.permissions.yml  # permissions
  src/
    Controller/              # route handlers
    Service/                 # business logic
    Plugin/                  # plugins (Block, Field, ...)
    Form/                    # forms
    EventSubscriber/         # event listeners
  config/
    install/                 # defaults on install
    schema/                  # config schema
  tests/
    src/
      Unit/                  # PHPUnit
      Kernel/                # Kernel tests
      Functional/            # browser tests

The info.yml declares dependencies explicitly:

name: 'My Module'
type: module
description: 'Domain-specific business logic for ...'
package: 'Custom'
core_version_requirement: ^10 || ^11
dependencies:
  - drupal:node
  - drupal:user
  - paragraphs:paragraphs

Anti-pattern: stuffing everything into the .module file. The .module file today is mostly a container for hook declarations. Business logic belongs in services under src/Service/.

Hooks, services, dependency injection — the modern way

Drupal 10/11 is a Symfony-based framework. Hooks remain the contract point between core and modules, but they should be as thin as possible and delegate immediately to a service:

// my_module.module
function my_module_node_presave(NodeInterface $node) {
  \Drupal::service('my_module.node_normalizer')->normalize($node);
}

The service itself is a plain class with dependency-injected collaborators:

# my_module.services.yml
services:
  my_module.node_normalizer:
    class: Drupal\my_module\Service\NodeNormalizer
    arguments:
      - '@entity_type.manager'
      - '@logger.channel.my_module'
namespace Drupal\my_module\Service;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Psr\Log\LoggerInterface;

final class NodeNormalizer {
  public function __construct(
    private readonly EntityTypeManagerInterface $entityTypeManager,
    private readonly LoggerInterface $logger,
  ) {}

  public function normalize(NodeInterface $node): void { ... }
}

Anti-pattern: calling \Drupal::service(...) in the middle of business logic. That is the service-locator pattern — it makes testing impossible and hides dependencies. Constructor injection is the only defensible form in 2026.

Logger channels (logger.channel.my_module) must be explicitly declared as services so that drush watchdog:show --type=my_module works.

Configuration management — config/install vs config/schema

Drupal's Configuration Management is one of the framework's most underrated strengths — and the most common source of cross-environment migration problems. Two directories matter:

config/install/ contains default configuration that gets copied into active config once, on drush en my_module. These files are never read again — they are install-time defaults, not an ongoing source of truth.

config/schema/ contains the schema (*.schema.yml) describing the structure of your configuration. Without schema, Drupal cannot serialise, validate, or export your configuration through the Configuration API. We have inherited projects where custom configuration ran for years without schema and silently broke the first time someone ran drush config:export.

# config/schema/my_module.schema.yml
my_module.settings:
  type: config_object
  label: 'My Module settings'
  mapping:
    api_endpoint:
      type: string
      label: 'API endpoint URL'
    timeout:
      type: integer
      label: 'Timeout in seconds'

Anti-pattern: editing config/install/ and expecting the change to land in existing sites. It will not — existing configuration must be migrated through hook_update_N.

Testing — PHPUnit, Kernel tests, Functional tests

Drupal has three test layers:

  • Unit tests (tests/src/Unit/) — fast, no Drupal bootstrap. For pure value calculation, validation, format conversion. Mock all Drupal services.
  • Kernel tests (tests/src/Kernel/) — seconds-fast, boots the Drupal kernel without the web stack. For service logic, entity operations, hook reactions.
  • Functional tests (tests/src/Functional/) — slow, full HTTP stack. For endpoint behaviour and browser workflows only.

Rule of thumb: every new service class gets a Kernel test covering the three important paths (happy path, edge case, error case). Reserve Functional tests for submit flows and permission boundaries.

namespace Drupal\Tests\my_module\Kernel;

use Drupal\KernelTests\KernelTestBase;

final class NodeNormalizerTest extends KernelTestBase {
  protected static $modules = ['node', 'user', 'my_module'];

  public function testNormalizeStripsTrailingWhitespace(): void {
    $node = Node::create(['type' => 'page', 'title' => 'Test  ']);
    $this->container->get('my_module.node_normalizer')->normalize($node);
    $this->assertSame('Test', $node->getTitle());
  }
}

Anti-pattern: testing only the happy path. Edge cases are what fail in production at 3 a.m.

Performance and common pitfalls

Three recurring performance problems in custom modules we see across enterprise Drupal:

  1. N+1 queries in hook_entity_load — each loaded node triggers an extra query. A listing of 50 nodes becomes 51+ queries. Fix: batch-load with entity_load_multiple and a per-request cache.
  2. Container rebuild on every request — a misconfigured services.yml (e.g. factory: on a stateful singleton) forces Drupal to rebuild the container on every request. Symptom: TTFB ~500 ms instead of ~50 ms. Fix: drush cache:rebuild and review container compilation.
  3. Missing cache invalidation — custom blocks and controllers that render entity data without setting cacheTags are not invalidated on content updates. Symptom: stale content in the anonymous cache. Fix: every render array gets explicit #cache.tags, #cache.contexts, and #cache.max-age.

Anti-pattern: disabling the cache system to work around these problems. That displaces the performance debt instead of resolving it.

When to upstream as a contrib module

If your custom module solves the same problem across multiple projects, it is a candidate for upstreaming to drupal.org. We have brought several Softescu modules into the contrib community over the years. The process is worth it because it forces what good code already has: explicit configuration over hardcoded assumptions, tests for every non-trivial path, documented hooks for extension.

Check before upstreaming:

  • Is there already a comparable module on drupal.org? If yes, co-maintainership usually beats a competing fork.
  • Is your configuration abstract enough that you can document its schema?
  • Are all class namespaces, service IDs, and config keys generic enough not to leak a client's name?

How we approach this at Softescu

Over the last decade we have built Drupal modules for enterprise platforms in insurance, government, and education. Our position: custom modules are an expensive investment — write them only when the business logic is genuinely domain-specific, test them, and plan upstreaming when the pattern is reusable. The companion article on enterprise Drupal support covers the long-term maintenance side of the same equation.

If your team needs help with Drupal module development — architecture reviews, dedicated developers, or a full Drupal enterprise engagementget in touch.

← Previous Next.js Rules for AI Agents in 2026: A Cursor / Claude / Copilot Context File

Related Articles

Enterprise Drupal Support: Running Drupal at Scale in 2026 Creating Custom Services in Drupal 8: A Comprehensive Guide