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.
Real Case: 30 Days After expires_at
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
php artisan app:users:cleanup-email-change-requestsWithout 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.
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.
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
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.
php artisan schedule:list* * * * * /usr/bin/php /var/www/icanup/artisan schedule:run >> /var/log/icanup-scheduler.log 2>&1I 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.
$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 |
|---|---|
| Defines when the request stops being valid |
| Defines how long an expired record is retained |
| Selects records that may now be deleted |
| 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.



