Skip to content

How-to: Soft Delete, Trash Bin, and Permanent Purge ​

FT reference: FT257 (NENE2-FT/softdeletelog) — Soft delete / trash bin / permanent purge pattern with deleted_at column

Demonstrates a three-stage lifecycle for records: active → soft-deleted (trash) → permanently purged. Active lists exclude deleted records automatically. A dedicated trash endpoint lists only deleted records. Restore returns a record from trash to active. Purge physically removes the record from the database (only allowed while in trash).


Routes ​

MethodPathDescription
POST/notesCreate a note
GET/notesList active notes (excludes soft-deleted)
GET/notes/trashList trashed notes only
GET/notes/{id}Get a single active note
DELETE/notes/{id}Soft-delete a note (moves to trash)
POST/notes/{id}/restoreRestore from trash to active
DELETE/notes/{id}/purgePermanently delete (only from trash)

Route order: /notes/trash must be registered before /notes/{id} so the literal segment trash is not captured as a path parameter.


Schema ​

sql
CREATE TABLE IF NOT EXISTS notes (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    title      TEXT NOT NULL,
    body       TEXT NOT NULL DEFAULT '',
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL,
    deleted_at TEXT NULL
);

deleted_at TEXT NULL is the soft-delete marker. When NULL the record is active; when set to an ISO timestamp the record is in the trash. No separate is_deleted boolean is needed — the timestamp also records when the delete happened, which is useful for audit trails and TTL-based purge jobs.


Domain object ​

php
final readonly class Note
{
    public function __construct(
        public int     $id,
        public string  $title,
        public string  $body,
        public string  $createdAt,
        public string  $updatedAt,
        public ?string $deletedAt,     // null = active, non-null = in trash
    ) {}

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

isDeleted() encapsulates the null-check so callers do not need to know the implementation detail.


Repository: the includeTrashed flag ​

php
public function findById(int $id, bool $includeTrashed = false): ?Note
{
    $sql = $includeTrashed
        ? 'SELECT * FROM notes WHERE id = ?'
        : 'SELECT * FROM notes WHERE id = ? AND deleted_at IS NULL';

    $rows = $this->executor->fetchAll($sql, [$id]);
    return $rows === [] ? null : $this->hydrate($rows[0]);
}

The default (includeTrashed: false) applies the deleted_at IS NULL filter so callers get the safe behaviour automatically. Only restore and purge need to see trashed records and pass includeTrashed: true explicitly.

Why not a separate findByIdIncludingTrashed() method?

A named boolean parameter is self-documenting at the call site:

  • findById($id) — clearly active-only
  • findById($id, includeTrashed: true) — clearly trash-aware

A separate method would duplicate the hydration logic or require an internal shared helper.


Listing: active vs trash ​

php
public function listActive(): array
{
    return $this->executor->fetchAll(
        'SELECT * FROM notes WHERE deleted_at IS NULL ORDER BY created_at DESC',
        [],
    );
}

public function listTrashed(): array
{
    return $this->executor->fetchAll(
        'SELECT * FROM notes WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC',
        [],
    );
}

Active notes are sorted by creation time (newest first). Trashed notes are sorted by deletion time (most recently deleted first), which is natural for a "recently deleted" UI.


Soft delete ​

php
public function softDelete(int $id, string $now): ?Note
{
    $note = $this->findById($id);   // active-only lookup
    if ($note === null) {
        return null;   // not found OR already in trash → 404
    }

    $this->executor->execute(
        'UPDATE notes SET deleted_at = ? WHERE id = ?',
        [$now, $id],
    );

    return new Note($note->id, $note->title, $note->body, $note->createdAt, $note->updatedAt, $now);
}

findById($id) without includeTrashed means that calling DELETE /notes/{id} on an already-trashed note returns null → 404. This prevents double-delete confusion: a client cannot tell from a 404 whether the note was active and missing, or already trashed.


Restore ​

php
public function restore(int $id): ?Note
{
    $note = $this->findById($id, includeTrashed: true);
    if ($note === null || !$note->isDeleted()) {
        return null;   // not found OR already active → 404
    }

    $this->executor->execute(
        'UPDATE notes SET deleted_at = NULL WHERE id = ?',
        [$id],
    );

    return new Note($note->id, $note->title, $note->body, $note->createdAt, $note->updatedAt, null);
}

includeTrashed: true is required here — the note IS deleted, so the default filter would hide it. The !$note->isDeleted() guard rejects an active note: calling restore on an active note returns null → 404. This makes restore idempotent in the "already restored" path: a client that calls restore twice gets 200 on the first call and 404 on the second.


Purge (permanent delete) ​

php
public function purge(int $id): bool
{
    $note = $this->findById($id, includeTrashed: true);
    if ($note === null || !$note->isDeleted()) {
        return false;   // not found OR still active → 404
    }

    $this->executor->execute('DELETE FROM notes WHERE id = ?', [$id]);
    return true;
}

purge() only works on trashed records (isDeleted() must be true). Calling DELETE /notes/{id}/purge on an active note returns false → 404. This guards against accidentally destroying data via the wrong endpoint — a client must explicitly soft-delete before it can purge.


State machine ​

           POST /notes
               │
               ▼
           [active]  ←──────── POST /notes/{id}/restore ────────┐
               │                                                  │
    DELETE /notes/{id}                                           │
               │                                                  │
               ▼                                                  │
           [trash]  ────────────────────────────────────────────┘
               │
    DELETE /notes/{id}/purge
               │
               ▼
          [gone — physical DELETE]

active → trash is reversible. trash → gone is irreversible. There is no direct path from active → gone: purge requires a prior soft-delete step.


Controller: route registration order ​

php
public function register(Router $router): void
{
    $router->post('/notes',              $this->create(...));
    $router->get('/notes',               $this->listActive(...));
    $router->get('/notes/trash',         $this->listTrashed(...));   // ← must come before {id}
    $router->get('/notes/{id}',          $this->get(...));
    $router->delete('/notes/{id}',       $this->softDelete(...));
    $router->post('/notes/{id}/restore', $this->restore(...));
    $router->delete('/notes/{id}/purge', $this->purge(...));
}

/notes/trash must be registered before /notes/{id}. If the order were reversed, a GET /notes/trash request would match {id} with id = "trash", fail the integer cast, and return 404 or 200 with an empty body instead of the trash list.


HTTP semantics ​

ActionMethodWhy
Soft deleteDELETEClient intends to remove the resource from their view
RestorePOSTNot idempotent (second call returns 404); POST is appropriate
PurgeDELETEClient intends permanent removal

PATCH /notes/{id} with {"deleted_at": null} is an alternative for restore, but POST /restore is more explicit and avoids leaking the internal column name in the API contract.


Design comparison ​

ApproachActive filterDeletedmarkerRestorePurge
deleted_at timestampWHERE deleted_at IS NULLTimestamp + audit trailSET deleted_at = NULLPhysical DELETE
is_deleted booleanWHERE is_deleted = 0Boolean onlySET is_deleted = 0Physical DELETE
Separate deleted_notes tableNo filter neededMove row to other tableMove row backDelete from deleted_notes

deleted_at is the most common pattern: one column, minimal schema change, and a built-in audit timestamp at no extra cost.


Released under the MIT License.