Repository Pattern in PHP ohne Framework implementieren
AI generated
<?php
8.4
PHP · Datenbank · Design Patterns
Repository Pattern ohne Framework implementieren
Datenzugriff hinter einem Interface verstecken

Das Repository Pattern trennt Domänenlogik von der konkreten Datenzugriffstechnologie, indem eine Sammlung von Domänenobjekten hinter einem einfachen Interface verborgen wird. Wer das Repository Pattern in reinem PHP versteht, kann Geschäftslogik testen, ohne eine echte Datenbank zu berühren, und die zugrunde liegende Speichertechnologie später austauschen, ohne den restlichen Code anzufassen.

18 Min. Lesezeit Interface · PDO · In-Memory-Testdouble PHP 8.4 · framework-unabhängig

1. Welches Problem das Repository Pattern löst

Das Repository Pattern ist ein Entwurfsmuster, das den Datenzugriff hinter einer Sammlungs-Metapher verbirgt: Aus Sicht der Geschäftslogik verhält sich ein Repository wie eine In-Memory-Collection von Objekten, obwohl im Hintergrund SQL-Abfragen gegen eine Datenbank laufen. Ohne Repository Pattern verteilt sich SQL-Code typischerweise über die gesamte Anwendung, in Controllern, Services und sogar in Templates, was jede Änderung an der Datenzugriffslogik zu einer Suche über zig Dateien macht.

Der zentrale Vorteil des Repository Patterns ist die Trennung von Domänenlogik und Persistenz. Ein Service, der eine Bestellung storniert, muss nicht wissen, ob die Bestellung aus MySQL, einer REST-API oder einer Datei geladen wurde. Er ruft schlicht $orderRepository->findById($id) auf und arbeitet mit dem zurückgegebenen Domänenobjekt weiter. Diese Entkopplung macht Geschäftslogik unabhängig von der konkreten Speichertechnologie testbar.

Wichtig ist die Abgrenzung zum Query Builder: Ein Repository arbeitet mit Domänenobjekten und fachlichen Methodennamen wie findActiveCustomers(), während ein Query Builder auf der Ebene von SQL-Konstrukten wie SELECT und WHERE bleibt. Ein Repository nutzt intern häufig einen Query Builder oder rohes PDO, verbirgt diese Details aber vollständig vor dem Aufrufer.

2. Das Repository Interface: Vertrag statt Implementierung

Der Ausgangspunkt jedes sauberen Repository Patterns ist ein Interface, das ausschließlich fachliche Methoden definiert, ohne jeden Hinweis auf die konkrete Speichertechnologie. Die Domänenlogik hängt nur von diesem Interface ab, niemals von einer konkreten Implementierung. Das entspricht dem Dependency-Inversion-Prinzip aus SOLID: High-Level-Code hängt von einer Abstraktion ab, nicht von Low-Level-Details wie PDO oder einem bestimmten SQL-Dialekt.

Diese Trennung zahlt sich vor allem beim Testen aus. Ein Service, der gegen das Repository Interface programmiert ist, lässt sich in Unit-Tests mit einem Test-Double des Interfaces ausstatten, ganz ohne Datenbankverbindung. Das Repository Pattern macht diese Art von Test überhaupt erst praktikabel, weil die Domänenlogik nirgends direkt PDO oder eine SQL-Bibliothek referenziert.


<?php

declare(strict_types=1);

interface CustomerRepositoryInterface
{
    public function findById(int $id): ?Customer;

    /** @return Customer[] */
    public function findActive(): array;

    public function save(Customer $customer): void;

    public function delete(int $id): void;
}

final class Customer
{
    public function __construct(
        public readonly ?int $id,
        public readonly string $name,
        public readonly string $email,
        public readonly bool $active,
    ) {
    }
}

3. Eine konkrete PDO-Implementierung des Repositorys

Die produktive Implementierung des Repository Patterns übersetzt jede Interface-Methode in konkrete SQL-Abfragen über PDO und wandelt die rohen Datenbankzeilen in Domänenobjekte um. Diese Umwandlung, oft Hydration genannt, ist ein zentraler Bestandteil jeder Repository-Implementierung, weil sie verhindert, dass assoziative Arrays mit Datenbankspalten in die Geschäftslogik durchsickern.

Fehler bei der Umwandlung, etwa vergessene Typumwandlungen bei booleschen Spalten, gehören ausschließlich in die Repository-Implementierung, niemals in den aufrufenden Code. Das Repository Pattern zieht damit eine klare Grenze: Alles, was mit der Datenbank zu tun hat, bleibt innerhalb der Implementierung, alles, was mit Geschäftsregeln zu tun hat, bleibt außerhalb.


<?php

declare(strict_types=1);

final class PdoCustomerRepository implements CustomerRepositoryInterface
{
    public function __construct(private readonly PDO $pdo)
    {
    }

    public function findById(int $id): ?Customer
    {
        $statement = $this->pdo->prepare(
            'SELECT id, name, email, active FROM customers WHERE id = ?'
        );
        $statement->execute([$id]);
        $row = $statement->fetch(PDO::FETCH_ASSOC);

        return $row === false ? null : $this->hydrate($row);
    }

    public function findActive(): array
    {
        $statement = $this->pdo->query(
            'SELECT id, name, email, active FROM customers WHERE active = 1'
        );
        return array_map($this->hydrate(...), $statement->fetchAll(PDO::FETCH_ASSOC));
    }

    public function save(Customer $customer): void
    {
        if ($customer->id === null) {
            $statement = $this->pdo->prepare(
                'INSERT INTO customers (name, email, active) VALUES (?, ?, ?)'
            );
            $statement->execute([$customer->name, $customer->email, (int) $customer->active]);
            return;
        }

        $statement = $this->pdo->prepare(
            'UPDATE customers SET name = ?, email = ?, active = ? WHERE id = ?'
        );
        $statement->execute([$customer->name, $customer->email, (int) $customer->active, $customer->id]);
    }

    public function delete(int $id): void
    {
        $statement = $this->pdo->prepare('DELETE FROM customers WHERE id = ?');
        $statement->execute([$id]);
    }

    /** @param array<string, mixed> $row */
    private function hydrate(array $row): Customer
    {
        return new Customer(
            id: (int) $row['id'],
            name: (string) $row['name'],
            email: (string) $row['email'],
            active: (bool) $row['active'],
        );
    }
}

4. Domänenobjekte statt Arrays zurückgeben

Ein häufiger Fehler bei einer nachträglichen Einführung des Repository Patterns ist, weiterhin assoziative Arrays statt echter Domänenobjekte zurückzugeben. Damit verschwindet zwar SQL aus der Geschäftslogik, aber die Typsicherheit bleibt auf der Strecke: Ein Tippfehler in einem Array-Schlüssel wird erst zur Laufzeit sichtbar, nicht bereits bei der statischen Analyse mit PHPStan. Echte Domänenobjekte mit typisierten, teils readonly Properties machen solche Fehler bereits beim Kompilieren des Codes sichtbar.

Domänenobjekte innerhalb eines Repository Patterns sollten zudem selbst keine Persistenzlogik enthalten. Eine Customer-Klasse kennt ihre eigenen Geschäftsregeln, etwa eine deactivate()-Methode, aber sie weiß nichts von SQL oder der Tabelle, aus der sie geladen wurde. Diese strikte Trennung unterscheidet das Repository Pattern grundlegend vom Active-Record-Ansatz, bei dem das Domänenobjekt sich selbst speichert.

5. Spezifikationen und Filter jenseits von findById

Reale Anwendungen brauchen oft mehr als einfache Methoden wie findById() oder findActive(). Für komplexere, dynamische Suchanfragen kombiniert man das Repository Pattern häufig mit dem Specification Pattern: Eine Specification-Klasse kapselt eine einzelne Filterbedingung, und das Repository nimmt eine oder mehrere Specifications entgegen, um daraus eine WHERE-Klausel zusammenzusetzen.

Diese Kombination verhindert, dass das Repository Interface mit dutzenden spezialisierten findByNameAndActiveAndRegion()-Methoden überladen wird. Stattdessen bleibt eine generische matching(Specification $spec)-Methode im Repository Pattern bestehen, die intern einen Query Builder mit den Bedingungen aus der Specification füttert.


<?php

declare(strict_types=1);

interface CustomerSpecification
{
    public function toSqlCondition(): string;

    /** @return array<int, mixed> */
    public function getBindings(): array;
}

final class ActiveInRegion implements CustomerSpecification
{
    public function __construct(private readonly string $region)
    {
    }

    public function toSqlCondition(): string
    {
        return 'active = 1 AND region = ?';
    }

    public function getBindings(): array
    {
        return [$this->region];
    }
}

// Extended repository method
public function matching(CustomerSpecification $spec): array
{
    $sql = 'SELECT id, name, email, active FROM customers WHERE ' . $spec->toSqlCondition();
    $statement = $this->pdo->prepare($sql);
    $statement->execute($spec->getBindings());

    return array_map($this->hydrate(...), $statement->fetchAll(PDO::FETCH_ASSOC));
}

6. In-Memory-Repository als Testdouble

Der größte praktische Vorteil des Repository Patterns zeigt sich beim Testen von Geschäftslogik. Eine In-Memory-Implementierung des Repository Interfaces, die Objekte lediglich in einem Array hält, verhält sich aus Sicht der Geschäftslogik identisch zur PDO-Implementierung, läuft aber ohne Datenbankverbindung und damit deutlich schneller. Unit-Tests für Services, die von einem Repository abhängen, können diese In-Memory-Variante injizieren und exakt kontrollieren, welche Daten zurückgegeben werden.

Diese Testbarkeit ist der eigentliche Business-Grund, das Repository Pattern überhaupt einzuführen. Ohne dieses Muster müssten Tests entweder gegen eine echte Testdatenbank laufen, was Testläufe deutlich verlangsamt, oder komplexe Mocking-Frameworks für PDO-Aufrufe bemühen, was die Tests unnötig fragil macht.


<?php

declare(strict_types=1);

final class InMemoryCustomerRepository implements CustomerRepositoryInterface
{
    /** @var array<int, Customer> */
    private array $customers = [];
    private int $nextId = 1;

    public function findById(int $id): ?Customer
    {
        return $this->customers[$id] ?? null;
    }

    public function findActive(): array
    {
        return array_values(array_filter($this->customers, fn (Customer $c) => $c->active));
    }

    public function save(Customer $customer): void
    {
        $id = $customer->id ?? $this->nextId++;
        $this->customers[$id] = new Customer($id, $customer->name, $customer->email, $customer->active);
    }

    public function delete(int $id): void
    {
        unset($this->customers[$id]);
    }
}

7. Zusammenspiel mit Unit of Work und Transaktionen

Sobald mehrere Repositories innerhalb einer einzigen Geschäftsoperation zusammenarbeiten, etwa beim Anlegen einer Bestellung samt zugehöriger Rechnungsposten, wird eine übergeordnete Transaktionsgrenze notwendig. Das Repository Pattern allein löst dieses Problem nicht, da jedes Repository unabhängig persistiert. Häufig kombiniert man es deshalb mit einer Unit-of-Work-Instanz, die eine PDO-Transaktion umschließt und alle beteiligten Repositories zum richtigen Zeitpunkt committen oder zurückrollen lässt.

In einer schlanken Eigenimplementierung reicht dafür oft schon, dieselbe PDO-Instanz an mehrere Repositories weiterzugeben und die Transaktionssteuerung an einer zentralen Stelle im Anwendungscode zu belassen, statt sie in jedes einzelne Repository zu verlagern. So bleibt das Repository Pattern auf seine eigentliche Aufgabe fokussiert: den Zugriff auf eine Sammlung von Domänenobjekten zu kapseln, nicht die Transaktionslogik zu verwalten.

8. Repositories per Dependency Injection einbinden

Damit Services tatsächlich gegen das Interface statt gegen eine konkrete Implementierung programmieren, muss ein DI-Container das Interface an die passende Implementierung binden. In Frameworks wie Symfony geschieht das über Service-Konfiguration, in einer framework-unabhängigen Anwendung reicht ein simpler Container, der eine Zuordnung von Interface zu konkreter Klasse verwaltet und beim Auflösen automatisch die richtige Repository-Implementierung injiziert.

Für Tests wird dieselbe Bindung einfach umgeschaltet: Statt PdoCustomerRepository wird InMemoryCustomerRepository registriert, ohne dass der Service-Code selbst angefasst werden muss. Diese Austauschbarkeit ist der praktische Kern des Repository Patterns und zeigt, warum sich die zusätzliche Abstraktionsebene gegenüber direktem PDO-Zugriff in den meisten mittelgroßen bis großen Projekten lohnt.

9. Repository Pattern im Vergleich zu Active Record

Die Alternative zum Repository Pattern ist der Active-Record-Ansatz, bei dem ein Domänenobjekt selbst Methoden wie save() oder delete() trägt und damit direkt für seine eigene Persistenz verantwortlich ist. Frameworks wie Laravel mit Eloquent setzen bewusst auf Active Record, weil es für viele CRUD-lastige Anwendungen weniger Boilerplate-Code erfordert.

Kriterium Repository Pattern Active Record
Testbarkeit ohne Datenbank Sehr gut, per Testdouble Schwierig, Objekt trägt Persistenz
Boilerplate-Code Mehr durch Interface und Implementierung Weniger, direkt am Objekt
Trennung von Verantwortlichkeiten Klar getrennt Objekt kennt sich selbst und die DB
Austausch der Speichertechnologie Nur Implementierung tauschen Eng an ORM gekoppelt
Passend für Komplexe Domänenlogik, DDD-Ansätze Schnelle CRUD-lastige Anwendungen

Für Projekte mit komplexer Geschäftslogik und dem Anspruch, diese Logik unabhängig von der Datenbank zu testen, ist das Repository Pattern die deutlich robustere Wahl. Für einfache CRUD-Anwendungen mit wenig Domänenlogik kann der geringere Boilerplate-Aufwand von Active Record überwiegen.

10. Zusammenfassung

Das Repository Pattern kapselt Datenzugriff hinter einem fachlichen Interface und trennt Domänenlogik konsequent von der konkreten Speichertechnologie. Die wichtigsten Bausteine sind ein Interface mit ausschließlich fachlichen Methodennamen, eine konkrete PDO-Implementierung mit sauberer Hydration zu Domänenobjekten, sowie eine In-Memory-Variante als schnelles Testdouble für Unit-Tests.

Erweiterungen wie das Specification Pattern für dynamische Filter und eine Unit of Work für übergreifende Transaktionen ergänzen das Repository Pattern in größeren Anwendungen sinnvoll. Im Vergleich zu Active Record erfordert das Repository Pattern etwas mehr Code, zahlt sich aber in Projekten mit komplexer, testbarer Geschäftslogik durch die klare Trennung der Verantwortlichkeiten deutlich aus.

Repository Pattern ohne Framework — Das Wichtigste auf einen Blick

Interface zuerst

Domänenlogik hängt nur vom Interface ab, niemals von PDO oder SQL direkt.

Hydration

Datenbankzeilen werden in typisierte Domänenobjekte umgewandelt, nie als Array durchgereicht.

In-Memory-Testdouble

Schnelle Unit-Tests für Geschäftslogik, komplett ohne Datenbankverbindung.

Abgrenzung

Kein Active Record: Domänenobjekte kennen keine Persistenzlogik.

11. FAQ: Repository Pattern in PHP

1Was ist das Repository Pattern einfach erklärt?
Ein Muster, das Datenzugriff hinter einer Sammlungs-Metapher versteckt, sodass sich ein Repository wie eine In-Memory-Collection verhält.
2Unterschied zu Active Record?
Beim Repository Pattern kennt das Domänenobjekt keine Persistenz, ein separates Repository übernimmt das. Bei Active Record trägt das Objekt save()/delete() selbst.
3Warum ist das Interface so wichtig?
Geschäftslogik hängt nur vom Interface ab, erlaubt Austausch der Speichertechnologie und Tests mit Testdoubles.
4Wie teste ich Services mit Repository?
Mit einer In-Memory-Implementierung des Interfaces, die identisch funktioniert, aber keine Datenbankverbindung braucht.
5Was ist Hydration?
Die Umwandlung von Datenbankzeilen in typisierte Domänenobjekte innerhalb der Repository-Implementierung.
6Wie geht man mit dynamischen Filtern um?
Mit dem Specification Pattern, das Filterbedingungen kapselt und einem generischen matching() übergeben wird.
7Braucht man einen DI-Container?
Nicht zwingend, aber ein Container erleichtert das Umschalten zwischen Implementierungen erheblich.
8Wie funktionieren Transaktionen über mehrere Repositories?
Über eine Unit of Work oder zentral gesteuerte PDO-Transaktion, die dieselbe Verbindung teilt.
9Ist es für jedes Projekt sinnvoll?
Nicht zwingend, bei einfachen CRUD-Anwendungen kann Active Record mit weniger Boilerplate überwiegen.
10Kann ein Repository einen Query Builder nutzen?
Ja, das ist üblich und bleibt reines Implementierungsdetail innerhalb des Repositorys.