skip to content

In Laravel, how do the Prunable and MassPrunable traits with php artisan model:prune remove old Eloquent records, and how do the two differ?

level: middleimportance: should knowfreq 32%

answer

  1. prunable() returns the query
  2. Prunable: chunks, per-model delete
  3. MassPrunable: one DELETE per chunk
  4. soft-deletable models are force-deleted
  5. --pretend, --model, --chunk=1000

basics

~20 s

Both traits make a model define prunable(), a query for rows to remove, and model:prune runs it. Prunable loads matches in chunks and deletes each model, calling pruning() and firing events; MassPrunable deletes by query, faster but with no events or hook.

solid answer

~40 s

Add `use Prunable;` (or `MassPrunable`) and a `prunable(): Builder` method, for example `static::onlyTrashed()->where('deleted_at', '<=', now()->subYears(7))`. `php artisan model:prune` finds models using either trait in `app/Models` (or `--model`, `--path`) and calls `pruneAll()`. With **Prunable**, the query gets `withTrashed()` for soft-deletable models, is walked with `chunkById()` (1,000 by default), and each model's `prune()` calls the `pruning()` hook and then `forceDelete()` if the model soft-deletes, otherwise `delete()` — so model events fire and you can remove files first; a failure on one model is reported and the rest continue. With **MassPrunable**, the query is limited to the chunk size and run as a builder `forceDelete()` or `delete()` in a loop: no models load, no events fire, no `pruning()` hook. `--pretend` reports counts without deleting; `--chunk` changes the size; `--except` excludes models.

code

bash · 5 lines
bash
# count what would be removed, delete nothing
php artisan model:prune --model="App\Models\Matter" --pretend

# prune every prunable model except audit logs, 500 per chunk
php artisan model:prune --except="App\Models\AuditLog" --chunk=500

go deeper

for a junior

Recall that a model defines prunable() and that php artisan model:prune deletes the rows that query matches.

for a middle

Explain Prunable versus MassPrunable, the pruning() hook, force deletion of soft-deleted models and the command options.

for a senior

Choose per table between event-safe and fast pruning, keep conditions explicit, and dry-run changes before they delete data.

for a principal

Own the retention policy behind prunable(): legal holds, audit needs and deletion guarantees across backups and replicas.

## The problem pruning solves A legal-case manager soft-deletes matters so they stay recoverable, and keeps audit and session-like records that lose value over time. Without cleanup these tables only grow. Eloquent's **pruning** feature lets each model declare which of its rows are obsolete, and one Artisan command removes them. ## Declaring what to prune ```php use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Prunable; use Illuminate\Database\Eloquent\SoftDeletes; class Matter extends Model { use Prunable, SoftDeletes; public function prunable(): Builder { return static::onlyTrashed() ->where('deleted_at', '<=', now()->subYears(7)) ->whereNull('legal_hold_at'); } protected function pruning(): void { Storage::deleteDirectory("matters/{$this->id}"); } } ``` A model with the trait but without `prunable()` throws a `LogicException` when pruned. ## Running it `php artisan model:prune` discovers models that use either trait under `app/Models` and prunes each. Its options, from the command's signature: - `--model=*` prunes only the given classes; `--except=*` skips some; - `--path=*` looks for models in other directories; - `--chunk=1000` sets how many records each chunk handles; - `--pretend` prints how many records would be pruned without deleting anything. Running it on a schedule is a scheduling concern; the command itself is a one-shot. ## Prunable versus MassPrunable | Aspect | `Prunable` | `MassPrunable` | |---|---|---| | How rows are removed | loads models with `chunkById()`, calls `prune()` on each | runs a limited delete query repeatedly | | `pruning()` hook | yes, before each delete | no | | Model events | yes (`deleting`, `deleted`, or force-delete events) | no | | Soft-deletable model | query gets `withTrashed()`; each model is `forceDelete()`d | builder `forceDelete()` | | Failure on one row | reported, the rest continue | the query fails as a whole | | Speed on millions of rows | slow: one delete per model | fast: one statement per chunk | ## Details that matter in production 1. **Soft-deleted models are force-deleted.** Pruning is permanent even for models that normally soft-delete; `Prunable::prune()` calls `forceDelete()` when the model uses `SoftDeletes`. 2. **Mass pruning skips global scopes.** The builder `forceDelete()` used for soft-deletable models runs without global scopes, so a tenant scope does not narrow it; the `prunable()` query must carry every condition it needs explicitly. 3. **Files and external state.** Only `Prunable` can clean up related files or remote records through `pruning()` or events. With `MassPrunable`, anything outside the table is orphaned. 4. **Events.** Each chunk dispatches a `ModelsPruned` event with the model class and running total, which the command uses for its output. 5. **Dry runs first.** `--pretend` is the safe first step whenever a `prunable()` query changes. ## A worked retention rule A firm's policy says archived matters are destroyed seven years after archiving, unless a legal hold is recorded. 1. `prunable()` returns `static::onlyTrashed()->where('deleted_at', '<=', now()->subYears(7))->whereNull('legal_hold_at')`, so live matters and held matters never match. 2. The model uses `Prunable`, because each matter has stored documents that `pruning()` must delete first. 3. Before the first real run, `php artisan model:prune --model="App\Models\Matter" --pretend` confirms the count looks right. 4. The command then runs on a schedule, and the `ModelsPruned` events give a per-run total for the audit log. If the documents lived in their own table with their own prunable rule, `MassPrunable` would be enough for the matters themselves. ## Choosing Use `Prunable` when deleting a row must also clean up something else, or observers must see the delete. Use `MassPrunable` for high-volume tables where each row is self-contained, such as logs or expired tokens.

  • Why might a MassPrunable model leave orphaned files behind?
    `MassPrunable` deletes with a query, so no models are loaded, no `pruning()` hook runs and no model events fire. Any cleanup that lives in `pruning()`, an observer or a `deleted` listener is skipped. If deleting a row must also remove stored documents, use `Prunable`, or clean the files in a separate step keyed on the same condition.
  • What does model:prune do with a soft-deleting model under Prunable?
    It adds `withTrashed()` to the prunable query so trashed rows can match, then calls `prune()` on each, which force-deletes the model instead of soft-deleting it. Pruning is therefore permanent; a condition such as `onlyTrashed()` in `prunable()` limits it to rows already archived.

saying these in an interview costs you the question

  • Believing pruning soft-deletes rows on SoftDeletes models
  • Expecting MassPrunable to call pruning() or fire model events
  • Thinking model:prune runs automatically without being invoked or scheduled
  • Relying on a tenant global scope to narrow a mass prune
  • Running a changed prunable() query without --pretend first