Skip to main content
Laravel, thinking fast.
Chapter 10 · Side Effects You Cannot Take Back

Single-Use Tokens Must Survive a Rejection

Julian Beaujardin

Creation requires proof the email was verified, and that proof is single-use:

$verificationPayload = Cache::pull("verified-email:{$verificationToken}");

if (! is_array($verificationPayload)) {
    return Response::json([
        'error' => 'Invalid or expired verification token. Email must be verified first.',
    ]);
}

Cache::pull reads and deletes atomically — the token cannot be spent twice, even by two simultaneous callers.

Which creates a problem the moment anything downstream fails. The token is gone, the site was not created, and the user is asked to check their email and type a six-digit code again for a failure that was entirely ours.

So a rejected attempt puts it back:

// The single-use token was already pulled above; restore it so a rejected
// duplicate doesn't force the user to re-verify their email.
private function restoreVerificationToken(string $token, array $payload): void
{
    Cache::put("verified-email:{$token}", $payload, now()->addMinutes(30));
}

A single-use credential needs an owner for its whole lifetime. Between pulling it and completing the work, you hold it — and if you do not finish, you give it back. Every failure path in this tool restores it.

Never Hold a Transaction Across a Network Call

// Call the platform OUTSIDE any DB transaction — a slow or hung upstream must
// never hold a database connection/transaction open for its duration.
try {
    $result = app(WebploService::class)->createSite(data: $validated);
} catch (Throwable $e) {
    //
}

The tidy-looking version wraps the whole tool in DB::transaction() so a failure rolls back cleanly. It is a trap.

An AI-driven call can take a minute. A transaction held open for a minute holds a connection, holds locks, and blocks anything touching those rows — and a handful of concurrent creations exhausts the pool. The database stops serving the entire application because one upstream service is slow.

The alternative is to accept a small window of intermediate state and clean it up explicitly. Which requires care about ordering.

Roll Back Before You Report

} catch (Throwable $e) {
    // Roll back and restore the token FIRST so a failure in reporting can
    // never leave the user with a burned token and an orphaned row.
    $provisioning->delete();
    $this->restoreVerificationToken($verificationToken, $verificationPayload);
    report($e);

    return Response::json([
        'error' => 'Site creation is temporarily unavailable. Please try again in a moment.',
    ]);
}

report() looks like the first thing you should do. It is the last.

report() is not free of failure: it can hit a logging service that is down, a queue that is full, a handler that throws. If it runs first and fails, the compensating actions never run — and the user is left with a burned verification token and an orphaned row claiming their URL. They cannot retry, because the row they created is now blocking them.

Order compensating actions before observability. Undo the state, then write about it. The log line is for you; the cleanup is for the user.

Note also that the returned message is generic and encouraging, while the real error goes to report(). The model does not need the exception; it needs to know whether to suggest trying again.