Symfony Doctrine Soft-Delete-Pattern sauber implementieren
AI generated
SF
{ }
Doctrine ORM
Soft-Delete in Symfony: Das Doctrine-Pattern sauber implementieren
Löschen ohne zu löschen: Filter, Spalten und Fallstricke

Warum echtes DELETE bei referenzierten Daten oft die falsche Wahl ist und wie du mit einem Doctrine-Filter, einer deletedAt-Spalte und sauberen Unique-Constraints ein robustes Soft-Delete-Pattern baust.

15 Min. Lesezeit Doctrine ORM Symfony 7

1. Warum echtes Löschen bei referenzierten Daten problematisch ist

In einer Bestellung referenzierte Produkte, in einem Ticket referenzierte Kommentare, in einer Rechnung referenzierte Positionen: Sobald eine Entity von anderen Datensätzen referenziert wird, wird ein einfaches DELETE FROM schnell gefährlich. Entweder verhindert eine Foreign-Key-Constraint das Löschen komplett und die Anwendung wirft eine hässliche Datenbank-Exception, oder ein CASCADE löscht ungewollt ganze Historien mit, etwa alle Bestellpositionen eines Kunden, der eigentlich nur deaktiviert werden sollte.

Fachlich ist echtes Löschen zudem oft schlicht falsch: Ein Kunde, der sein Konto kündigt, soll aus Compliance-Gründen meist nicht sofort aus der Datenbank verschwinden, sondern für eine definierte Frist nachvollziehbar bleiben. Genau für solche Fälle ist Soft-Delete gedacht: Der Datensatz bleibt physisch in der Tabelle bestehen, wird aber als gelöscht markiert und aus allen normalen Abfragen automatisch herausgefiltert.

2. deletedAt-Spalte vs. Status-Flag: Zwei Ansätze im Vergleich

Die beiden gängigsten Implementierungen sind eine nullable deletedAt-Spalte vom Typ DateTimeImmutable oder ein einfaches Boolean-Flag wie isDeleted. Das Flag ist auf den ersten Blick simpler, verliert aber eine wichtige Information: den Zeitpunkt des Löschens. Genau dieser Zeitpunkt ist in der Praxis fast immer relevant, etwa um eine Aufbewahrungsfrist zu berechnen oder in einem Audit-Log nachzuvollziehen, wann genau etwas passiert ist.

Deshalb ist die deletedAt-Spalte in den meisten Projekten die bessere Wahl. Sie liefert gleichzeitig das Flag (null bedeutet nicht gelöscht, ein Wert bedeutet gelöscht) und den Zeitstempel, ohne zwei redundante Spalten pflegen zu müssen. In Doctrine wird das Feld typischerweise als #[ORM\Column(type: 'datetime_immutable', nullable: true)] deklariert und über eine einfache delete()-Methode auf der Entity gesetzt, statt den EntityManager remove() aufzurufen.

3. Einen Doctrine-Filter für automatisches Ausblenden implementieren

Ohne zusätzliche Maßnahme müsste jede einzelne Query im Projekt manuell um eine WHERE deleted_at IS NULL Bedingung ergänzt werden, was garantiert irgendwann vergessen wird. Doctrine löst dieses Problem elegant mit SQL-Filtern, die als Klasse von Doctrine\ORM\Query\Filter\SQLFilter erben und die Methode addFilterConstraint() implementieren, welche automatisch an jede Query für die betroffene Entity angehängt wird.

Der Filter wird einmalig in doctrine.yaml registriert und muss anschließend explizit über den EntityManager aktiviert werden, meist ganz zu Beginn des Requests in einem Event-Subscriber. Das folgende Beispiel zeigt einen vollständigen SoftDeleteableFilter, der über ein Marker-Interface erkennt, welche Entities überhaupt eine deletedAt-Spalte besitzen, und den Filter nur für diese anwendet.


<?php

declare(strict_types=1);

namespace App\Doctrine\Filter;

use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;

final class SoftDeleteableFilter extends SQLFilter
{
    public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string
    {
        if (!$targetEntity->reflClass->implementsInterface(SoftDeleteableInterface::class)) {
            return '';
        }

        return sprintf('%s.deleted_at IS NULL', $targetTableAlias);
    }
}

4. Filter registrieren und pro Request aktivieren

Damit Doctrine den Filter tatsächlich anwendet, muss er zunächst unter doctrine.orm.filters in der doctrine.yaml registriert werden, mit dem vollqualifizierten Klassennamen und einem sprechenden Alias wie softdeleteable. Standardmäßig ist ein registrierter Filter noch inaktiv, er muss explizit über $entityManager->getFilters()->enable('softdeleteable') eingeschaltet werden.

Am saubersten geschieht diese Aktivierung in einem Kernel-Event-Subscriber, der auf kernel.request lauscht und den Filter für jeden eingehenden Request aktiviert, bevor irgendeine Query ausgeführt wird. Für administrative Bereiche, in denen gelöschte Datensätze bewusst sichtbar sein sollen, etwa ein Papierkorb im Backend, kann derselbe Filter gezielt wieder deaktiviert werden, ohne die generelle Query-Logik anzufassen.

5. Die delete()-Methode auf der Entity statt EntityManager::remove()

Ein häufiger Fehler bei der Einführung von Soft-Delete ist, dass Entwickler weiterhin reflexartig $entityManager->remove($entity) aufrufen, was aber ein echtes DELETE auslöst und den ganzen Sinn des Patterns zunichtemacht. Sauberer ist es, auf der Entity selbst eine Methode delete() bereitzustellen, die lediglich das deletedAt-Feld auf die aktuelle Zeit setzt, und diese Konvention im Team konsequent durchzusetzen, etwa per Code-Review oder einer eigenen PHPStan-Regel.

Manche Teams gehen noch einen Schritt weiter und implementieren ein SoftDeleteableInterface mit genau dieser delete()-Methode, das gleichzeitig als Marker für den Doctrine-Filter dient. So wird auf einen Blick klar, welche Entities überhaupt vom Soft-Delete-Mechanismus betroffen sind, und ein statischer Code-Analyzer kann problemlos prüfen, dass niemand remove() für eine Entity aufruft, die dieses Interface implementiert.

6. Fallstricke bei Unique-Constraints mit Soft-Delete

Ein klassisches Problem entsteht, sobald eine Spalte wie E-Mail-Adresse oder Artikelnummer einen klassischen UNIQUE-Index in der Datenbank hat. Wird ein Nutzer mit der E-Mail max@example.com soft-gelöscht, bleibt der Datensatz physisch bestehen und ein neuer Nutzer kann sich mit derselben E-Mail-Adresse nicht mehr registrieren, weil der Unique-Index den gelöschten Datensatz nicht kennt. Fachlich ist das aber falsch, da die Adresse aus Sicht der Anwendung wieder frei sein sollte.

Die übliche Lösung ist ein partieller (bedingter) Index, der nur für Datensätze mit deleted_at IS NULL gilt, was PostgreSQL nativ über CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL unterstützt. Bei MySQL, das partielle Indizes nicht kennt, behilft man sich oft mit einem zusätzlichen, bei jedem Soft-Delete veränderten Diskriminator-Wert, zum Beispiel indem die E-Mail beim Löschen um einen Zeitstempel-Suffix erweitert wird, um den Unique-Index technisch zu erfüllen.

7. Kaskadierung: Was passiert mit abhängigen Entities?

Während ein echtes DELETE mit onDelete: 'CASCADE' automatisch von der Datenbank übernommen wird, gibt es für Soft-Delete keine eingebaute Kaskadierung, da schließlich gar kein DELETE-Statement ausgeführt wird. Wenn eine Bestellung soft-gelöscht wird, bleiben ihre Positionen ohne zusätzliche Logik ganz normal sichtbar, was fachlich meist nicht gewünscht ist.

Die übliche Lösung ist ein Doctrine-Lifecycle-Callback oder ein Event-Listener auf preUpdate, der beim Setzen von deletedAt auf der Eltern-Entity automatisch alle abhängigen Entities ebenfalls soft-löscht. Diese Logik muss bewusst und explizit geschrieben werden, da Doctrine sie nicht automatisch aus den Assoziationen ableitet, was gleichzeitig ein Vorteil ist, weil man pro Beziehung entscheiden kann, ob eine Kaskadierung fachlich überhaupt sinnvoll ist.

8. Soft-Delete-Verhalten in eigenen Repository-Methoden berücksichtigen

Der Doctrine-Filter greift zuverlässig bei DQL- und QueryBuilder-basierten Abfragen, aber nicht bei nativen SQL-Queries oder bestimmten Bulk-Operationen wie einem direkten UPDATE über den QueryBuilder, die den Filter umgehen können. Eigene Repository-Methoden sollten deshalb, wo relevant, explizit dokumentieren, ob sie den Soft-Delete-Filter respektieren oder bewusst auch gelöschte Datensätze einschließen, etwa für einen Admin-Papierkorb.

Für den Papierkorb selbst bietet sich eine dedizierte Methode wie findDeleted() an, die den Filter für die Dauer der Abfrage gezielt deaktiviert, die Ergebnisse holt und den Filter danach wieder aktiviert. So bleibt der Normalfall (Filter aktiv) der Standardpfad, und der Sonderfall wird explizit und lesbar im Code sichtbar gemacht, statt den globalen Zustand dauerhaft zu verändern.

9. Endgültiges Löschen und DSGVO-Konformität

Soft-Delete ist kein Ersatz für eine echte Löschstrategie, sondern nur ein Zwischenschritt. Aus DSGVO-Sicht müssen personenbezogene Daten nach Ablauf einer definierten Frist tatsächlich und unwiderruflich entfernt werden, nicht nur als gelöscht markiert bleiben. Ein soft-gelöschter Datensatz ist technisch weiterhin vollständig auslesbar, solange niemand ein echtes DELETE ausführt.

In der Praxis empfiehlt sich ein periodischer Symfony-Console-Command, der per Cronjob läuft und alle Datensätze mit einem deletedAt-Wert älter als die definierte Aufbewahrungsfrist tatsächlich per echtem DELETE entfernt. So kombiniert man die Vorteile von Soft-Delete im Alltag (Undo-Möglichkeit, referenzielle Integrität) mit der rechtlichen Pflicht zur endgültigen Löschung nach Ablauf der Frist.

Ansatz Vorteil Nachteil Empfehlung
deletedAt (DateTimeImmutable) Liefert Zeitstempel und Status zugleich Etwas mehr Speicherplatz als Boolean Standardwahl für die meisten Projekte
isDeleted (Boolean) Sehr einfach zu verstehen Kein Löschzeitpunkt verfügbar Nur bei sehr einfachen Anforderungen
Doctrine SQLFilter Automatisches Ausblenden ohne manuelle WHERE-Klauseln Muss pro Request aktiviert werden Immer in Kombination mit deletedAt nutzen
Partieller Unique-Index Löst Unique-Constraint-Konflikt sauber Nicht in MySQL nativ verfügbar PostgreSQL bevorzugen oder Workaround nutzen

Mironsoft

Symfony-Architektur, saubere Domänenlogik und Legacy-Modernisierung

Symfony-Anwendungen, die nach zwei Jahren noch wartbar sind?

Wir prüfen bestehende Symfony-Projekte auf aufgeblähte Controller, fehlende Serviceabstraktionen und ungetestete Kernlogik und bauen daraus eine Architektur, die neue Features aufnimmt, ohne bei jedem Release brüchiger zu werden.

Architektur-Review

Bundle-Struktur, Dependency Injection und Serviceabstraktionen auf Wartbarkeit prüfen.

Legacy-Modernisierung

Schrittweise Migration veralteter Symfony-Versionen ohne kompletten Neuschrieb.

Test- und Qualitätssicherung

PHPUnit, PHPStan und CI-Pipelines für nachhaltige Codequalität aufbauen.

10. Zusammenfassung

Doctrine Soft-Delete

Spalte

deletedAt als DateTimeImmutable liefert Zeitstempel und Status in einem Feld

Filter

Doctrine SQLFilter blendet gelöschte Datensätze automatisch aus allen Queries aus

Unique-Index

Partielle Indizes oder Diskriminator-Suffixe lösen Konflikte bei erneuter Nutzung

Endgültiges Löschen

Periodischer Command entfernt Daten nach Ablauf der Aufbewahrungsfrist

11. FAQ: Doctrine Soft-Delete

1Was ist der Unterschied zwischen Soft-Delete und echtem Löschen?
Beim echten Löschen wird der Datensatz per DELETE FROM physisch aus der Datenbank entfernt. Bei Soft-Delete bleibt der Datensatz bestehen, wird aber über ein Feld wie deletedAt als gelöscht markiert und aus normalen Abfragen automatisch herausgefiltert.
2Sollte ich deletedAt oder ein Boolean-Flag verwenden?
In den meisten Fällen ist deletedAt als DateTimeImmutable die bessere Wahl, weil es sowohl den Löschstatus als auch den genauen Zeitpunkt liefert, was für Audit-Logs und Aufbewahrungsfristen fast immer relevant wird.
3Wie funktioniert ein Doctrine SQLFilter technisch?
Eine Klasse erbt von SQLFilter und implementiert addFilterConstraint(), die eine SQL-Bedingung zurückgibt. Diese Bedingung wird von Doctrine automatisch an jede Query für die betroffene Entity angehängt, sobald der Filter aktiviert ist.
4Muss der Filter bei jedem Request neu aktiviert werden?
Ja, Doctrine-Filter sind standardmäßig inaktiv und müssen explizit über getFilters()->enable() eingeschaltet werden, üblicherweise in einem Event-Subscriber, der auf kernel.request reagiert.
5Wie löse ich Unique-Constraint-Konflikte mit soft-gelöschten Datensätzen?
Am saubersten mit einem partiellen Index, der nur für Datensätze mit deleted_at IS NULL gilt. Bei Datenbanken ohne partielle Indizes hilft ein zusätzlicher Diskriminator-Wert, der beim Löschen verändert wird.
6Kaskadiert Soft-Delete automatisch auf verknüpfte Entities?
Nein, anders als bei echtem DELETE mit CASCADE gibt es bei Soft-Delete keine automatische Kaskadierung. Das muss explizit über einen Lifecycle-Callback oder Event-Listener implementiert werden.
7Wie greife ich trotzdem auf gelöschte Datensätze zu, zum Beispiel für einen Papierkorb?
Über eine dedizierte Repository-Methode, die den Filter für die Dauer der Abfrage gezielt deaktiviert, die Ergebnisse lädt und den Filter danach wieder aktiviert.
8Ersetzt Soft-Delete die DSGVO-konforme Löschung personenbezogener Daten?
Nein, Soft-Delete ist nur ein Zwischenschritt. Nach Ablauf der gesetzlichen Aufbewahrungsfrist müssen die Daten über einen echten DELETE-Vorgang unwiderruflich entfernt werden, meist über einen periodischen Console-Command.
9Wirkt der Doctrine-Filter auch bei nativen SQL-Queries?
Nein, der Filter greift nur bei DQL- und QueryBuilder-basierten Abfragen. Native SQL-Queries und manche Bulk-Updates umgehen den Filter und müssen die Bedingung manuell ergänzen.
10Sollte ich EntityManager::remove() für Soft-Delete-Entities noch verwenden?
Nein, remove() löst weiterhin ein echtes DELETE aus. Stattdessen sollte eine eigene delete()-Methode auf der Entity das deletedAt-Feld setzen und über flush() persistiert werden.