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.