How to Clean Millions of Stale Records Safely in Laravel
As applications grow over time, database tables accumulate obsolete and stale records—such as expired authentication tokens, old audit logs, transient webhook payloads, abandoned shopping carts, and temporary export files. Leaving millions of stale records in your database bloats table sizes, slows down indexes, and degrades overall application query performance.
Traditionally, developers handled this by writing custom Artisan commands or Specific SQL queries and scheduling them via cron jobs. However, Laravel provides a built-in, highly optimized solution: the Prunable and MassPrunable traits.
In this tutorial, we will learn how to use Laravel's Prunable trait to automatically clean up stale database records, understand the difference between standard pruning and mass pruning, handle related cleanup tasks (like deleting associated storage files), and schedule automated cleanup jobs.
What is Model Pruning in Laravel?
Model pruning allows you to periodically detect and delete database records that are no longer needed. By adding the Illuminate\Database\Eloquent\Prunable trait to an Eloquent model and implementing a single prunable() query builder method, Laravel handles chunking, memory management, and record deletion automatically.
Laravel provides two pruning traits depending on your performance and lifecycle needs:
- Prunable: Retrieves records in chunks, fires Eloquent model events (such as
deletinganddeleted), and allows custom cleanup logic before each model is deleted.
- MassPrunable: Executes a direct, high-performance database-level
DELETEquery without hydrating models or firing model events. Ideal for purging massive tables with millions of rows.
1. Setting Up the Prunable Trait
Let's take a common real-world example: an ActivityLog model where logs older than 30 days should be automatically deleted.
Step 1: Add the Prunable Trait to the Model
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Prunable;
class ActivityLog extends Model
{
use Prunable;
protected $fillable = [
'user_id',
'action',
'ip_address',
'payload',
];
/**
* Get the prunable model query.
*/
public function prunable(): Builder
{
return static::where('created_at', '<=', now()->subDays(30));
}
}
Let's understand what is happening here:
- We import and use the
Illuminate\Database\Eloquent\Prunabletrait on the model.
- We define the
prunable()method, which returns an EloquentBuilderinstance defining the records that are eligible for deletion (in this case, records older than 30 days).
Step 2: Cleaning Up Associated Resources with pruning()
If your model is linked to external resources—such as stored files in AWS S3, local disk assets, or cache keys—you can define a pruning() lifecycle hook on the model. This method executes automatically right before the record is deleted from the database:
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Prunable;
use Illuminate\Support\Facades\Storage;
class TemporaryExport extends Model
{
use Prunable;
protected $fillable = [
'file_path',
'user_id',
'status',
];
/**
* Prune exports older than 48 hours.
*/
public function prunable(): Builder
{
return static::where('created_at', '<=', now()->subHours(48));
}
/**
* Prepare the model for pruning.
*/
protected function pruning(): void
{
// Delete the generated export file from storage before deleting the DB record
if ($this->file_path && Storage::disk('s3')->exists($this->file_path)) {
Storage::disk('s3')->delete($this->file_path);
}
}
}
2. High-Performance Purging: Using MassPrunable
When you have tables containing millions of rows—such as high-volume API request logs, IoT metric feeds, or audit traces—hydrating each individual model into memory to fire events can slow down execution and consume excessive server memory.
For raw database throughput, use the MassPrunable trait instead:
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\MassPrunable;
use Illuminate\Database\Eloquent\Model;
class WebhookCall extends Model
{
use MassPrunable;
protected $fillable = [
'payload',
'response_status',
];
/**
* Delete processed webhook records older than 14 days directly in SQL.
*/
public function prunable(): Builder
{
return static::where('created_at', '<=', now()->subDays(14));
}
}
Note: Because MassPrunable executes a direct batch DELETE statement in SQL, neither the pruning() method nor model events (like deleting or deleted) will be triggered.
3. Running the Prune Command
Laravel provides a dedicated Artisan command to scan your models and prune all eligible records:
php artisan model:prune
If you want to prune a specific model only, use the --model option:
php artisan model:prune --model="App\Models\ActivityLog"
You can also preview how many records will be pruned without actually deleting them using the --pretend flag:
php artisan model:prune --pretend
4. Automating Database Cleanup via the Scheduler
To run your pruning automatically in the background, register the model:prune command in your application scheduler.
In modern Laravel applications (inside routes/console.php):
use Illuminate\Support\Facades\Schedule;
// Run model pruning daily at midnight in the background
Schedule::command('model:prune')->daily();
If you prefer to prune specific high-frequency models at different intervals:
Schedule::command('model:prune', [
'--model' => [
\App\Models\WebhookCall::class,
\App\Models\TemporaryExport::class,
],
])->hourly();
Prunable vs MassPrunable: Which One Should You Use?
| Feature | Prunable Trait | MassPrunable Trait |
|---|---|---|
| Execution Strategy | Fetches records in chunks and deletes individual models. | Executes direct chunked SQL DELETE queries. |
| Model Events | Fires deleting and deleted events. |
Does NOT fire model events. |
| File / Storage Cleanup | Supported via pruning() method. |
Not supported (ignores pruning()). |
| Performance | Moderate (hydrates PHP objects in chunks). | Extremely fast (minimal memory footprint). |
Important Things to Remember
- Database Indexing: Ensure the column used in your
prunable()query (such ascreated_atorstatus) is properly indexed in your database migration. Without an index, chunked deletes on multi-million row tables will cause full table scans and database locks.
- Soft Deletes Compatibility: If your model uses Laravel's
SoftDeletestrait,Prunablewill perform soft deletion by default. If you want to permanently remove soft-deleted records from the database, usewhereNotNull('deleted_at')in yourprunable()query.
- Custom Chunk Sizes: By default, Laravel processes prunable records in chunks of 1,000. You can customize this by setting the
--chunkoption on the command (e.g.php artisan model:prune --chunk=5000).
Conclusion
Laravel's Prunable and MassPrunable traits provide an elegant, maintainable way to keep your database lean and performant. By delegating pruning queries directly to your models and automating execution through the task scheduler, you eliminate stale data clutter without writing custom maintenance scripts.
Thank you for reading this article 😊
For any query, do not hesitate to comment 💬
.png)