Skip to main content
Laravel, shipping fast.
Chapter 17 · Data Management

Deletion Requests and What They Touch

Julian Beaujardin

“Delete our account” sounds like one DELETE statement. It never is.

When a partner leaves the License API, removing its consumer touches five things, and each has a different right answer:

consumer leaves
  tokens          delete now: they are credentials
  secrets         delete now: provider token, webhook secret
  request log     keep until retention removes it
  the row         soft-delete now, prune after 30 days
  licenses        not ours: they live at the provider

Chapter 5’s destroy method does the first, second, and fourth of those in one transaction. If removing a consumer also has to start work elsewhere, dispatch it from inside that transaction and hold it until the commit:

RevokeProviderAccess::dispatch($consumer->id)
    ->afterCommit();

Credentials and secrets first. Nothing else matters if the tokens still work, or if a partner’s provider token outlives the partnership in your database.

One transaction, and work that leaves it only after the commit. afterCommit() holds the job until the transaction has really committed. Without it, a worker can pick the job up before the rows it depends on exist, or after a rollback has undone them. (Laravel documentation: Queues › Jobs & Database Transactions.)

Not everything is yours to delete. The licenses belong to the partner’s own provider account. Knowing where your responsibility ends is part of the answer.

“Delete” doesn’t mean the same operation on every table. It means permanently gone for what existed only because of this identity, and it can mean kept, without the identity, for records you have a stated reason to keep: an invoice the law requires, a count that feeds a report. The test is whether you can say the reason out loud to the person asking. “We kept it because it might be useful” isn’t a reason. If personal data is involved, the retention rules of the jurisdictions you operate in decide this, not your schema.

Archiving Cold Data

Deleting a record and losing the ability to answer “how much did this account use us before it left?” are two different outcomes. Archiving keeps the second from disappearing with the first.

A sister service that collects analytics does this when a site is deleted. It doesn’t keep the raw rows. It keeps a summary:

// app/Listeners/ArchiveSiteAnalytics.php
public function handle(SiteDeleted $event): void
{
    Archive::create([
        'site_id' => $event->siteId,
        'summary' => $event->totals,
        'archived_at' => now(),
    ]);
}

The archive is a snapshot, not a copy. One row of totals per deleted site, where the live table had millions. That is what keeps the archive cheap forever and stops it becoming a second copy of the growth problem you were solving.

It is a listener, not part of the delete. Anything that fires SiteDeleted gets the same guarantee, without the archiving being repeated at every place a site can be deleted.

Somebody can find it. An archive nobody can look up six months later isn’t an archive. You only delayed the delete.

Proving What You Keep and Why

A retention rule does its job only if someone can point at it and say “here is why we keep this, and here is proof that it runs.” A policy nobody can verify earns no trust, not even from the team that wrote it.

Two things make a policy provable. Thresholds live in config, as retention.api_requests_days did above, and not in a number buried in a method. Change the policy by changing one value. And the evidence can be inspected:

php artisan model:prune --pretend

That prints how many rows each prunable model would delete, without deleting any. Run it before you tighten a retention window, and you know what the change will do.

Chapter 17 Summary

Ownership:

  • Every table and JSON field has an owner and a one-line reason to exist.
  • If nobody knows why a table is kept, treat that as a bug.

Retention:

  • Retention lives on the model, with Prunable or MassPrunable.
  • One scheduled model:prune covers every model that opts in.

Schema changes and backfills:

  • Add, don’t change. Make migrations safe to run twice.
  • Walk live tables with chunkById(), never offset-based chunk().
  • Backfills are commands with a dry run and a skip-if-unchanged check.

Deletion and archiving:

  • Decide, table by table, what a deletion request removes and what it keeps, and be able to say why.
  • Dispatch follow-up work afterCommit().
  • Archive a summary, from a listener, where someone can find it.

The audio could not be loaded. Try again in a moment.