Skip to content

Soft Deletes

Not every delete should vanish from disk. Soft deletes mark rows with a deleted_at timestamp instead of issuing DELETE, so you can restore mistakes, audit history, or respect foreign-key constraints that prefer a living row.

In Arvel, mix SoftDeletes into your model and the framework handles the rest: repositories soft-delete by default, queries hide trashed rows automatically, and the query builder exposes Laravel-familiar escape hatches.

Adding the mixin

from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column

from arvel.data import ArvelModel
from arvel.data.soft_deletes import SoftDeletes


class Post(SoftDeletes, ArvelModel):
    __tablename__ = "posts"

    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(200))

SoftDeletes declares deleted_at: Mapped[datetime | None] and registers a global scope that filters to deleted_at IS NULL for ordinary reads.

Querying trashed rows

The query builder mirrors Eloquent’s vocabulary:

# Include trashed rows in the result set
await Post.query(session).with_trashed().all()

# Only soft-deleted rows
await Post.query(session).only_trashed().all()

Under the hood these manipulate the SoftDeleteScope global scope — the same mechanism other global scopes use, so behavior stays consistent.

Deleting and restoring

Repositories detect the mixin: delete() sets deleted_at instead of removing the row. Dedicated restore flows (when you implement them) go through observer hooks like restoring / restored if you need side effects.

The trashed property

Each instance exposes trashedTrue when deleted_at is not None. Useful in serializers or policies without re-querying.

Migrations

Add deleted_at in a migration — nullable DateTime(timezone=True) — when you introduce the mixin. Existing rows simply have NULL and behave as "not deleted."

Soft deletes are a contract with your future self: users get undo, operators get auditability, and your code keeps a single obvious path for "this row counts" versus "this row is gone."