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.
Table of Contents
- 1. Why deleting referenced data for real is problematic
- 2. deletedAt column vs. status flag: two approaches compared
- 3. Implementing a Doctrine filter for automatic hiding
- 4. Registering and enabling the filter per request
- 5. A delete() method on the entity instead of EntityManager::remove()
- 6. Unique constraint pitfalls with soft delete
- 7. Cascading: what happens to dependent entities?
- 8. Accounting for soft delete behavior in custom repository methods
- 9. Permanent deletion and GDPR compliance
- 10. Summary
- 11. FAQ
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