Z U R Ü C K
Drupal-Modulentwicklung: ein Leitfaden für Enterprise-Projekte

Drupal-Modulentwicklung: ein Leitfaden für Enterprise-Projekte

Wissen

Die häufigste Fehlentscheidung in Enterprise-Drupal-Projekten ist, ein Custom-Modul für ein Problem zu schreiben, das ein Contrib-Modul bereits gelöst hat. Bevor Sie eine Zeile Code schreiben, suchen Sie auf drupal.org. Auch wenn das bestehende Contrib-Modul nicht zu 100 % passt, ist es fast immer günstiger, es zu konfigurieren oder über einen schlanken Adapter zu erweitern, als ein paralleles Modul zu pflegen.

Dieser Artikel ist das Playbook, das wir bei jedem Drupal-Engagement bei Softescu anwenden: wann ein Custom-Modul sinnvoll ist, wie es in modernem Drupal 10/11 strukturiert wird, wie man es mit Services, Konfiguration und Tests sauber verdrahtet, und welche Performance-Fallen man vermeiden sollte.

Wann ein Custom-Modul die richtige Wahl ist — und wann nicht

Ein Custom-Modul ist die richtige Antwort, wenn:

  • Die Geschäftslogik wirklich domänenspezifisch ist und nicht in eine generische Lösung gehört — Versicherungstarif-Berechnung, regulatorisches Reporting, branchenspezifische Workflows.
  • Ein bestehendes Contrib-Modul fundamentale Architekturentscheidungen anders trifft, als das Projekt es benötigt.
  • Eine Integration mit einem internen System gebraucht wird, das nie öffentlich wird.

Anti-Pattern: ein Custom-Modul für "Telefonnummer-Feldvalidierung", wenn telephone plus field_validation das Problem bereits abdecken. Wir haben Projekte übernommen, in denen drei Entwickler-Generationen jeweils ein neues Custom-Modul für denselben Anwendungsfall geschrieben haben — keines davon getestet. Suchen, bevor man schreibt.

Modulstruktur in modernem Drupal (10/11)

Ein modernes Drupal-Modul besteht aus klar getrennten Schichten, die per info.yml deklariert und über den Service-Container verdrahtet werden. Die Mindeststruktur:

my_module/
  my_module.info.yml         # Modul-Metadaten
  my_module.module           # Hooks (so wenig wie möglich)
  my_module.services.yml     # Service-Definitionen
  my_module.routing.yml      # Routen
  my_module.permissions.yml  # Berechtigungen
  src/
    Controller/              # Route-Handler
    Service/                 # Geschäftslogik
    Plugin/                  # Plugins (Block, Field, ...)
    Form/                    # Formulare
    EventSubscriber/         # Event-Listener
  config/
    install/                 # Defaults bei Installation
    schema/                  # Konfigurationsschema
  tests/
    src/
      Unit/                  # PHPUnit
      Kernel/                # Kernel-Tests
      Functional/            # Browser-Tests

Die info.yml deklariert Abhängigkeiten explizit:

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: alles in die .module-Datei zu stopfen. Die .module-Datei ist heute fast nur noch ein Container für Hook-Deklarationen. Geschäftslogik gehört in Services unter src/Service/.

Hooks, Services, Dependency Injection — der moderne Weg

Drupal 10/11 ist ein Symfony-basiertes Framework. Hooks bleiben der Vertragspunkt zwischen Core und Modulen, aber sie sollten so dünn wie möglich sein und sofort an einen Service delegieren:

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

Der Service selbst ist eine einfache Klasse mit dependency-injected Abhängigkeiten:

# 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: \Drupal::service(...) mitten in der Geschäftslogik aufrufen. Das ist das Service-Locator-Pattern — es macht Tests unmöglich und verschleiert Abhängigkeiten. Constructor-Injection ist 2026 die einzig vertretbare Form.

Logger-Channels (logger.channel.my_module) müssen explizit als Service deklariert werden, damit drush watchdog:show --type=my_module funktioniert.

Konfigurationsmanagement — config/install vs. config/schema

Drupals Configuration Management ist eine der unterschätzten Stärken des Frameworks — und gleichzeitig die häufigste Quelle für Migrations-Probleme zwischen Umgebungen. Zwei Verzeichnisse sind entscheidend:

config/install/ enthält Default-Konfiguration, die bei drush en my_module einmalig in die aktive Konfiguration kopiert wird. Diese Dateien werden danach nie wieder gelesen — sie sind Installations-Defaults, keine fortlaufende Quelle der Wahrheit.

config/schema/ enthält das Schema (*.schema.yml), das die Struktur Ihrer Konfiguration beschreibt. Ohne Schema kann Drupal Konfiguration nicht serialisieren, nicht validieren und nicht über die Configuration-API exportieren. Wir haben Projekte übernommen, in denen Custom-Konfiguration jahrelang ohne Schema lief und still beim ersten drush config:export zerbrach.

# 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: Konfigurationsänderungen direkt in config/install/ machen und erwarten, dass sie in bestehende Sites einfließen. Das passiert nicht — bestehende Konfiguration muss per hook_update_N migriert werden.

Tests — PHPUnit, Kernel-Tests, Functional-Tests

Drupal hat drei Test-Schichten:

  • Unit-Tests (tests/src/Unit/) — schnell, kein Drupal-Bootstrap. Geeignet für reine Wertberechnung, Validierung, Format-Konvertierung. Mocks für alle Drupal-Services.
  • Kernel-Tests (tests/src/Kernel/) — schnell genug (~Sekunden), bootet den Drupal-Kernel ohne Web-Stack. Geeignet für Service-Logik, Entity-Operationen, Hook-Reaktionen.
  • Functional-Tests (tests/src/Functional/) — langsam, vollständiger HTTP-Stack. Nur für Endpunkt-Verhalten und Browser-Workflows.

Faustregel: jede neue Service-Klasse bekommt einen Kernel-Test mit den drei wichtigsten Pfaden (Happy Path, Edge Case, Error Case). Functional-Tests reservieren Sie für Submit-Flows und Berechtigungs-Tests.

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: nur den Happy Path testen. Edge Cases sind das, was nachts um 3 Uhr in Produktion ausfällt.

Performance und häufige Fallstricke

Drei wiederkehrende Performance-Probleme in Custom-Modulen, die wir bei Enterprise-Kunden regelmäßig sehen:

  1. N+1-Queries in hook_entity_load — jeder geladene Node löst eine zusätzliche Query aus. Ein Listing mit 50 Nodes wird zu 51+ Queries. Lösung: Batch-Load mit entity_load_multiple und ein Per-Request-Cache.
  2. Container-Rebuild bei jedem Request — eine falsch geschriebene services.yml (z. B. factory: auf einem statebehafteten Singleton) zwingt Drupal, den Container bei jedem Request neu zu bauen. Symptom: TTFB ~500 ms statt ~50 ms. Lösung: drush cache:rebuild und Container-Compilation prüfen.
  3. Fehlende Cache-Invalidierung — Custom-Blocks und -Controller, die Entity-Daten rendern, ohne cacheTags zu setzen, werden bei Inhalts-Updates nicht invalidiert. Symptom: veralteter Inhalt im anonymen Cache. Lösung: jedes Render-Array bekommt explizite #cache.tags, #cache.contexts und #cache.max-age.

Anti-Pattern: das Cache-System abschalten, um diese Probleme zu umgehen. Das verlagert die Performance-Schulden nur, statt sie zu lösen.

Wann ein Modul als Contrib veröffentlicht werden sollte

Wenn Ihr Custom-Modul über mehrere Projekte hinweg dasselbe Problem löst, ist es Kandidat für die Veröffentlichung auf drupal.org. Wir haben über die Jahre mehrere Module von Softescu in die Contrib-Community gebracht. Der Prozess lohnt sich, weil er erzwingt, was guten Code ohnehin auszeichnet: explizite Konfiguration statt hartcodierter Annahmen, Tests für jeden nicht-trivialen Pfad, dokumentierte Hooks für Erweiterungen.

Prüfen vor dem Upstreaming:

  • Gibt es bereits ein vergleichbares Modul auf drupal.org? Wenn ja, ist Co-Maintainership meist besser als ein Konkurrenz-Fork.
  • Ist die Konfiguration ausreichend abstrahiert, dass Sie ihr Schema dokumentieren können?
  • Sind alle Klassen-Namespaces, Service-IDs und Konfigurations-Keys generisch genug, dass sie nicht den Namen Ihres Kunden enthalten?

Wie wir das bei Softescu handhaben

In den letzten zehn Jahren haben wir Drupal-Module für Enterprise-Plattformen in den Bereichen Versicherung, Behörden und Bildung gebaut. Unsere Position: Custom-Module sind eine teure Investition — schreiben Sie sie nur, wenn die Geschäftslogik wirklich domänenspezifisch ist, testen Sie sie, und planen Sie das Upstreaming, wenn das Pattern wiederverwendbar ist. Unser ergänzender Artikel zu Enterprise-Drupal-Support behandelt die langfristige Wartungsseite derselben Gleichung.

Wenn Ihr Team Unterstützung bei der Drupal-Modulentwicklung sucht — Architektur-Reviews, dedizierte Entwickler oder ein vollständiges Drupal-Enterprise-Engagementsprechen Sie uns an.

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

Ähnliche Artikel

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