skip to content

With Eloquent's SoftDeletes trait, what do delete(), withTrashed(), onlyTrashed(), restore() and forceDelete() do to a matter and its queries?

level: juniorimportance: must knowfreq 62%

answer

  1. deleted_at timestamp, not DELETE
  2. SoftDeletingScope hides trashed rows
  3. withTrashed / onlyTrashed / trashed()
  4. restore() nulls deleted_at
  5. builder delete() soft-deletes in bulk

basics

~20 s

With SoftDeletes, delete() sets deleted_at instead of removing the row, and a global scope hides trashed rows from queries. withTrashed() includes them, onlyTrashed() returns only them, restore() clears deleted_at, and forceDelete() really deletes the row.

solid answer

~30 s

Adding `use SoftDeletes;` to `Matter` registers `SoftDeletingScope`, a global scope that adds `whereNull('matters.deleted_at')` to every query, and changes `delete()` to an `UPDATE` setting `deleted_at` (and `updated_at`) to now. The row stays, `$matter->trashed()` becomes `true`, and normal queries, relations and `whereHas()` stop seeing it. `Matter::withTrashed()` removes that filter, `onlyTrashed()` replaces it with `whereNotNull`, and `withoutTrashed()` restores the default. `$matter->restore()` sets `deleted_at` back to `null` and saves; `Matter::onlyTrashed()->where(...)->restore()` does it in bulk. `forceDelete()` on a model issues a real `DELETE`. On the builder, `Matter::where(...)->delete()` soft-deletes every match in one `UPDATE`, while `->forceDelete()` hard-deletes them.

code

php · 14 lines
php
<?php

use App\Models\Matter;

$matter->delete();                       // UPDATE matters SET deleted_at = now()
Matter::find($matter->id);               // null: hidden by SoftDeletingScope
$matter->trashed();                      // true

$archive = Matter::onlyTrashed()->latest('deleted_at')->paginate();

Matter::withTrashed()->findOrFail($id)->restore();   // deleted_at = null

Matter::where('status', 'draft')->delete();          // bulk soft delete, one UPDATE
$matter->forceDelete();                              // real DELETE

go deeper

for a junior

Recall that SoftDeletes turns delete() into a deleted_at timestamp and that withTrashed() and onlyTrashed() bring trashed rows back into queries.

for a middle

Explain the SoftDeletingScope, builder-level soft deletes, restore and forceDelete, and how relations treat trashed rows.

for a senior

Plan for unique indexes, missing cascades, bulk deletes without events and table growth, and decide between a status column and soft deletes.

for a principal

Set data-retention policy: what is soft-deleted, what is purged when, and how legal holds and audits interact with deletion.

## What soft deleting means in Eloquent In a legal-case manager, "delete" rarely means destroy: a closed or mistaken matter must stay recoverable and auditable. **Soft deleting** marks a row as deleted with a timestamp instead of removing it. In Eloquent it takes the `SoftDeletes` trait on the model and a nullable `deleted_at` column on the table. ## What changes when the trait is added Reading the trait and its scope in the framework source: 1. **A global scope is registered.** `SoftDeletingScope::apply()` adds `whereNull(<table>.deleted_at)`, so every Eloquent query of the model hides trashed rows. 2. **`delete()` becomes an update.** On a model, it sets `deleted_at` (and `updated_at` when timestamps are on) to the current time with an `UPDATE`, fires the usual deleting and deleted events plus a `trashed` event, and keeps the row. 3. **Builder deletes become updates too.** The scope installs an `onDelete` handler, so `Matter::where('status', 'draft')->delete()` runs one `UPDATE ... SET deleted_at = now()` for all matches. 4. **Builder methods are added:** `withTrashed()`, `withoutTrashed()`, `onlyTrashed()`, `restore()`, `restoreOrCreate()` and `createOrRestore()`. ## The API at a glance | Call | Effect | |---|---| | `$matter->delete()` | sets `deleted_at`; row stays | | `$matter->trashed()` | `true` when `deleted_at` is not null | | `Matter::withTrashed()->find($id)` | finds live or trashed | | `Matter::onlyTrashed()->get()` | only trashed rows (an archive view) | | `$matter->restore()` | sets `deleted_at` to null and saves | | `$matter->forceDelete()` | real `DELETE` for this row | | `Matter::forceDestroy($ids)` | loads each by ID, including trashed, and force-deletes it | | `Matter::where(...)->forceDelete()` | real `DELETE` in one query | Quiet variants, `forceDeleteQuietly()` and `restoreQuietly()`, skip model events. ## Effects on relations and filters - `$client->matters` excludes trashed matters, because relation queries apply the related model's global scopes. - `Client::whereHas('matters')` ignores trashed matters in its subquery for the same reason. - A `belongsTo` whose parent is trashed returns `null`; add `->withTrashed()` to the relation definition when a document must still show its archived matter. - Queries through `DB::table('matters')` see every row, trashed or not: the scope is Eloquent-only. ## Common traps - **Unique columns.** A unique index on `reference_number` still counts trashed rows, so reusing the number of an archived matter fails. Include `deleted_at` in the index design, or restore instead of recreating. - **Cascades.** A foreign key's `ON DELETE CASCADE` never fires on a soft delete, because nothing is deleted. Soft-deleting a matter leaves its documents live unless you delete them too. - **Builder `forceDelete()` skips global scopes.** It runs the base query directly, so a tenant filter or any other global scope does not apply, and without `onlyTrashed()` it deletes live and trashed rows alike; direct `where` clauses and `onlyTrashed()` itself are kept. - **Growth.** Trashed rows accumulate forever unless something prunes them. ## Working with the archive - **Archive listing:** `Matter::onlyTrashed()->latest('deleted_at')->paginate()` sorts by when each matter was archived. - **Bulk restore:** `Matter::onlyTrashed()->where('client_id', $clientId)->restore()` clears `deleted_at` for every match in one `UPDATE`, without per-model events. - **Checking one record:** `Matter::withTrashed()->findOrFail($id)` followed by `$matter->trashed()` tells an archived matter from a live one. - **Creating or reviving:** `Matter::createOrRestore(['reference_number' => $ref], [...])` restores a trashed match instead of inserting a duplicate. - **Cleanup:** trashed rows are removed for good only by `forceDelete()` or by pruning. ## When to use it Use soft deletes where recovery, audit or legal hold matters. Do not use it as a substitute for an explicit status: an "archived" matter that users still browse is often clearer as `status = archived` than as a trashed row that every query must opt into.

  • Does Matter::where('status', 'draft')->delete() fire model events for each soft-deleted matter?
    No. The soft-delete scope replaces the builder's delete with a single `UPDATE` setting `deleted_at`, so no models are loaded and no per-model events fire. To run events, load the models and call `delete()` on each, for example in a chunked loop.
  • How do you show a document whose parent matter has been soft-deleted?
    Define the `belongsTo` relation with `->withTrashed()`, as in `return $this->belongsTo(Matter::class)->withTrashed();`. The relation query then removes the soft-delete scope, so `$document->matter` returns the archived matter instead of `null`.

A soft delete is moving a case file to the archive shelf rather than the shredder: it disappears from the day-to-day cabinet, anyone can fetch it back with the right request, and only an explicit shredding order destroys it.

saying these in an interview costs you the question

  • Believing delete() removes the row once SoftDeletes is used
  • Expecting ON DELETE CASCADE to run on a soft delete
  • Thinking relations still return trashed children by default
  • Assuming unique indexes ignore soft-deleted rows
  • Saying DB::table() queries hide trashed rows