Object-Oriented Code and Architecture Standards
Modern Drupal is built, to a large extent, on an object-oriented architecture. Controllers, plugins, event subscribers, and service classes are the building blocks it's made of, and how they're wired together ends up mattering just as much as how any single class is written.
1. Dependency Injection
A class that needs a service — a logger, the database connection, an HTTP client — asks for it through its constructor instead of reaching for it with a static call in the middle of a method. This is what lets the class's dependencies show up in one place, and what lets a test swap in a mock instead of talking to the real service.
// ❌ Incorrect: a static service call buried inside a method.
public function importAll() {
$logger = \Drupal::service('logger.factory')->get('product_import');
$logger->error('Import failed.');
}
// ✅ Correct: the service is injected through the constructor.
public function __construct(LoggerChannelFactoryInterface $logger_factory) {
$this->logger = $logger_factory->get('product_import');
}
public function importAll() {
$this->logger->error('Import failed.');
}The difference looks small in a single method, but it adds up: a class's dependencies are visible at a glance from its constructor signature, and swapping in a mock logger for a test takes one line instead of a container override.
2. Service Definitions
A service is declared once, in module_name.services.yml, and Drupal's container takes care of building it and injecting its own dependencies in turn. The service ID is namespaced with the module name, and the class is referenced by the interface it implements wherever it's type-hinted elsewhere.
services:
product_import.importer:
class: Drupal\product_import\ProductImporter
arguments: ['@logger.factory', '@http_client']Arguments are other service IDs, prefixed with @, listed in the same order the constructor expects them. A service that needs to react to something — an event, a route being built, a form being altered — is usually given a tag in this same file rather than being called directly by other code.
3. Plugins
Blocks, field formatters, field widgets, and a long list of other extension points in Drupal are implemented as plugins rather than plain services. A plugin class carries an attribute describing its ID and metadata, and is discovered automatically by its plugin manager — nothing needs to register it by hand.
#[Block(
id: "product_import_status_block",
admin_label: new TranslatableMarkup("Import status"),
)]
class ImportStatusBlock extends BlockBase {
public function build(): array {
return [
'#markup' => $this->t('Last import: @date', ['@date' => $this->lastImportDate()]),
];
}
}If a plugin needs an injected service, it implements ContainerFactoryPluginInterface and builds itself through a static create() method — the same pattern controllers use. A plugin's constructor is never called directly by application code.
4. Event Subscribers
Code that needs to react to something happening elsewhere in Drupal — a route being matched, an entity being saved, a response about to be sent — subscribes to the relevant event instead of being called directly by the code that triggers it. A subscriber is a plain service, tagged event_subscriber, implementing EventSubscriberInterface.
class ProductImportSubscriber implements EventSubscriberInterface {
public static function getSubscribedEvents(): array {
return [
ProductImportEvents::IMPORT_COMPLETE => 'onImportComplete',
];
}
public function onImportComplete(ImportCompleteEvent $event): void {
// React to the completed import.
}
}getSubscribedEvents() is static and stateless on purpose — it maps event names to method names without needing an instance, so Drupal can wire up every subscriber up front without instantiating any of them.
5. Controllers
A controller's job is to turn a request into a response, nothing more. Business logic belongs in a service the controller calls into, not in the controller itself — that split is what keeps the logic reusable from a Drush command, a queue worker, or a test, none of which have an HTTP request to hand it.
// ❌ Incorrect: import logic lives inside the controller.
class ImportController extends ControllerBase {
public function run() {
$items = json_decode(file_get_contents('https://example.com/feed.json'), TRUE);
foreach ($items as $item) {
// ... twenty lines of import logic ...
}
return new Response('OK');
}
}
// ✅ Correct: the controller delegates to an injected service.
class ImportController extends ControllerBase {
public function __construct(protected ProductImporterInterface $importer) {}
public static function create(ContainerInterface $container): static {
return new static($container->get('product_import.importer'));
}
public function run(): Response {
$count = $this->importer->importAll();
return new Response("Imported $count products.");
}
}6. Programming to Interfaces
A constructor type-hints against an interface, not the concrete class that happens to implement it today. That's what lets a different implementation — or a mock, in a test — stand in without touching the code that depends on it.
// ❌ Incorrect: type-hinted against a concrete class.
public function __construct(ProductImporter $importer) {}
// ✅ Correct: type-hinted against the interface it implements.
public function __construct(ProductImporterInterface $importer) {}Every service and plugin that's meant to be swappable ships with an interface alongside its implementation, even when there's only one implementation today. It costs one small file, and it's what keeps a future second implementation — or a test double — from requiring changes anywhere else.