Skip to main content

Keeping the token table small

Every request inserts a row, and consumption only marks it consumed, so the table needs a regular purge. The package ships the command and can register the schedule for you:

// config/email-magic-link.php
'prune' => [
'schedule' => true,
'frequency' => 'daily', // hourly · daily · weekly · monthly
],

It is off by default. A package that deletes rows on a cadence nobody chose is making your decision, and an application that already wires the command itself would end up running it twice. If you would rather keep the schedule in your own file, leave the switch off:

use Illuminate\Support\Facades\Schedule;

Schedule::command('email-magic-link:purge --isolated')->daily();

Either way the command deletes rows that are expired or already consumed and reports how many it removed. It deletes in chunks (prune.chunk, 1000 rows by default), so no single statement holds its row locks for long on a table that grew for months, and it is Isolatable: pass --isolated on your own schedule line and a second copy refuses to start while one runs. The package-registered entry adds withoutOverlapping() and a name, so schedule:list shows it as email-magic-link:purge. See the command reference.

Watching it run

A failed purge surfaces the way any failed scheduled command does: the exception reaches your handler and your error tracker. A success heartbeat is yours to add, and so is onOneServer(): it needs a cache store with atomic locks, which the package cannot assume on your behalf, and it hangs off the same loop. With prune.schedule on, the package owns the schedule entry, so attach the ping through the scheduler's list rather than to a line of your own:

foreach (app(Schedule::class)->events() as $event) {
if (str_contains($event->command ?? '', 'email-magic-link:purge')) {
$event->pingOnSuccess('https://heartbeat.example/purge');
}
}

Or keep the manual Schedule::command(...) line, where thenPing() and friends attach directly.

Under multi-tenancy

The self-registered entry runs email-magic-link:purge as a plain subprocess, on the central connection, with no tenant context: with the tables only on tenant databases it fails every night, and with the tables also on the central one it purges nothing and reports success. Leave prune.schedule off there and run the command through your tenancy runner — see Running under multi-tenancy.

What the purge does to your locks

The purge never waits. It claims each chunk with SELECT ... FOR UPDATE SKIP LOCKED and then deletes the rows it holds, so a row another transaction is currently touching is skipped and collected on the next run rather than queued behind. That is deliberate: a background job is the one caller that can always come back later, and a statement that never waits cannot take part in a deadlock, whatever order anything else acquires its locks in.

This matters most for invitations, because accepting one runs your handler inside the transaction that holds the invitation row. Without the skip, a handler that also writes to the invitations table -- calling revoke() for the same address, say -- could form a cycle with a purge chunk, and the database picks the victim. It might pick the person signing in.

Your server's timeouts are still yours

PostgreSQL and MySQL both ship with no statement timeout, no lock timeout, and no idle transaction timeout. The package cannot set them for you: they are server or role settings, and a package that changed them would be changing the behavior of every other query in your application.

Two consequences worth knowing about, both measured on PostgreSQL 18:

  • An acceptance handler that hangs holds its transaction open, and an open transaction blocks VACUUM from reclaiming dead rows across the whole database, not only on the tables it touched.
  • Nothing bounds how long your handler may run inside that transaction, so nothing bounds how long the invitation row stays locked.

If your handler does real work -- provisioning an account, calling an external service -- set statement_timeout and idle_in_transaction_session_timeout on the role your application connects as, and keep lock_hold_seconds above the slowest issuance you expect.

Why rows are kept until then

Consumption is a single race-free conditional claim: the row has to survive the claim so the update can prove it changed exactly one row. Deleting on consume would also destroy the evidence that a token was already spent, which is what makes a replay fail cleanly rather than look like an unknown token.

Calling it yourself

TokenStore::purge() is public and returns the number of deleted rows, so a custom store or your own scheduled job can drive it directly:

$deleted = app(\EmailMagicLink\Contracts\TokenStore::class)->purge();