Laravel Packages

Enforce Per-Action Waiting Periods in Laravel with Cooldown

Published
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.

Sponsored

acquaintsoft logo
Acquaint Softtech

Hire Laravel developers with AI expertise at $20/hr. Get started in 48 hours.

Visit Acquaint Softtech

The latest

View all →
Image Dominant Color and HEIC Support in Laravel 13.24 image

Image Dominant Color and HEIC Support in Laravel 13.24

Read article
Official Laravel Zed Extension: LSP for PHP & Blade image

Official Laravel Zed Extension: LSP for PHP & Blade

Read article
Laravel Head: Manage Meta Tags, Open Graph, and JSON-LD image

Laravel Head: Manage Meta Tags, Open Graph, and JSON-LD

Read article
PhpStorm 2026.2 Released image

PhpStorm 2026.2 Released

Read article
Laravel Doctor: Diagnose Your App With One Artisan Command image

Laravel Doctor: Diagnose Your App With One Artisan Command

Read article
CPX: The Composer Package Executor for PHP image

CPX: The Composer Package Executor for PHP

Read article