Enforce Per-Action Waiting Periods in Laravel with the Cooldown Package
Laravel Composer Pacakge #Laravel #Packages #Rate Limiting #Cooldown #PHP

Enforce Per-Action Waiting Periods in Laravel with the Cooldown Package

4 min read Mohamed Said Mohamed Said

What Is Laravel Cooldown?

Laravel Cooldown is a package by Mahedi Zaman Zaber that enforces per-action waiting periods in your Laravel application. While Laravel's built-in RateLimiter counts how many requests hit an endpoint within a window, Cooldown answers a different question: is this specific action still within its waiting period for this specific owner? That distinction matters for flows like OTP resends, password reset requests, or payment retries, where you care about the gap between attempts rather than a raw request count.

Key Features at a Glance

  • Owner-scoped actions — scope a cooldown to a model, an IP string, or nothing for a global lock
  • HasCooldowns trait — attach $user->cooldown('action') directly to any Eloquent model
  • Route middlewarecooldown:action,duration with an optional driver argument
  • Atomic block() — acquires a lock, runs the callback, and sets the cooldown only on success
  • enforce() — throws CooldownActiveException, rendered as HTTP 429
  • CooldownInfo object — exposes remainingSeconds() and remainingForHumans()
  • Cache and database backends — switchable per call; database backend survives cache flushes
  • Pruning — expired database rows cleared via Laravel's model:prune command

The Fluent API

A cooldown is a named action, an optional owner, and a duration:

use ZaberDev\Cooldown\Facades\Cooldown;

// Global lock for 10 minutes
Cooldown::for('rebuild_search_index')->for(600);

// User-scoped lock for 15 minutes
Cooldown::for('resend_verification', $user)->for(900);

// Lock until end of day
Cooldown::for('daily_checkin', $user)->until(now()->endOfDay());

When a cooldown is active, info() returns a CooldownInfo object:

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

To stop a request outright instead of branching, call enforce():

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

Atomic Blocking

Race conditions are the classic problem with cooldowns: two concurrent requests can both pass the active check before either one sets the lock. block() solves this by acquiring an atomic lock, running the callback, and only applying the cooldown if the work succeeds:

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

Cooldowns on Eloquent Models

Add the HasCooldowns trait to any model and the same fluent builder becomes available directly on the instance:

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, records are polymorphic, so you can query a model's cooldowns like any relation:

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

Route Middleware

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 (Redis or Memcached). Switch to database when the lock must survive a cache flush—billing flows being the obvious example:

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

Custom drivers can be registered via Cooldown::extend() in a service provider.

Installation

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

Configuration lives in config/cooldown.php and covers the default driver, cache store, key prefix, table name, and event dispatching.

Real Takeaways

  • Cooldown complements RateLimiter—use it when the gap between attempts matters more than a request count.
  • block() is the right tool for any action where a race condition could fire duplicate side effects.
  • The database backend is worth the extra write cost for anything tied to billing or compliance.
  • The HasCooldowns trait keeps model-scoped logic clean and avoids scattering Cooldown::for($user) calls everywhere.
  • Pruning expired rows with model:prune keeps the cooldowns table lean without custom maintenance scripts.

Source: Enforce Per-Action Waiting Periods in Laravel with Cooldown — Laravel News

Found this useful?

Frequently Asked Questions

3 questions
Q01 How is Laravel Cooldown different from Laravel's built-in RateLimiter?
RateLimiter counts how many requests hit an endpoint within a time window. Cooldown tracks whether a specific named action for a specific owner is still within its waiting period—useful when you care about the gap between attempts rather than a raw request count.
Q02 How does the block() method prevent race conditions?
block() acquires an atomic lock before running the callback and only sets the cooldown if the callback succeeds. This prevents two concurrent requests from both passing the active check before either one sets the lock.
Q03 When should I use the database backend instead of the cache backend?
Use the database backend when the cooldown lock must survive a cache flush—for example, billing flows or subscription renewals where losing the lock state could allow duplicate charges.

Continue reading

More Articles

View all