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:
- 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 withentity_load_multipleand a per-request cache. - 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:rebuildand review container compilation. - Missing cache invalidation — custom blocks and controllers that render entity data without setting
cacheTagsare 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 engagement — get in touch.