DEV Community

Cover image for Laravel idempotency works better when TTL follows user intent
Saqueib Ansari
Saqueib Ansari

Posted on • Originally published at qcode.in

Laravel idempotency works better when TTL follows user intent

Most Laravel idempotency layers solve the infrastructure problem and miss the business one.

They stop duplicate HTTP requests. Great. But they often do it with a generic replay window like 10 minutes, 1 hour, or 24 hours because that is what the middleware supports easily. That is where the design quietly goes wrong.

An idempotency key is not just a transport concern. It is a temporary claim about user intent. It says, this request should still be treated as the same action if it appears again within this window. If that window lasts longer than the underlying business intent, your protection layer stops being protective and starts being distortive.

That is the real lesson behind Laravel idempotency TTL design: the replay window should expire when the protected business intent expires, not when the route middleware’s default cache duration ends.

This matters more than teams think. A bad TTL can prevent double charges and still create bad outcomes. It can block a legitimate retry after circumstances changed, freeze a stale response longer than the workflow deserves, or make support teams debug “why is this still considered the same request?” incidents that are technically correct and product-wise wrong.

The common Laravel implementation is fine technically and weak conceptually

The usual setup looks something like this:

  • client sends an Idempotency-Key header
  • server hashes the request payload or route context
  • middleware stores the response in Redis, cache, or database
  • repeated requests with the same key get the same response replayed for some configured TTL

That is a reasonable infrastructure starting point. It handles duplicate submits, mobile retries, proxy weirdness, and impatient double clicks.

The problem is that the TTL is usually defined at the wrong layer.

A route-level default like this is easy to build:

final class IdempotencyMiddleware
{
    public function handle($request, Closure $next)
    {
        $key = $request->header('Idempotency-Key');
        $ttl = now()->addHour();

        // lookup + replay logic
    }
}
Enter fullscreen mode Exit fullscreen mode

But “one hour” is not a business rule. It is a convenience constant.

That distinction matters because the same HTTP pattern can represent very different business actions:

  • create payment
  • resend invitation
  • start free trial
  • create draft quote
  • issue refund
  • send password reset email

All of them might be POST requests. None of them necessarily deserve the same definition of “same action.”

The mistake teams make

Teams often assume the idempotency layer only needs to answer one question:

Is this request a duplicate?

The better question is:

For how long should this request still be considered the same business attempt?

That second question is where the TTL comes from.

TTL should be derived from intent lifetime, not network uncertainty alone

Idempotency exists because systems are uncertain.

The client might not know whether the first request succeeded. The browser may retry. A mobile network may drop after submission. A worker may time out after the side effect already happened.

So yes, part of idempotency is about transport uncertainty.

But the replay window should not be sized only around infrastructure anxiety. It should be sized around how long a human or upstream system could still reasonably mean the same attempt.

That is the key design shift.

Three kinds of intent you should separate

In practice, repeated requests usually fall into one of these buckets:

  1. Retry intent — “I am unsure whether my earlier attempt worked, so I am trying the same thing again.”
  2. Repeat intent — “I now genuinely want to perform the action again.”
  3. Replacement intent — “I want the same goal, but with changed inputs or changed circumstances.”

A good idempotency TTL protects retry intent without suppressing repeat or replacement intent longer than necessary.

If your TTL is too short, you lose duplicate protection.

If your TTL is too long, you turn a past attempt into a policy that outlives the user’s actual meaning.

The replay window is a business statement

A 24-hour TTL on a payment request says:

For the next 24 hours, the system will assume a repeated submission with this key should still be interpreted as the same payment attempt.

That may be correct in a few workflows. It is wildly wrong in others.

This is why generic middleware defaults are so dangerous. They hide a business decision inside infrastructure.

Start by modeling the workflow, not the route

If you want better Laravel idempotency TTL decisions, start from the business workflow that the route participates in.

Ask four questions:

  1. What exact action is being protected?
  2. How long is retry ambiguity realistically present?
  3. When does a repeated request become a legitimate new attempt?
  4. What change in business context should invalidate sameness?

Those questions are much more useful than “what default TTL feels safe?”

Example 1: invoice payment

Suppose a user pays an invoice from a mobile app. The first request may succeed server-side, but the client loses connection before receiving the response.

In that case, protecting retries for a few minutes is sensible. The user may tap again because they do not know whether payment succeeded.

But if your TTL lasts 24 hours, you risk blocking a legitimate second payment attempt after the user:

  • changed payment method
  • retried after bank authentication issues
  • resumed later from a different device

The original duplicate risk was real. The 24-hour sameness assumption was not.

A business-aware design might choose a 5-minute or 10-minute replay window for the initial attempt while relying on deeper domain constraints, like invoice state, to prevent invalid duplicate settlement later.

Example 2: team invitation email

A user clicks “send invite” twice because the button lagged. That is classic duplicate-submit territory.

Here, a 10- or 15-minute TTL may be enough. You want to prevent spammy accidental duplicates, but you do not want the system treating a legitimate resend several hours later as the same event if the original invite expired or the recipient never saw it.

Example 3: quote draft creation

A sales rep generates a draft quote, closes the laptop, and returns later. A generic 1-hour TTL might cause a repeat submit to replay stale draft creation even though the rep now expects a new quote version.

That is a sign the idempotency TTL is protecting the wrong layer of meaning.

In this kind of workflow, the real duplicate protection might need to be far shorter, or the key may need to be tied to a client-side draft session rather than just the route and payload.

Key design and TTL design have to work together

Teams often obsess about TTL and ignore key scope. That is a mistake.

The replay window only makes sense relative to what the key claims is “the same action.”

A broad key plus a long TTL is the easiest way to create product bugs that look like infrastructure success.

Bad key shape

user:42:create-payment
Enter fullscreen mode Exit fullscreen mode

This key says every payment attempt by the same user inside the TTL might be the same action. That is far too broad.

Better key shape

invoice:inv_991:payment_attempt:client_key_abc123
Enter fullscreen mode Exit fullscreen mode

This key says the sameness belongs to a specific invoice payment attempt context. That is much safer.

The rule to remember

  • Key scope defines what counts as the same action.
  • TTL defines how long that sameness remains believable.

If either one is wrong, the idempotency layer can still behave badly.

A practical Laravel pattern

Let the application define a normalized idempotency context instead of letting middleware infer too much from the route.

interface DefinesIdempotencyContext
{
    public function idempotencyKeyScope(): string;

    public function idempotencyTtlSeconds(): int;
}
Enter fullscreen mode Exit fullscreen mode

Then specific requests or actions can implement it:

final class PayInvoiceRequest extends FormRequest implements DefinesIdempotencyContext
{
    public function idempotencyKeyScope(): string
    {
        return 'invoice:' . $this->route('invoice')->id . ':payment';
    }

    public function idempotencyTtlSeconds(): int
    {
        return 600;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the middleware becomes transport plumbing, not the owner of business sameness.

Use the domain layer to decide when sameness should die

One of the best ways to improve TTL design is to stop thinking in terms of static route config and start thinking in terms of domain state transitions.

Because in many real workflows, sameness does not just expire with time. It expires when the business situation changes.

Payment flows are a good example

A payment attempt may stop being “the same attempt” not only after 10 minutes, but also when:

  • the invoice status changes
  • the payment method changes
  • the authentication challenge is restarted
  • the customer explicitly chooses a new funding path

That means time alone is sometimes the wrong control plane.

A hybrid approach works better

Use TTL as the transport-level replay window, but let domain state constrain whether replay is still valid.

For example:

final class PaymentIdempotencyPolicy
{
    public function replayAllowed(Invoice $invoice, array $requestData): bool
    {
        if ($invoice->status === 'paid') {
            return true;
        }

        if ($invoice->payment_method_id !== $requestData['payment_method_id']) {
            return false;
        }

        return true;
    }
}
Enter fullscreen mode Exit fullscreen mode

The point is not that this exact code is complete. The point is that domain state should participate in deciding whether the old attempt still meaningfully matches the new one.

This lets you avoid two bad extremes:

  • TTL so short that retries slip through unprotected
  • TTL so long that changed user intent gets blocked by stale sameness

Laravel middleware should delegate TTL policy, not own it

A lot of idempotency implementations become rigid because middleware owns too much logic.

Middleware is a fine place to:

  • read the key
  • look up stored attempts
  • short-circuit with replayed responses
  • persist successful outcomes

Middleware is a bad place to hardcode workflow semantics.

Better architecture

Let the middleware ask a policy provider for the replay rules.

interface IdempotencyPolicy
{
    public function scope(Request $request): string;

    public function ttlSeconds(Request $request): int;
}
Enter fullscreen mode Exit fullscreen mode

Then bind policies per action or route:

final class SendInviteIdempotencyPolicy implements IdempotencyPolicy
{
    public function scope(Request $request): string
    {
        return 'workspace:' . $request->route('workspace')->id . ':invite';
    }

    public function ttlSeconds(Request $request): int
    {
        return 900;
    }
}
Enter fullscreen mode Exit fullscreen mode

Or, if you prefer keeping business rules closer to application services, let the service expose the TTL:

final class SendWorkspaceInvite
{
    public function idempotencyTtlSeconds(): int
    {
        return 900;
    }
}
Enter fullscreen mode Exit fullscreen mode

The big win is not style. It is that the replay window is now owned by something that understands the workflow.

Don’t let replayed responses hide changed intent

One subtle failure mode is response replay that is technically correct but semantically stale.

For example, the original request returned:

{
  "status": "processing",
  "payment_id": "pay_123"
}
Enter fullscreen mode Exit fullscreen mode

A later retry with the same key gets that same response replayed, even though the invoice has since moved to failed or the payment attempt was abandoned.

From the middleware’s perspective, replay succeeded.

From the product’s perspective, the response may now be misleading.

This is why TTL cannot be lazy

If the replay window is too long, you are not just preventing duplication. You are also extending the life of an old interpretation.

That can confuse clients, background workers, and support staff who assume replay means “still relevant” instead of “previously captured.”

A shorter, workflow-aware TTL reduces that risk. So does returning domain-aware status from the replay layer when appropriate.

A practical TTL selection framework for Laravel teams

If you want something operational, use this framework.

Step 1: Identify the duplicate risk

What harm are you actually preventing?

  • double charge?
  • double email?
  • duplicate draft?
  • repeated side effect on a third-party API?

Higher-risk side effects justify stronger idempotency, but not automatically longer sameness windows.

Step 2: Measure real retry behavior

How long do legitimate retries actually happen after the first attempt?

If 95 percent of user retries happen within 2 minutes, a 1-hour TTL is probably policy sprawl, not protection.

Step 3: Define the boundary where a second attempt becomes legitimate

When should the system stop assuming “same attempt”?

That might be based on:

  • elapsed time
  • payment method change
  • workflow state change
  • explicit user action restart

Step 4: Choose the narrowest key that still matches the protected action

Do not key on user ID if the real sameness belongs to invoice ID, draft ID, invite target, or checkout session.

Step 5: Put TTL selection in application policy, not magic middleware constants

This is the maintainability step. If developers cannot see why a route has its TTL, the design will decay into cargo-cult defaults.

What I would avoid in production

There are a few patterns I would distrust immediately.

“One TTL for all POST routes”

This is easy to implement and almost always conceptually wrong.

“24 hours because payments are scary”

Fear is not a policy. The real question is whether the same payment intent still exists that long later.

“Replay forever until manual cleanup”

That is not idempotency anymore. That is accidental archival behavior.

“TTL chosen by cache convenience”

If the duration exists because it fits a Redis habit or middleware package default, that is a red flag.

The rule that actually holds up

If you want one sharp rule for Laravel idempotency TTL, make it this:

The replay window should last only as long as a repeated submission still honestly represents the same business attempt.

Not longer.

That means idempotency TTL is not just an infrastructure knob. It is part of your workflow design.

In Laravel terms, the transport layer can enforce idempotency, but the application layer should define when sameness expires. That usually means moving TTL decisions out of generic middleware defaults and into request policies, action classes, or domain-aware idempotency rules.

Because duplicate protection is not the real goal. The real goal is to protect business intent without accidentally extending it beyond its life.

When the TTL outlives the intent, the system stops being careful and starts being stubborn. And in production, stubborn infrastructure is just another way to create business bugs more confidently.


Read the full post on QCode: https://qcode.in/laravel-idempotency-should-expire-by-business-intent-not-middleware-defaults/

Top comments (0)