Laravel Magazine
Stop Calling sleep() Directly: Laravel's Testable Sleep Facade

Stop Calling sleep() Directly: Laravel's Testable Sleep Facade

Eric Van Johnson ·

If you've ever written a retry loop with backoff in a Laravel job, you've probably called PHP's native sleep() or usleep() somewhere in the middle of it. That works fine in production, but it's miserable in tests — your test suite either actually waits out those seconds, or you refactor your code just to make the delay mockable.

Laravel's Sleep facade (Illuminate\Support\Sleep) solves this directly. It's a drop-in, fluent replacement for sleep()/usleep() that behaves identically in production but can be faked entirely in tests.

Basic Usage

use Illuminate\Support\Sleep;

// Sleep for a whole second
Sleep::for(1)->second();

// Sleep for milliseconds
Sleep::for(500)->milliseconds();

// Sleep until a specific point in time
Sleep::until(now()->addMinutes(5));

// Chain durations together
Sleep::for(1)->second()->and(500)->milliseconds();

All of these are readable in a way usleep(1500000) never was — you don't need to do arithmetic in your head to know how long the pause actually is.

A Real Example: Retrying a Payment Gateway Call

Here's a fairly common shape for a PhpArchitect job that polls a third-party payment gateway until a transaction settles, backing off a little more between each attempt:

<?php

namespace App\Jobs;

use App\Services\PaymentGateway;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Sleep;

class ConfirmPaymentSettlement implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public function __construct(private readonly string $transactionId)
    {
    }

    public function handle(PaymentGateway $gateway): void
    {
        for ($attempt = 1; $attempt <= 5; $attempt++) {
            $status = $gateway->checkStatus($this->transactionId);

            if ($status->isSettled()) {
                return;
            }

            Sleep::for($attempt)->seconds();
        }

        throw new \RuntimeException("Transaction {$this->transactionId} never settled.");
    }
}

Attempt one waits a second, attempt two waits two seconds, and so on — a simple linear backoff. In production that's a sensible way to avoid hammering a third-party API. In a test suite, waiting up to 15 real seconds for this job to run is not sensible at all.

Making It Fast to Test

This is where Sleep::fake() earns its keep. Call it in your test and every Sleep::for()/Sleep::until() call in the code under test is intercepted — no real waiting happens, but Laravel still records every call so you can assert on it.

<?php

namespace Tests\Feature;

use App\Jobs\ConfirmPaymentSettlement;
use App\Services\PaymentGateway;
use Illuminate\Support\Sleep;
use Tests\TestCase;

class ConfirmPaymentSettlementTest extends TestCase
{
    public function test_it_retries_with_increasing_backoff_until_settled(): void
    {
        Sleep::fake();

        $this->mock(PaymentGateway::class, function ($mock) {
            $mock->shouldReceive('checkStatus')
                ->times(3)
                ->andReturn(
                    status(settled: false),
                    status(settled: false),
                    status(settled: true),
                );
        });

        (new ConfirmPaymentSettlement('txn_123'))->handle(app(PaymentGateway::class));

        Sleep::assertSequence([
            Sleep::for(1)->seconds(),
            Sleep::for(2)->seconds(),
        ]);
    }
}

That test runs in milliseconds, not seconds, and it verifies the actual backoff intervals your job used — not just that it "waited some amount of time." You can also assert on individual sleeps or their count:

Sleep::assertSlept(fn ($duration) => $duration->seconds === 1);

Sleep::assertSleptTimes(2);

Sleep::fake() is typically called once per test (or in a shared setUp() if every test in the class needs it), and Laravel automatically resets faked sleep state between tests, so you don't need to worry about assertions leaking across test methods.

The Takeaway

If your codebase has raw sleep() or usleep() calls hiding in retry loops, rate limiters, or polling code, swap them for Sleep::for()/Sleep::until(). It costs nothing at runtime — it's still a real sleep in production — but it turns previously untestable (or painfully slow to test) timing logic into something you can assert on directly, in a test that finishes instantly.

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.