Skip to main content
Laravel, thinking fast.

Chapter 10

Side Effects You Cannot Take Back

Julian Beaujardin

Everything so far has been recoverable.

A bad response is discarded and retried. A truncated reply is reported. A failed turn writes an error into a cache key and the user tries again. Nothing that has gone wrong yet has stayed wrong.

This chapter is about the tool that creates a real thing — a record, a subscription, a provisioned server, a billed resource — where "try again" is not a recovery strategy but a way to do the damage twice.

The Instruction Is Not the Guard

The tool that creates a site carries a clear description. Verification first, one site, do not repeat. The model followed it faithfully.

Then two requests arrived close enough together that "once" was ambiguous, and one person got two sites.

Nothing malfunctioned. The instruction was correct, it was followed each time, and each call was individually reasonable — because once is a statement about state that neither call could observe. Two callers with no shared view of the world cannot enforce uniqueness by both intending to.

An instruction describes intent. A constraint enforces it. If the only thing preventing a duplicate is a sentence in a description, you do not have uniqueness — you have a strong preference.

This is principle four of this book arriving where it costs money: the model is an untrusted caller, and never more so than when it is being agreeable.

Persist First, So the Database Is the Guard

The fix is not a better sentence. It is a unique index, and an ordering that lets the index do its job:

// Persist the provisioning row first: its unique `url` index is the dedup
// guard that rejects a concurrent double-create with a friendly message
// instead of a raw 500 QueryException.
try {
    $provisioning = ProvisioningSite::create([
        'id' => $siteId,
        'lead_id' => $leadId,
        'url' => $fullUrl,
        'email' => $validatedEmail,
        'status' => 'provisioning',
        'metadata' => $validated,
    ]);
} catch (QueryException) {
    $this->restoreVerificationToken($verificationToken, $verificationPayload);

    return Response::json([
        'error' => 'A site is already being created for this address. Choose a
                    different URL, or wait for the current one to finish.',
    ]);
}

Three decisions in that block.

The row is written before the expensive work starts. It is a claim, not a record of success. Whoever writes it first owns the URL; everyone else collides.

The collision is caught and translated. An uncaught QueryException is a 500 that the model reads as an unexplained failure and may well retry. Catching it produces something the model can act on — choose a different URL — which turns a crash into a conversation.

The catch is typed and bodyless. catch (QueryException) without binding a variable, because the exception carries nothing worth showing. The constraint is the message.

Everything else in this chapter is about the two things that must still be true after that catch fires.