DEV Community

Cover image for Laravel cooldowns work better when each action has its own rules
Saqueib Ansari
Saqueib Ansari

Posted on • Originally published at qcode.in

Laravel cooldowns work better when each action has its own rules

Rate limiting is a decent default, but it is a bad product policy for many real workflows. A user refreshing a dashboard, generating an AI summary, exporting a CSV, inviting teammates, and resending a notification are not equivalent actions. They have different cost profiles, different abuse patterns, and different UX expectations. Treating them all as "N requests per minute" is how you end up protecting the wrong thing while annoying the right users.

In Laravel apps, per-action cooldowns are often a better fit than one-size-fits-all rate limits. The model is simple: after a specific actor performs a specific expensive action, you block or delay just that action for a short period. The rest of the product keeps working. That gives you tighter abuse control without turning the entire app into a traffic cop.

Where Rate Limits Get Too Blunt

Laravel's built-in rate limiting tools are solid. If you need IP-based throttling, login protection, or API quotas, use them. The problem starts when you apply the same mechanism to workflows that are not really about request volume.

An AI generation endpoint is not expensive because it gets called often. It is expensive because each call burns tokens, queue time, and maybe third-party API budget. An export button is not risky because it sees high RPS. It is risky because a single user can generate ten multi-megabyte exports back to back and bury your workers.

That is why cooldowns map better to intent than throughput. You are not saying, "this user may only make 20 requests per minute." You are saying, "this user may trigger this costly action once every 30 seconds." That is a very different policy, and usually the more correct one.

A few common cases where cooldowns beat generic throttles:

  • AI content generation per workspace owner or user
  • CSV, PDF, or ZIP exports
  • Invite sending and reminder emails
  • OTP resend or magic-link resend
  • Notification blasts to teams or customers
  • Billing-related recalculation jobs

The key idea is narrow scope. Instead of globally slowing a user down, you cool down the exact thing that is expensive, spammy, or operationally risky.

Model The Cooldown Around Ownership and Action

The most useful cooldown key is usually owner + action, not IP + route.

Why owner? Because many expensive actions are tied to an account, team, tenant, or workspace budget. If one workspace generates ten AI reports in a minute, that cost lands on the workspace whether the requests came from one user, three users, or a job retried twice.

Why action? Because not all expensive operations deserve the same waiting window. A resend-email button may need 60 seconds. AI generation may need 15 seconds. Export creation may need 2 minutes. A single global throttle cannot express that cleanly.

A practical key format looks like this:

$cooldownKey = sprintf('cooldown:%s:%s', $ownerId, $action);
Enter fullscreen mode Exit fullscreen mode

You then store the next allowed timestamp or use a cache lock/TTL to represent the wait window.

A Minimal Laravel Service

A small dedicated service keeps this logic out of controllers and makes the policy reusable.

<?php

namespace App\Support;

use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Support\Carbon;

class ActionCooldowns
{
    public function __construct(private Cache $cache) {}

    public function activeFor(string|int $ownerId, string $action): ?Carbon
    {
        $expiresAt = $this->cache->get($this->key($ownerId, $action));

        if (! $expiresAt) {
            return null;
        }

        $time = Carbon::createFromTimestamp($expiresAt);

        return $time->isFuture() ? $time : null;
    }

    public function start(string|int $ownerId, string $action, int $seconds): Carbon
    {
        $expiresAt = now()->addSeconds($seconds);

        $this->cache->put(
            $this->key($ownerId, $action),
            $expiresAt->timestamp,
            $expiresAt
        );

        return $expiresAt;
    }

    public function enforce(string|int $ownerId, string $action): void
    {
        if ($until = $this->activeFor($ownerId, $action)) {
            abort(response()->json([
                'message' => 'Action temporarily unavailable.',
                'retry_after_seconds' => now()->diffInSeconds($until),
                'retry_at' => $until->toIso8601String(),
            ], 429));
        }
    }

    private function key(string|int $ownerId, string $action): string
    {
        return "cooldown:{$ownerId}:{$action}";
    }
}
Enter fullscreen mode Exit fullscreen mode

This is intentionally boring. Boring is good here. You want a single place where cooldown policy is obvious, testable, and easy to extend.

Why Cache TTL Is Usually Enough

For cooldowns, Redis or your cache store is usually the right layer. You do not need a database table unless you need analytics, audit history, or operator visibility.

A cache-backed cooldown has the right properties:

  • cheap reads and writes
  • natural expiry
  • shared across app servers
  • good enough precision for product enforcement

If you are running multiple web nodes or queue workers, do not keep cooldown state in memory. Use a shared backend such as Redis. Otherwise your policy becomes node-dependent, which is another way of saying broken.

Add Cooldowns At The Workflow Boundary

The right place to enforce a cooldown is usually the boundary where cost or side effects begin. Not every controller deserves this. The actions that do are the ones that kick off work, send something external, or consume a priced dependency.

Example: AI Generation

This is the classic case. If a workspace can trigger a generation every few seconds, users can accidentally hammer your token budget just by clicking twice.

public function store(GenerateSummaryRequest $request, ActionCooldowns $cooldowns)
{
    $workspace = $request->user()->currentWorkspace;

    $cooldowns->enforce($workspace->id, 'ai-summary-generate');

    dispatch(new GenerateSummaryJob(
        workspaceId: $workspace->id,
        prompt: $request->string('prompt')->toString(),
    ));

    $cooldowns->start($workspace->id, 'ai-summary-generate', 20);

    return response()->json([
        'message' => 'Generation queued.',
    ], 202);
}
Enter fullscreen mode Exit fullscreen mode

That policy is much sharper than throttle:10,1 on the route. It protects the exact action that costs money while allowing the user to keep navigating, editing, or fetching other data.

A useful refinement is dynamic cooldowns. If the model is expensive or the prompt size is large, increase the cooldown. If the workspace is on a higher plan, shorten it. Cooldowns are product policy, so they should reflect product reality.

Example: Export Generation

Exports tend to create operational spikes, especially when users click again because the UI did not make progress obvious.

public function export(Request $request, ActionCooldowns $cooldowns)
{
    $account = $request->user()->account;

    $cooldowns->enforce($account->id, 'orders-export');

    ExportOrders::dispatch($account->id, $request->all());

    $cooldowns->start($account->id, 'orders-export', 120);

    return response()->json([
        'message' => 'Export started. We will notify you when it is ready.',
    ], 202);
}
Enter fullscreen mode Exit fullscreen mode

This does two things a global rate limit does not.

First, it stops repeated export launches even if the user is otherwise behaving normally. Second, it creates a predictable product contract: one export every two minutes for this account.

That is easier to explain, easier to support, and easier to reason about than a generic route throttle that may or may not trigger depending on unrelated requests.

The UX Matters More Than The Backend Trick

A cooldown without a clear UI is just a bug with a timer. If the user clicks a button and gets a 429 with no context, they will keep clicking, retry from another tab, or assume your app is broken.

You need to surface the policy clearly.

Return Structured Retry Data

Do not just return "Too many requests." Return when the user can try again.

A good response includes:

  • a stable error message
  • retry_after_seconds
  • an absolute retry_at timestamp
  • optionally the action name for the client

That gives the frontend enough data to disable the button, start a countdown, and avoid blind retries.

Show Cooldowns In The Interface

If a user triggers an action with a cooldown, the UI should reflect it immediately. Disable the action, show a countdown, and explain why. For AI generation, say it is preventing duplicate runs. For exports, say one export is already in progress or recently started.

This is not decoration. It reduces duplicate work and support noise.

Pair Cooldowns With Idempotency When Needed

Cooldowns stop repeated triggers across a time window. They do not solve duplicate submissions caused by network retries or double form posts at the same instant. For that, use idempotency keys or job deduplication.

Cooldowns and idempotency solve different failures. If the workflow is expensive enough, you usually want both.

A good decision rule:

  • Use idempotency to prevent accidental duplicates of the same intent.
  • Use cooldowns to limit how often a costly intent may be started.
  • Use rate limits for broad traffic control, abuse protection, and API fairness.

Failure Modes To Avoid

Most bad cooldown implementations are not conceptually wrong. They are just attached to the wrong identity, stored in the wrong place, or tuned with no product thinking.

Cooldown By IP

This is usually the wrong key for authenticated product workflows. Shared office IPs, VPNs, mobile networks, and browser privacy features make IP-based cooldowns noisy and unfair. If the action belongs to an account or workspace, key it that way.

Cooldown That Starts Too Early

If you start the cooldown before basic validation or authorization, you can punish harmless user mistakes. Start it when the system actually accepts the action and begins meaningful work.

Cooldown That Starts Too Late

If you only set the cooldown after a long-running job completes, repeated clicks can queue duplicates before the window exists. For queued workflows, start the cooldown at dispatch time or reserve the action with an atomic lock first.

No Atomic Guard Around First Trigger

If two requests arrive at nearly the same moment, both can pass the "not on cooldown" check before one writes the TTL. In high-contention actions, use Redis-backed atomic primitives such as locks or Cache::add() semantics instead of a naive read-then-write sequence.

A stronger pattern looks like this:

$acquired = Cache::add("cooldown:{$ownerId}:{$action}", now()->addSeconds($seconds)->timestamp, now()->addSeconds($seconds));

if (! $acquired) {
    return response()->json([
        'message' => 'Please wait before retrying this action.',
    ], 429);
}
Enter fullscreen mode Exit fullscreen mode

That closes the race for the first write in a simple way.

Inventing Cooldowns For Everything

This is where teams go wrong after discovering the pattern. Not every action needs a cooldown. If the action is cheap, reversible, and already protected by validation, adding a wait window is just friction.

Use cooldowns for actions that are at least one of these:

  • expensive
  • spam-prone
  • operationally heavy
  • externally visible
  • confusing when triggered multiple times

If an action does not hit one of those, do not force it.

A Better Default For Real Laravel Products

If your app has AI features, exports, invites, notifications, or any workflow with uneven cost, stop reaching for one generic throttle first. Per-action cooldowns give you a more honest policy surface. They let you protect expensive paths tightly while leaving the rest of the product responsive.

Laravel makes this easy because the underlying pieces are already there: cache, Redis, jobs, policies, and clean service abstractions. The trick is not technical difficulty. The trick is choosing the right control for the job.

My recommendation is simple: keep Laravel rate limiting for broad API and auth protection, then add owner-scoped action cooldowns for workflows that cost real money or create real operational drag. When the action is expensive, user-facing, and easy to spam, a cooldown is usually the cleaner answer.

If your current policy reads like "5 requests per minute" for a costly button, you probably have the wrong abstraction. Replace it with a rule the product can actually defend: who can do what, and how often, without harming cost, systems, or UX.


Read the full post on QCode: https://qcode.in/per-action-cooldowns-laravel-rate-limits/

Top comments (0)