0% прочитано

Laravel MassPrunable in Practice: Pruning Old Email Change Requests After 30 Days

While implementing email changes in ICanUp, I needed to remove old UserEmailChangeRequest records, but not immediately when expires_at passed. This article shows why I kept a 30-day retention window, placed the rule in prunable(), and used Laravel model:prune instead of a custom cleanup command.

29 вересня 2026 р. 10 хв читанняLaravel

While implementing email changes in ICanUp, I ran into a simple-looking lifecycle problem: old UserEmailChangeRequest records should not remain in the database forever.

Each request has an expires_at value. After that moment it can no longer confirm an email change. The obvious solution would be to delete the record as soon as it expires.

In the real user flow, however, these became two separate moments: when an action stops being valid and when the stored record is no longer useful.

In my case, expiration does not mean immediate deletion. The request is retained for another 30 days after expires_at and becomes prunable only after that window.

Real Case: 30 Days After expires_at

PHP - UserEmailChangeRequest pruning rule
use Illuminate\Database\Eloquent\Builder;use Illuminate\Database\Eloquent\MassPrunable; final class UserEmailChangeRequest extends Model{    use MassPrunable;     public const RETENTION_DAYS = 30;     public function prunable(): Builder    {        return static::query()            ->where(                'expires_at',                '<=',                now()->subDays(self::RETENTION_DAYS),

The important rule lives entirely in prunable(). I do not select every record where expires_at <= now(), because that would remove requests immediately after expiration.

Instead, the cutoff is shifted back by 30 days: expires_at <= now() - 30 days.

Laravel may therefore delete only records that have already been expired for at least 30 days.

Request State

Action

Reason

Expired 31 days ago

Prune

The 30-day retention window has passed

Expired 1 day ago

Keep

The retention window is still active

Expires in the future

Keep

The request is still valid

Expiration and Retention Are Not the Same Thing

expires_at answers one question: until when may this request be used to change the email address?

The retention period answers another: when is this stored record no longer useful and safe to delete permanently?

Using one timestamp for both decisions would make technical cleanup control the user flow. I wanted those concerns separated, so the extra 30 days live explicitly in RETENTION_DAYS.

Expiration defines when an action stops being valid. Retention defines when the stored data stops being useful. Those do not have to be the same moment.

Why I Did Not Write a Custom Cleanup Command

BASH - A command I could have created separately
php artisan app:users:cleanup-email-change-requests

Without Laravel pruning I could have created a custom Artisan command, a separate service, a deletion query, a scheduler entry, and tests for that additional layer.

But this case does not contain a separate cleanup workflow. It contains one lifecycle rule owned by a model: which UserEmailChangeRequest records may now be deleted?

prunable() answers that question, while Laravel performs the actual cleanup through the standard model:prune command.

BASH - Prune only UserEmailChangeRequest
php artisan model:prune \  --model='App\Modules\User\Domain\Entity\UserEmailChangeRequest'

Why I Use MassPrunable Here

This cleanup does not need to load every database row as an individual model. There are also no files, external resources, or per-record side effects that must run before a UserEmailChangeRequest is removed.

That makes MassPrunable a good fit. Laravel can delete matching rows in bulk without instantiating each model.

For periodic cleanup of simple historical records, that is exactly the behavior I need.

MassPrunable does not invoke pruning(), deleting, or deleted for each record. If cleanup requires those per-model actions, this approach is no longer an automatic fit.

This detail matters more to me than the trait itself. I use MassPrunable not because it is shorter, but because deleting these rows has no per-model side effects.

If deletion needed to remove a file, call an external service, or execute logic for every model instance, I would revisit this choice.

Scheduler: Run Only This Model

PHP - Daily pruning of old requests
use App\Modules\User\Domain\Entity\UserEmailChangeRequest;use Illuminate\Support\Facades\Schedule; Schedule::command(    'model:prune',    ['--model' => UserEmailChangeRequest::class],)    ->name('users:prune-email-change-requests')    ->dailyAt('03:40')    ->timezone((string) config('app.timezone'))    ->withoutOverlapping();

The schedule explicitly passes --model, so this task does not prune every model in the application that may support pruning.

The command has its own name, runs daily at 03:40 in the application timezone, and uses withoutOverlapping().

The 03:40 time is not part of the retention rule. It is only the daily execution window. The decision about what may be deleted still belongs to prunable().

A routes/console.php Entry Does Not Run by Itself

There is an easy-to-miss production detail here. Adding Schedule::command(...) is not enough. The server must actually run the global Laravel Scheduler.

In ICanUp, I verify this separately after schedule changes. First I check that Laravel sees the task through schedule:list, then I verify the system cron that invokes schedule:run every minute.

I do not need a separate cron entry specifically for model:prune.

BASH - Verify the Laravel Scheduler
php artisan schedule:list
BASH - Global Laravel Scheduler cron on the server
* * * * * /usr/bin/php /var/www/icanup/artisan schedule:run >> /var/log/icanup-scheduler.log 2>&1
If schedule:run is not running on the server, a correct prunable() query and a correct schedule entry are not enough: model:prune will never be invoked at the expected time.

I Test the 30-Day Boundary, Not the Trait

A weak test here would only verify that the model uses MassPrunable. That tells me nothing about our actual rule.

The regression I care about is practical: accidentally changing the query so that every expired request is deleted immediately, or so that old records are never removed.

So the test covers three points around the boundary.

  • A request that expired 31 days ago must be returned by prunable().
  • A request that expired 1 day ago must remain.
  • A request with a future expiry must remain as well.
PHP - Verify the retention rule
$prunableIds = (new UserEmailChangeRequest())    ->prunable()    ->pluck('id')    ->all(); expect($prunableIds)    ->toContain($oldExpired->id)    ->not->toContain($recentExpired->id)    ->not->toContain($active->id);

This test is not coupled to Laravel internals. It protects our data lifecycle rule. I can change how cleanup is triggered later while the 30 days after expires_at requirement remains covered.

That gives me more value than asserting that a particular trait exists on the class.

Each Part Ends Up With One Responsibility

Part

Responsibility

expires_at

Defines when the request stops being valid

RETENTION_DAYS

Defines how long an expired record is retained

prunable()

Selects records that may now be deleted

model:prune

Performs Laravel standard pruning

Laravel Scheduler

Runs pruning at the configured time

System cron

Invokes the Laravel Scheduler on the server

I like this split because cleanup did not introduce another service such as CleanupExpiredEmailChangeRequestsService that would mostly duplicate framework behavior.

The model owns its lifecycle rule. Laravel knows how to prune. The schedule knows when to invoke it. The server knows how to keep the scheduler running.

When Standard Pruning Is No Longer Enough

I do not treat MassPrunable as a universal replacement for every cleanup workflow.

A separate process is justified when deletion requires a domain action, an external API call, related file cleanup, dedicated auditing, or individual processing for each model.

But when the requirement is simply "this row may now be deleted", I first check whether Laravel standard pruning is enough.

Conclusion

For UserEmailChangeRequest, I did not need a dedicated cleanup command. I kept the rule where it makes the most sense: on the model itself.

prunable() defines the boundary clearly: a record may be removed only after another 30 days have passed since its expires_at. MassPrunable lets Laravel remove those rows efficiently, while the Scheduler runs the process regularly.

The most important part for me was not the trait itself, but separating two concepts.

Cleanup is not always about deleting expired data as soon as possible. Sometimes expiration ends an action, while retention keeps the surrounding user flow useful.