Soft Deletes and Audit Trails

Keep the row, hide it with a Doctrine filter that applies to every query, and record who changed what in a separate table.

Syntaxpublic function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string

A soft delete keeps the row and marks it as gone, usually with a nullable deletedAt timestamp. You do this when the data is still needed: to restore a mistake, to keep invoices that reference it, or because an auditor will ask.

Hiding it in one place

Setting the column is the easy half. The hard half is that every query must now exclude those rows, and a condition repeated in forty repository methods will be missing from one. A Doctrine filter solves that. A filter is a class that returns a fragment of SQL, which Doctrine appends to the WHERE clause of every query it builds for a matching entity: DQL, repository finders, and the queries that load associations later. Excluded rows are never hydrated, which means never turned into entity objects, so the cost is paid in the database rather than in PHP.

What a filter does not cover

A filter only touches SQL that Doctrine generates, so SQL you wrote yourself is unaffected. Enabling or disabling a filter has no effect on entities already in memory; clear the manager and re-fetch if you need the new rules applied. Unique indexes still see hidden rows, so a soft-deleted account keeps its email reserved unless you plan for that.

Recording who changed what

The unit of work, Doctrine's record of what changed since objects were loaded, holds the old and new value of every modified field. Read it during onFlush and write an append-only audit row. Pass the actor in as a plain value from the application; where that value comes from is a later subject.

Example

Example · php
<?php
// src/Doctrine/Filter/SoftDeleteFilter.php

declare(strict_types=1);

namespace App\Doctrine\Filter;

use App\Entity\SoftDeletable;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;

final class SoftDeleteFilter extends SQLFilter
{
    /**
     * Doctrine calls this once per entity per query and appends what it
     * returns to the WHERE clause. $targetTableAlias is the alias Doctrine
     * gave that entity's table in the SQL it is building.
     */
    public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string
    {
        // Entities that do not opt in must be left alone.
        if (!$targetEntity->reflClass?->implementsInterface(SoftDeletable::class)) {
            return '';
        }

        // Ask the metadata for the column name instead of hardcoding it,
        // so a renamed column cannot break the filter silently.
        $column = $targetEntity->getColumnName('deletedAt');

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

When to use it

  • Let an admin undo a deletion for thirty days without restoring a database backup.
  • Keep historic orders readable after the customer record is removed from the site.
  • Show a reviewer which fields of a document changed and who changed them.

More examples

Registering the filter, and switching it off deliberately

A filter that is off by default is a filter somebody forgets to switch on, so enable it in configuration and treat seeing deleted rows as the special case. Use suspend() and restore() rather than disable() and enable() around a restore screen or a cleanup command, because disable() discards any parameters the filter was given and enable() hands back a fresh instance without them.

Example · yaml
# config/packages/doctrine.yaml
doctrine:
    orm:
        filters:
            soft_delete:
                class: App\Doctrine\Filter\SoftDeleteFilter
                # enabled: true applies it from the first query of every request
                enabled: true

# The longhand of "enabled: true" is doing it in PHP at runtime:
#
#     $entityManager->getFilters()->enable('soft_delete');
#
# An admin screen that must see deleted rows suspends it for one query:
#
#     $entityManager->getFilters()->suspend('soft_delete');
#     // ... run the query ...
#     $entityManager->getFilters()->restore('soft_delete');
#
# suspend()/restore() keep the filter's parameters; disable() throws them away.

An audit row per change, written during onFlush

onFlush runs after Doctrine has worked out every change but before the statements are sent, which is the one moment where you can both read the old values and add rows to the same transaction. The computeChangeSet() call is the detail that catches people out: an entity persisted this late is ignored without it, and the audit table stays empty while the code looks correct. Store field names or old and new values as data, never a reference to the entity, so the audit row still means something after the entity changes again.

Example · php
<?php
// src/EventListener/AuditLogger.php

declare(strict_types=1);

namespace App\EventListener;

use App\Entity\Article;
use App\Entity\AuditEntry;
use App\Service\ActorProvider;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;

#[AsDoctrineListener(event: Events::onFlush)]
final class AuditLogger
{
    public function __construct(
        private readonly ActorProvider $actors,
    ) {
    }

    public function onFlush(OnFlushEventArgs $args): void
    {
        $em = $args->getObjectManager();
        $unitOfWork = $em->getUnitOfWork();
        $actor = $this->actors->currentActor(); // a plain string the app supplies

        foreach ($unitOfWork->getScheduledEntityUpdates() as $entity) {
            if (!$entity instanceof Article) {
                continue;
            }

            // ['title' => [oldValue, newValue], 'body' => [...], ...]
            $changes = $unitOfWork->getEntityChangeSet($entity);

            $entry = new AuditEntry($entity->getId(), $actor, array_keys($changes));
            $em->persist($entry);

            // persist() alone is too late at this point in the flush: the new
            // row is only written if its changeset is computed explicitly.
            $unitOfWork->computeChangeSet($em->getClassMetadata(AuditEntry::class), $entry);
        }
    }
}

The entity that opts in

The interface is what the filter matches on, so implementing it is how an entity opts in, and the column name must match the field the filter asks the metadata for. Pass the timestamp in rather than calling a clock inside the entity, which keeps the behaviour testable. Deleting now means calling softDelete() and flushing, so the code path that used to call remove() has to change, and anything that still calls remove() bypasses all of this.

Example · php
<?php
// src/Entity/Article.php

declare(strict_types=1);

namespace App\Entity;

use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Article implements SoftDeletable
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 200)]
    private string $title;

    // Nullable: NULL means "live". The filter tests exactly this.
    #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
    private ?DateTimeImmutable $deletedAt = null;

    public function __construct(string $title)
    {
        $this->title = $title;
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    // Deleting is an ordinary update: set the column and flush.
    public function softDelete(DateTimeImmutable $at): void
    {
        $this->deletedAt = $at;
    }

    public function isDeleted(): bool
    {
        return $this->deletedAt !== null;
    }
}

Discussion

  • Be the first to comment on this lesson.