Writing Custom Drupal Modules the Right Way
A custom module should be small at its boundary and explicit about its dependencies. The goal is not merely code that runs; it is code another developer can discover, test, replace, and deploy safely.
Start with a narrow module contract
The .info.yml file declares compatibility and module dependencies. Routing maps an HTTP path to a controller. Permissions describe intent in language administrators can understand. Keep each file focused on one framework responsibility.
Put behavior in services
Controllers should translate a request into a response, not become application service containers. Define reusable behavior in services.yml, inject interfaces through the constructor, and let Drupal’s container assemble the object graph.
Constructor injection makes dependencies visible and allows unit tests to supply controlled collaborators. Avoid calling the global service locator from ordinary class methods.
Forms are workflows
A Form API class defines inputs, validation, and submission as separate steps. Validate business rules server-side, protect permissions at the route, and make side effects idempotent where retries are possible.
Working example
namespace Drupal\portfolio_tools\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\portfolio_tools\ReportBuilderInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
final class ReportController extends ControllerBase {
public function __construct(private readonly ReportBuilderInterface $builder) {}
public static function create(ContainerInterface $container): static {
return new static($container->get('portfolio_tools.report_builder'));
}
public function view(): array {
return ['#markup' => $this->builder->summary()];
}
}Organize around framework boundaries
A maintainable module makes Drupal conventions easy to recognize. Routes belong in routing configuration, permissions belong in permissions configuration, reusable objects belong in the service container, configuration data needs schema, and presentation should ultimately reach Twig through render arrays. Following those boundaries means another Drupal developer can navigate the module before learning its business rules.
That does not mean every module needs dozens of classes. Small modules should stay small. The goal is to avoid one controller, form, or hook gradually becoming the place where storage queries, authorization, formatting, email delivery, and external API calls all accumulate.
Hooks, events, and services have different jobs
Hooks are appropriate when Drupal provides a procedural extension point and the module needs to participate in it. Event subscribers are useful when an event-driven API is already the contract. Services are the better home for behavior that needs to be called from more than one place or tested independently.
A useful rule is to keep framework entry points thin. A hook can collect Drupal-specific input and delegate to a service. A controller can validate the request and delegate to a service. A queue worker can load the job context and delegate to a service. The application logic then does not care which Drupal mechanism invoked it.
Test at the smallest useful layer
Pure decision-making code can often be unit tested without Drupal. Code that depends on the service container, configuration, entities, or plugins may deserve a kernel test. Routes, permissions, forms, and complete browser workflows can be covered by functional tests. Using the smallest test that proves the behavior keeps the suite useful without turning every assertion into a full Drupal installation.
Key Takeaways
- Keep controllers thin and business behavior in injected services.
- Declare permissions and dependencies explicitly.
- Design validation and side effects as separate concerns.