Enforce Per-Action Waiting Periods in Laravel with Cooldown

Last updated on by

Enforce Per-Action Waiting Periods in Laravel with Cooldown image

Laravel Cooldown is a package from Mahedi Zaman Zaber for putting a timed lock on a named action: no second OTP for 120 seconds, one password reset request per minute, a payment retry that can't fire again until the window clears. Where Laravel's RateLimiter counts how many requests hit an endpoint within a window, Cooldown tracks whether a specific action for a specific owner is still within its waiting period and can persist that state in the database rather than only in the cache.

Main features

  • Owner-scoped actions through Cooldown::for('resend_verification', $user), where the owner can be any model, an IP string, or nothing for a global lock
  • A HasCooldowns trait that puts $user->cooldown('change_avatar') on any Eloquent model
  • Route middleware (cooldown:action,duration) with an optional driver argument
  • Atomic execution via block(), which takes a lock around the callback and sets the cooldown on success
  • enforce() to throw a CooldownActiveException that renders as HTTP 429
  • An immutable CooldownInfo object with remainingSeconds() and remainingForHumans()
  • Cache or database backends, switchable per call with using()
  • Pruning of expired database rows through Laravel's model:prune command

The Fluent API

A cooldown is a named action, an optional owner, and a duration. Once you name one, you can set it, check it, or clear it:

use ZaberDev\Cooldown\Facades\Cooldown;
 
Cooldown::for('rebuild_search_index')->for(600);
 
Cooldown::for('resend_verification', $user)->for(900);
 
Cooldown::for('daily_checkin', $user)->until(now()->endOfDay());

When a cooldown is active, info() returns an object with the duration details:

if (Cooldown::for('resend_verification', $user)->active()) {
$info = Cooldown::for('resend_verification', $user)->info();
 
echo "Please wait " . $info->remainingForHumans() . " before requesting another email.";
echo "Seconds remaining: " . $info->remainingSeconds();
}

If you'd rather stop the request than branch on it, enforce() throws when the action is still locked:

Cooldown::for('request_password_reset', $user)->enforce();

Atomic Blocking

Cooldowns exist for exactly this situation: sending an OTP, charging a card, where two concurrent requests can both pass the check before either one sets the lock. block() handles this by acquiring a lock, running the callback, and applying the cooldown only if the work succeeds:

Cooldown::for('send_login_code', $user)->block(function () use ($smsClient, $user) {
$smsClient->sendCode($user->phone);
}, duration: 90);

If you need to manage the lock yourself, acquireLock() and releaseLock() are available for that purpose.

Cooldowns on Eloquent Models

Add the HasCooldowns trait and the same builder hangs off the model instance, scoped to that record:

use ZaberDev\Cooldown\HasCooldowns;
 
class User extends Authenticatable
{
use HasCooldowns;
}
$user->cooldown('change_avatar')->for(300);
 
if ($user->cooldown('change_avatar')->active()) {
return response()->json(['message' => 'You can change your avatar again shortly.'], 429);
}
 
$user->cooldown('change_avatar')->reset();

On the database backend, the records are polymorphic, so a model's cooldowns are queryable like any other relation:

$activeCooldowns = $user->cooldowns()->where('expires_at', '>', now())->get();
 
$user->cooldowns()->delete();

Route Middleware

The cooldown middleware takes an action name, a duration in seconds, and an optional driver:

Route::post('/feedback', [FeedbackController::class, 'store'])
->middleware('cooldown:submit_feedback,120');
 
Route::post('/invoices/export', [InvoiceController::class, 'export'])
->middleware('cooldown:invoice_export,600,database');

Storage Backends

The default driver is cache, backed by the store you've configured, so both Redis and Memcached work. The database driver writes to a cooldowns table instead, which is what you want when the lock is tied to something like billing and can't be removed during a cache flush. You can choose per call:

Cooldown::for('poll_job_status', $ip)->using('cache')->for(20);
 
Cooldown::for('renew_subscription', $user)->using('database')->for(86400);

If you need somewhere else to keep the state, Cooldown::extend() registers a custom driver from a service provider, and expired database rows can be cleared on a schedule with Laravel's model:prune command.

Installation

The package requires PHP 8.2 and supports Laravel 11, 12, and 13:

composer require zaber-dev/laravel-cooldown
 
php artisan vendor:publish --provider="ZaberDev\Cooldown\CooldownServiceProvider"
php artisan migrate

config/cooldown.php sets the default driver (COOLDOWN_DRIVER), the cache store and key prefix, the database table name, and whether events are dispatched.

You can find installation instructions and full documentation on GitHub.

Yannick Lyn Fatt photo

Staff Writer at Laravel News and Full stack web developer.

Cube

Laravel Newsletter

Join 40k+ other developers and never miss out on new tips, tutorials, and more.

image
Laravel Code Review

Get expert guidance in a few days with a Laravel code review

Visit Laravel Code Review
Laravel Cloud logo

Laravel Cloud

Easily create and manage your servers and deploy your Laravel applications in seconds.

Laravel Cloud
No Compromises logo

No Compromises

Joel and Aaron, the two seasoned devs from the No Compromises podcast, are now available to hire for your Laravel project. ⬧ Flat rate of $9500/mo. ⬧ No lengthy sales process. ⬧ No contracts. ⬧ 100% money back guarantee.

No Compromises
Kirschbaum logo

Kirschbaum

Providing innovation and stability to ensure your web application succeeds.

Kirschbaum
Lucky Media logo

Lucky Media

Get Lucky Now - the ideal choice for Laravel Development, with over a decade of experience!

Lucky Media
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS Starter Kit

SaaSykit is a Multi-tenant Laravel SaaS Starter Kit that comes with all features required to run a modern SaaS. Payments, Beautiful Checkout, Admin Panel, User dashboard, Auth, Ready Components, Stats, Blog, Docs and more.

SaaSykit: Laravel SaaS Starter Kit
Shift logo

Shift

Running an old Laravel version? Instant, automated Laravel upgrades and code modernization to keep your applications fresh.

Shift
Harpoon: Next generation time tracking and invoicing logo

Harpoon: Next generation time tracking and invoicing

The next generation time-tracking and billing software that helps your agency plan and forecast a profitable future.

Harpoon: Next generation time tracking and invoicing
Acquaint Softtech logo

Acquaint Softtech

Acquaint Softtech offers AI-ready Laravel developers who onboard in 48 hours at $3000/Month with no lengthy sales process and a 100 percent money-back guarantee.

Acquaint Softtech
PhpStorm logo

PhpStorm

The go-to PHP IDE with extensive out-of-the-box support for Laravel and its ecosystem.

PhpStorm
Tinkerwell logo

Tinkerwell

The must-have code runner for Laravel developers. Tinker with AI, autocompletion and instant feedback on local and production environments.

Tinkerwell

The latest

View all →
Build a Laravel Scout Search Endpoint With the HTTP QUERY Method image

Build a Laravel Scout Search Endpoint With the HTTP QUERY Method

Read article
First-Party Image Processing in Laravel 13.20 image

First-Party Image Processing in Laravel 13.20

Read article
Laravel Legacy Bridge: Carry Authenticated Sessions from a Legacy App into Laravel image

Laravel Legacy Bridge: Carry Authenticated Sessions from a Legacy App into Laravel

Read article
Laravel Quota: Usage Budgets for Calendar Periods image

Laravel Quota: Usage Budgets for Calendar Periods

Read article
Find the Security Vulnerabilities in Your Laravel App with Sensagraph image

Find the Security Vulnerabilities in Your Laravel App with Sensagraph

Read article
Uvora: A macOS Menu Bar App for Finding Laravel Projects image

Uvora: A macOS Menu Bar App for Finding Laravel Projects

Read article