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.
Inhaltsverzeichnis
- 1. Warum echtes Löschen bei referenzierten Daten problematisch ist
- 2. deletedAt-Spalte vs. Status-Flag: Zwei Ansätze im Vergleich
- 3. Einen Doctrine-Filter für automatisches Ausblenden implementieren
- 4. Filter registrieren und pro Request aktivieren
- 5. Die delete()-Methode auf der Entity statt EntityManager::remove()
- 6. Fallstricke bei Unique-Constraints mit Soft-Delete
- 7. Kaskadierung: Was passiert mit abhängigen Entities?
- 8. Soft-Delete-Verhalten in eigenen Repository-Methoden berücksichtigen
- 9. Endgültiges Löschen und DSGVO-Konformität
- 10. Zusammenfassung
- 11. FAQ
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