NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

RateLimits::fake() swaps the manager for a recording fake, so your suite can assert on throttling without real sleeps or Redis:

use RoundlyConsulting\HttpClientRateLimits\Facades\RateLimits;

$fake = RateLimits::fake();

Http::rateLimit(1, by: 'acct-1')->get('https://api.example.com/one');
Http::rateLimit(1, by: 'acct-1')->get('https://api.example.com/two');

$fake->assertDeferred('acct-1');   // a request for this key was throttled
$fake->assertAllowed();            // at least one request went through
// $fake->assertNothingDeferred(); // would fail here

RateLimits::perMinute(60)->by('stripe')->reset();
$fake->assertReset('stripe');

fake() returns a RateLimitsFake — a RateLimitManager subtype swapped into the container, so the facade, an injected manager and Http::rateLimit() all get it. The limiter still runs: hits are counted and waits computed, but nothing sleeps and no Redis is touched. Requests are still sent, so pair it with Http::fake().

Every assertion

MethodChecks / returns
assertDeferred(?string $key = null)A request was deferred — for that owner key, when given.
assertNothingDeferred()No request was deferred.
assertAllowed(?string $key = null)A request was allowed — for that owner key, when given.
assertNothingAllowed()No request was allowed.
assertReset(?string $key = null)A limit was reset — for that owner key, when given.
assertNothingReset()No limit was reset.
deferredCount() / allowedCount()How many requests were deferred / allowed.
store()The shared InMemoryStore; read hits with the store key, e.g. hits('acct-1:minute').
deferrer()The RecordingDeferrer: defers() and deferCount().
// Every assertion returns the fake, so they chain.
$fake->assertDeferred();                // some request was deferred
$fake->assertDeferred('acct-1');        // … for this owner key
$fake->assertNothingDeferred();         // no request was deferred

$fake->assertAllowed();                 // some request was allowed
$fake->assertAllowed('acct-1');         // … for this owner key
$fake->assertNothingAllowed();          // no request went through

$fake->assertReset();                   // some limit was reset
$fake->assertReset('stripe');           // … for this owner key
$fake->assertNothingReset();            // nothing was reset

$fake->deferredCount();                 // int
$fake->allowedCount();                  // int
$fake->store()->hits('acct-1:minute');  // hits recorded for a key + window
$fake->deferrer()->defers();            // every wait in ms, e.g. [60000]
$fake->deferrer()->deferCount();        // int

A complete test

use Illuminate\Support\Facades\Http;
use RoundlyConsulting\HttpClientRateLimits\Facades\RateLimits;

it('throttles the second call for the same account', function () {
    Http::fake(['*' => Http::response('ok')]);

    $fake = RateLimits::fake();

    Http::rateLimit(1, by: 'acct-1')->get('https://api.example.com/one');
    Http::rateLimit(1, by: 'acct-1')->get('https://api.example.com/two');

    $fake->assertAllowed('acct-1')->assertDeferred('acct-1');

    expect($fake->deferredCount())->toBe(1)
        ->and($fake->allowedCount())->toBe(2)
        ->and($fake->deferrer()->defers())->toBe([60_000]); // one full minute
});
  • Every limit built while the fake is active shares one InMemoryStore and one RecordingDeferrer — so separate Http::rateLimit() calls with the same key and window share a budget.
  • RateLimits::usingStore(), usingDeferrer() and releasingJob() still take effect under the fake: they return a manager on your override that keeps the fake’s recording store or deferrer for the other half — so a released job really is released, and its events are still recorded by the fake.
  • The RecordingDeferrer never sleeps: it records each wait (defers(), deferCount()) and advances its own clock, so windows still progress deterministically.
  • Events are forced on while faking, regardless of events_enabled — the assertions are built on them.
  • Combine with Http::fake() so no real request leaves the test.

Faking Sleep instead

To keep the real limiter but skip real waiting, fake Laravel’s Sleep — the default SleepDeferrer pauses through it:

use Illuminate\Support\Sleep;
use RoundlyConsulting\HttpClientRateLimits\Facades\RateLimits;

Sleep::fake();

$limit = RateLimits::perSecond(1);

Http::withMiddleware($limit)->get('https://api.example.com/one');
Http::withMiddleware($limit)->get('https://api.example.com/two');

Sleep::assertSleptTimes(1); // the SleepDeferrer paused once, without really sleeping

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.