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.