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
VACUUMfrom 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();