Audit logs, notification rows, expired tokens, failed-job records — some tables only ever grow. Left alone they balloon into millions of rows that slow every query and bloat the automated database backups you're paying to store. Laravel's Prunable trait fixes this the built-in way: a model declares which rows are stale, and a scheduled php artisan model:prune deletes them in safe chunks. This post shows how to wire up the Laravel Prunable trait, schedule it, clean up related files, and when to switch to MassPrunable.
Add the Prunable trait to a model#
Add the Illuminate\Database\Eloquent\Prunable trait and implement a prunable() method that returns an Eloquent query resolving the rows you no longer need. Nothing else is required — no config, no package.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Prunable;
class AuditLog extends Model
{
use Prunable;
/**
* Get the prunable model query.
*/
public function prunable(): Builder
{
// Delete audit logs older than three months.
return static::where('created_at', '<=', now()->subMonths(3));
}
}
The query is just a normal query builder, so you can be as specific as you like — scope by status, tenant, or any column. Whatever the query matches is what gets deleted.
Run and schedule model:prune#
The laravel model:prune command discovers every Prunable model in app/Models and runs its prunable() query. It deletes in chunks of 1000 rows by default, so a huge table never gets loaded into memory in one go. Override the batch size with --chunk if you need to tune it for your database.
Before you trust it, preview what it would remove with --pretend. It reports the count per model without deleting anything:
php artisan model:prune --pretend
Once you're happy, schedule it. In Laravel 12 and 13 there's no App\Console\Kernel, so the schedule lives in routes/console.php:
use Illuminate\Support\Facades\Schedule;
Schedule::command('model:prune')->daily();
Pruning only helps if that schedule actually fires. In production that means running the Laravel scheduler the right way — as a real cron entry or a dedicated container — otherwise the command never runs and the table keeps growing.
If your models don't live in app/Models (a modular or DDD layout, say), the auto-discovery finds nothing and silently prunes zero rows. Point it at your classes explicitly:
Schedule::command('model:prune', [
'--model' => [\App\Domain\Audit\AuditLog::class],
])->daily();
Clean up related data with pruning()#
Deleting the row is often not the whole job. If a record owns an uploaded file, a cache entry, or an external resource, you want that gone too. Define a pruning() method and it runs immediately before each model is deleted:
use Illuminate\Support\Facades\Storage;
/**
* Prepare the model for pruning.
*/
protected function pruning(): void
{
Storage::disk('s3')->delete($this->export_path);
}
Every chunk that gets pruned also dispatches an Illuminate\Database\Events\ModelsPruned event carrying the model class and the number of rows removed. Listen for it if you want a record of how much each run cleaned up — and if you're already attaching context to your logs and jobs, those prune counts drop straight into the same structured output.
Prunable vs MassPrunable#
Prunable retrieves each matching model and deletes it individually. That's what makes the pruning() hook and the deleting/deleted model events fire — but retrieving and deleting millions of rows one at a time has a cost.
MassPrunable swaps that for a single mass-deletion query. It's dramatically faster because the models are never hydrated, but that's exactly the trade-off: the pruning() method never runs, and no model events are dispatched.
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\MassPrunable;
use Illuminate\Database\Eloquent\Model;
class AuditLog extends Model
{
use MassPrunable;
public function prunable(): Builder
{
return static::where('created_at', '<=', now()->subMonths(3));
}
}
It's the same decision as choosing between queue chains and batches: reach for MassPrunable when you just need rows gone at speed, and stick with Prunable when each deletion needs to trigger side effects like file cleanup or observers.
Gotchas and Edge Cases#
Soft deletes are hard-deleted. If a model uses SoftDeletes, any row matching your prunable() query is removed with forceDelete() — permanently, not re-trashed. That's usually what you want for pruning, but scope the query to trashed rows so you don't nuke live data:
public function prunable(): Builder
{
return static::onlyTrashed()
->where('deleted_at', '<=', now()->subDays(30));
}
MassPrunable silently skips your cleanup. If you switch a model to MassPrunable but still rely on a pruning() hook or a model observer to delete files, those side effects stop happening and you leak orphaned data. Only use it on models with no per-row cleanup.
No schedule, no pruning. The trait does nothing on its own. If --pretend shows the right counts but the table still grows, your scheduler isn't running the command.
Wrapping Up#
Add the Prunable trait, write a prunable() query, verify it with --pretend, then schedule model:prune daily. That's the whole pattern — and it keeps logs, notifications, and expired tokens from ever growing unbounded. Start with Prunable so events and file cleanup keep working, and only move to MassPrunable once you've confirmed a model has no side effects to run.
If a table has already grown past comfortable and you need to change its schema, prune it lean first, then apply zero-downtime migrations with the expand-and-contract pattern so the cleanup doesn't collide with a long-running ALTER.
FAQ#
What is the Prunable trait in Laravel?
Prunable is an Eloquent trait (Illuminate\Database\Eloquent\Prunable) that lets a model declare which of its rows are stale. You add the trait and implement a prunable() method that returns a query matching the records you want gone. The php artisan model:prune command then finds those records and deletes them in batches, so you don't have to write or maintain your own cleanup scripts.
What's the difference between Prunable and MassPrunable?
Prunable retrieves each matching model, fires its deleting and deleted events, and calls the pruning() hook before removing it — so observers and per-model cleanup still run. MassPrunable skips all of that and issues a single mass-delete query, which is far faster but never loads the models, so no events or hooks fire. Use Prunable when you need side effects like deleting files, and MassPrunable when you just want rows gone quickly.
How do I schedule model pruning?
Schedule the command in routes/console.php with the Schedule facade, for example Schedule::command('model:prune')->daily(). Laravel auto-discovers every Prunable model in app/Models and prunes each one. The key thing is that your scheduler has to be running in production, otherwise the command is defined but never actually fires.
Does model:prune trigger model events?
With the Prunable trait, yes — each record is deleted individually, so the deleting and deleted events fire and the pruning() hook runs. With MassPrunable, no — records are removed with one query and never instantiated, so no model events or hooks are dispatched. Separately, both traits dispatch a ModelsPruned event after each chunk with the model class and count, which is handy for logging.
How do I permanently delete soft-deleted records with pruning?
If a model uses SoftDeletes, any row matching your prunable() query is removed with forceDelete(), so it's gone for good rather than re-trashed. Scope the query to trashed rows with onlyTrashed() and a date — such as onlyTrashed()->where('deleted_at', '<=', now()->subDays(30)) — so you only hard-delete records that have sat in the trash long enough. That gives users a grace period to restore before the data is really deleted.