Symfony Doctrine Soft Delete Pattern: A Clean Implementation
AI generated
SF
{ }
Doctrine ORM
Soft Delete in Symfony: Implementing the Doctrine Pattern Cleanly
Deleting without deleting: filters, columns, and pitfalls

Why a real DELETE is often the wrong choice for referenced data, and how to build a robust soft delete pattern with a Doctrine filter, a deletedAt column, and clean unique constraints.

15 min read Doctrine ORM Symfony 7

1. Why deleting referenced data for real is problematic

Products referenced in an order, comments referenced in a ticket, line items referenced in an invoice: as soon as an entity is referenced by other records, a plain DELETE FROM quickly becomes dangerous. Either a foreign key constraint blocks the deletion entirely and the application throws an ugly database exception, or a CASCADE unintentionally wipes out entire histories, such as every order line of a customer who was only supposed to be deactivated.

From a business perspective, real deletion is also often simply wrong. A customer who cancels their account usually should not vanish from the database immediately for compliance reasons, but remain traceable for a defined retention period. This is exactly what soft delete is for: the record physically stays in the table but is marked as deleted and automatically filtered out of all normal queries.

2. deletedAt column vs. status flag: two approaches compared

The two most common implementations are a nullable deletedAt column of type DateTimeImmutable or a simple boolean flag like isDeleted. The flag looks simpler at first glance, but it loses an important piece of information: the moment of deletion. That timestamp is almost always relevant in practice, for example to calculate a retention period or to reconstruct exactly when something happened in an audit log.

That is why the deletedAt column is the better choice in most projects. It provides both the flag (null means not deleted, a value means deleted) and the timestamp at once, without maintaining two redundant columns. In Doctrine, the field is typically declared as #[ORM\Column(type: 'datetime_immutable', nullable: true)] and set through a simple delete() method on the entity instead of calling the entity manager's remove().

3. Implementing a Doctrine filter for automatic hiding

Without an extra mechanism, every single query in the project would need a manual WHERE deleted_at IS NULL condition, which is guaranteed to be forgotten sooner or later. Doctrine solves this elegantly with SQL filters, implemented as a class extending Doctrine\ORM\Query\Filter\SQLFilter with an addFilterConstraint() method, which gets appended automatically to every query for the affected entity.

The filter is registered once in doctrine.yaml and must then be explicitly enabled through the entity manager, usually right at the start of the request in an event subscriber. The example below shows a complete SoftDeleteableFilter that detects which entities actually have a deletedAt column through a marker interface, and only applies the filter to those.


<?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. Registering and enabling the filter per request

For Doctrine to actually apply the filter, it first needs to be registered under doctrine.orm.filters in doctrine.yaml, with the fully qualified class name and a descriptive alias such as softdeleteable. By default a registered filter is still inactive, it must be explicitly turned on through $entityManager->getFilters()->enable('softdeleteable').

The cleanest place for this activation is a kernel event subscriber listening to kernel.request, enabling the filter for every incoming request before any query runs. For administrative areas where deleted records should intentionally be visible, such as an admin trash view, the same filter can be selectively disabled again without touching the general query logic.

5. A delete() method on the entity instead of EntityManager::remove()

A common mistake when introducing soft delete is that developers keep reflexively calling $entityManager->remove($entity), which triggers a real DELETE and defeats the whole point of the pattern. It is cleaner to provide a delete() method on the entity itself that simply sets the deletedAt field to the current time, and to consistently enforce that convention across the team, for example through code review or a dedicated PHPStan rule.

Some teams go a step further and implement a SoftDeleteableInterface with exactly this delete() method, which also serves as a marker for the Doctrine filter. That makes it immediately obvious which entities are actually affected by the soft delete mechanism, and a static analyzer can easily verify that nobody calls remove() on an entity implementing that interface.

6. Unique constraint pitfalls with soft delete

A classic problem arises as soon as a column such as email address or SKU has a plain UNIQUE index in the database. If a user with the email max@example.com is soft deleted, the record physically remains, and a new user cannot register with the same email address anymore, because the unique index has no idea the record is considered deleted. From a business standpoint this is wrong, since the address should be available again.

The usual solution is a partial (conditional) index that only applies to records with deleted_at IS NULL, which PostgreSQL natively supports through CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL. On MySQL, which does not support partial indexes, a common workaround is an additional discriminator value that changes on every soft delete, for example appending a timestamp suffix to the email address to satisfy the unique index technically.

7. Cascading: what happens to dependent entities?

While a real DELETE with onDelete: 'CASCADE' is handled automatically by the database, there is no built in cascading for soft delete, since no DELETE statement is executed at all. When an order gets soft deleted, its line items remain perfectly visible without extra logic, which is usually not what the business actually wants.

The usual solution is a Doctrine lifecycle callback or an event listener on preUpdate that automatically soft deletes all dependent entities as well when deletedAt is set on the parent entity. This logic has to be written deliberately and explicitly, since Doctrine does not derive it automatically from associations, which is actually an advantage, because you can decide per relationship whether cascading even makes business sense.

8. Accounting for soft delete behavior in custom repository methods

The Doctrine filter reliably applies to DQL and query builder based queries, but not to native SQL queries or certain bulk operations like a direct UPDATE through the query builder, which can bypass the filter. Custom repository methods should therefore explicitly document, where relevant, whether they respect the soft delete filter or intentionally include deleted records too, for example for an admin trash view.

For the trash view itself, a dedicated method like findDeleted() works well, temporarily disabling the filter for the duration of the query, fetching the results, and re enabling the filter afterward. That way the normal case (filter active) stays the default path, and the special case becomes explicit and readable in the code instead of permanently mutating global state.

9. Permanent deletion and GDPR compliance

Soft delete is not a replacement for a real deletion strategy, it is only an intermediate step. From a GDPR perspective, personal data must actually and irrevocably be removed after a defined retention period expires, not just remain marked as deleted. A soft deleted record is technically still fully readable as long as nobody executes a real DELETE.

In practice, a periodic Symfony console command running via cron job is recommended, actually removing every record whose deletedAt value is older than the defined retention period through a real DELETE. This combines the everyday benefits of soft delete (undo capability, referential integrity) with the legal obligation to permanently delete data once the retention period has passed.

Approach Advantage Drawback Recommendation
deletedAt (DateTimeImmutable) Provides timestamp and status at once Slightly more storage than a boolean Default choice for most projects
isDeleted (Boolean) Very simple to understand No deletion timestamp available Only for very simple requirements
Doctrine SQLFilter Automatic hiding without manual WHERE clauses Must be enabled per request Always use together with deletedAt
Partial unique index Cleanly resolves unique constraint conflicts Not natively available in MySQL Prefer PostgreSQL or use a workaround

Mironsoft

Symfony architecture, clean domain logic, and legacy modernization

Symfony applications that stay maintainable two years down the line?

We review existing Symfony projects for bloated controllers, missing service abstractions, and untested core logic, then build an architecture that absorbs new features without getting more fragile with every release.

Architecture Review

Checking bundle structure, dependency injection, and service abstractions for maintainability.

Legacy Modernization

Incrementally migrating outdated Symfony versions without a full rewrite.

Testing and Quality Assurance

Setting up PHPUnit, PHPStan, and CI pipelines for lasting code quality.

10. Summary

Doctrine Soft Delete

Column

deletedAt as DateTimeImmutable provides timestamp and status in one field

Filter

Doctrine SQLFilter automatically hides deleted records from every query

Unique index

Partial indexes or discriminator suffixes resolve conflicts on reuse

Permanent deletion

A periodic command removes data once the retention period expires

11. FAQ: Doctrine Soft Delete

1What is the difference between soft delete and real deletion?
With real deletion, the record is physically removed from the database via DELETE FROM. With soft delete, the record stays in place but is marked as deleted through a field like deletedAt and automatically filtered out of normal queries.
2Should I use deletedAt or a boolean flag?
In most cases deletedAt as a DateTimeImmutable is the better choice, because it provides both the deletion status and the exact timestamp, which is almost always relevant for audit logs and retention periods.
3How does a Doctrine SQLFilter work technically?
A class extends SQLFilter and implements addFilterConstraint(), which returns an SQL condition. Doctrine automatically appends this condition to every query for the affected entity once the filter is enabled.
4Does the filter need to be enabled again on every request?
Yes, Doctrine filters are disabled by default and must be explicitly turned on via getFilters()->enable(), usually in an event subscriber reacting to kernel.request.
5How do I resolve unique constraint conflicts with soft deleted records?
The cleanest way is a partial index that only applies to records with deleted_at IS NULL. For databases without partial indexes, an additional discriminator value that changes on deletion helps.
6Does soft delete automatically cascade to related entities?
No, unlike real DELETE with CASCADE, soft delete has no automatic cascading. This must be implemented explicitly through a lifecycle callback or event listener.
7How do I still access deleted records, for example for a trash view?
Through a dedicated repository method that temporarily disables the filter for the duration of the query, loads the results, and re enables the filter afterward.
8Does soft delete replace GDPR compliant deletion of personal data?
No, soft delete is only an intermediate step. Once the legal retention period expires, the data must be irrevocably removed through a real DELETE, usually via a periodic console command.
9Does the Doctrine filter also apply to native SQL queries?
No, the filter only applies to DQL and query builder based queries. Native SQL queries and some bulk updates bypass the filter and need the condition added manually.
10Should I still use EntityManager::remove() for soft delete entities?
No, remove() still triggers a real DELETE. Instead, a dedicated delete() method on the entity should set the deletedAt field and be persisted via flush().