Pruning Stale Eloquent Models in Laravel

How to Clean Millions of Stale Records Safely in Laravel

Pruning Stale Eloquent Models 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 deleting and deleted), and allows custom cleanup logic before each model is deleted.
  • MassPrunable: Executes a direct, high-performance database-level DELETE query 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\Prunable trait on the model.
  • We define the prunable() method, which returns an Eloquent Builder instance 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 as created_at or status) 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 SoftDeletes trait, Prunable will perform soft deletion by default. If you want to permanently remove soft-deleted records from the database, use whereNotNull('deleted_at') in your prunable() query.
  • Custom Chunk Sizes: By default, Laravel processes prunable records in chunks of 1,000. You can customize this by setting the --chunk option 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 💬



Previous Post Next Post

Contact Form