Skip to main content
Laravel, shipping fast.
Chapter 12 ยท Data Management

Retention as a policy, not a cron job

Julian Beaujardin

The first mistake teams make with retention is writing a command that deletes old rows and scheduling it. That works, right up until the command is the only place the rule lives, and six months later nobody remembers it exists or what it's protecting.

// Bad: retention logic lives only in a scheduled command,
// invisible to anything that isn't reading routes/console.php
class PruneExpiredTokens extends Command
{
    public function handle(): void
    {
        DB::table('site_tokens')
            ->where('expires_at', '<', now())
            ->delete();
    }
}

Nothing on Token itself says this model has a lifespan. A developer querying site_tokens has no signal that expired rows are supposed to be gone; the rule lives in a command file most people never open.

The fleet's actual pattern puts the rule on the model, using Eloquent's Prunable contract:

// app/Models/Token.php
class Token extends Model
{
    use HasFactory;
    use HasUuids;
    use Prunable;

    protected $connection = 'sites';
    protected $table = 'site_tokens';

    protected function casts(): array
    {
        return [
            'metadata' => 'array',
            'expires_at' => 'datetime',
        ];
    }

    /**
     * @return Builder<Token>
     */
    public function prunable()
    {
        return static::where('expires_at', '<', now());
    }
}
// routes/console.php
Schedule::command('model:prune')->daily();

That's the whole mechanism. model:prune is a built-in Laravel command that finds every Prunable model in the app and calls its prunable() scope. You don't write a command per model, and you don't schedule anything per model either. One scheduled line handles retention for every model that opts in.

  • Prunable: an Eloquent trait that marks a model as having a defined end of life.
  • prunable(): the query scope that says exactly which rows are eligible, right next to the casts and relationships that describe the rest of the model.
  • model:prune: Laravel's built-in sweep, one scheduled entry covering every prunable model in the app.

Why does this matter more than it looks like it should? Because the rule and the model live in the same file. Anyone reading Token sees use Prunable; and knows immediately this table isn't permanent. Retention that lives on the model survives the person who wrote it leaving.

Migrations that run on a live table

A migration that works fine on your local sqlite database can lock a production table for minutes. The difference is row count, and it matters most when you're not just changing a schema, you're rewriting data underneath live traffic. Here's what that looks like when it goes wrong, and the fix the fleet shipped after it happened:

// Bad: DB::table()->chunk() paginates by numeric offset.
// On a UUID-keyed table, concurrent writes shift the offset
// window mid-run and rows silently get skipped.
DB::table('sites')
    ->select('id', 'category', 'metadata')
    ->chunk(500, function ($rows): void {
        foreach ($rows as $row) {
            // ... backfill logic
        }
    });
// Good: cursor() streams every row in a single query, so no
// row can be missed no matter what else is writing to the
// table while this runs. Idempotent: a row already matching
// the target shape is skipped, not re-written.
DB::table('sites')
    ->select('id', 'category', 'metadata')
    ->cursor()
    ->each(function ($row): void {
        $metadata = is_string($row->metadata)
            ? (json_decode($row->metadata, true) ?: [])
            : ((array) ($row->metadata ?? []));

        if (($metadata['category'] ?? null) === $row->category) {
            return;
        }

        $metadata['category'] = $row->category;

        DB::table('sites')
            ->where('id', $row->id)
            ->update(['metadata' => json_encode($metadata, JSON_THROW_ON_ERROR)]);
    });

chunk() pages through a table using LIMIT/OFFSET. Fine for a static table, but on one taking writes, rows shift between pages and offset-based paging silently drops or repeats them. cursor() opens a single streaming query with a server-side cursor, so pagination drift can't happen no matter how many other writes land during the run. Idempotence is the other half of the fix: the update only fires when the JSON is actually out of sync, so running the migration twice, or resuming it after a deploy interrupts it, produces the same end state either way.

Additive schema changes get the same discipline in miniature: a column-adding migration checks Schema::hasColumn() before it adds anything, so the same file can run safely a second time against an environment where a previous, partial run already got that far.

A migration you can run twice without consequence is a migration you can actually trust to run in production. One you can only run once, successfully, on the first try, is a migration you're gambling on.