retry

Re-runs a callback until it succeeds or the attempt budget runs out, sleeping in between.

Signature
retry(int|array<int, int> $times, callable $callback, \Carbon\CarbonInterval|int|\Closure $sleepMilliseconds = 0, ?callable $when = null): mixed

Parameters

ParameterTypeDescription
$$timesint|array<int, int>Total attempts to make, or an array of sleep durations that also sets the attempt count to count($times) + 1.
$$callbackcallableThe operation to run; it receives the 1-based attempt number.
$$sleepMillisecondsoptional\Carbon\CarbonInterval|int|\Closure= 0Milliseconds to sleep between attempts, or a closure called with ($attempt, $exception) returning the duration.
$$whenoptional?callable= nullReceives the thrown exception and returns whether it should be retried; a false result rethrows immediately.

Return value

mixedWhatever the callback returns on its first successful attempt.

Errors

Rethrows the callback's last exception once the attempt budget is exhausted, or immediately when the $when closure returns false.

Changelog

  • PHP 12.x$sleepMilliseconds may now be a Carbon\CarbonInterval as well as an integer or a closure; intervals are converted with totalMicroseconds.

retry() runs the callback and, if it throws, runs it again until the attempt budget is gone. The callback receives the 1-based attempt number. On success its return value is passed straight back; when the budget is exhausted the last exception is rethrown untouched.

Controlling the pause

The third argument is milliseconds. Pass a closure instead and it is called with ($attempt, $exception) so you can compute a backoff. Pass an array as the first argument and it doubles as the schedule: retry([100, 200], $cb) means three attempts in total — the array length plus one — sleeping 100 ms then 200 ms.

What trips people up

$times counts total attempts, not retries: retry(3, ...) calls the callback at most three times, not four. The array form is the exception to that rule, and that inconsistency catches people out.

Sleeping goes through Illuminate\Support\Sleep, so Sleep::fake() makes retry logic testable with no real delay and lets you assert on the durations. Finally, the $when closure decides whether an exception is retryable at all: return false and it is rethrown immediately with the remaining budget untouched.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Re-issue an HTTP call to a flaky third-party API with exponential backoff.
  • Retry a deadlocked database transaction a couple of times before failing the job.
  • Wait for an eventually-consistent object store write to become readable.

Frequently asked questions

Does retry(3) mean three retries or three total attempts?

Three total attempts — the initial call plus two retries. The array form works differently: retry([100, 200], ...) allows count($times) + 1 attempts, so three attempts with two sleeps between them.

How do I add exponential backoff to retry()?

Pass a closure as the third argument; it receives the attempt number and the exception, so fn (int $attempt) => 100 * (2 ** ($attempt - 1)) gives 100ms, 200ms, 400ms. For a fixed schedule the array form retry([100, 200, 400], $callback) is simpler.

How do I stop retry() from sleeping in tests?

Call Illuminate\Support\Sleep::fake() before the code under test. retry() sleeps through the Sleep class, so faking it removes the real delay and lets you assert on the durations with Sleep::assertSleptTimes() or Sleep::assertSlept().

More examples

The four ways to describe a pause

The three-argument forms all keep the attempt count in the first argument. The array form is the odd one out: its length sets the schedule and the attempt count becomes count($array) + 1, because the last attempt has nothing to sleep before.

Example · php
<?php

use Exception;

$call = fn () => $client->get($url);

// Fixed: 5 attempts, 100ms between each.
retry(5, $call, 100);

// Computed: 100ms, 200ms, 300ms, 400ms.
retry(5, $call, fn (int $attempt, Exception $e) => $attempt * 100);

// Explicit schedule: 3 attempts (2 + 1), sleeping 100ms then 200ms.
retry([100, 200], $call);

// A CarbonInterval also works on Laravel 12.
retry(3, $call, \Carbon\CarbonInterval::milliseconds(250));

Retry only transient failures

The $when closure means a 422 validation error from the API is rethrown on the first attempt instead of being hammered four more times, while a dropped connection is retried with a growing backoff. Without it, every exception type consumes the whole budget.

Example · php
<?php

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Throwable;

$data = retry(
    5,
    fn (int $attempt) => Http::timeout(5)->throw()->get('https://api.example.com/orders')->json(),
    fn (int $attempt) => $attempt * 200,
    fn (Throwable $e) => $e instanceof ConnectionException
);

echo count($data), " orders fetched\n";

Testing retry logic without real sleeps

Sleep::fake() records durations instead of blocking, so a test covering a five-attempt backoff finishes instantly. assertSleptTimes(2) confirms the helper paused between the three attempts but not after the successful one — it needs PHPUnit, so this belongs in a test case.

Example · php
<?php

use Illuminate\Support\Sleep;

Sleep::fake();

$calls = 0;

$result = retry(3, function () use (&$calls) {
    $calls++;

    if ($calls < 3) {
        throw new RuntimeException('boom');
    }

    return 'ok';
}, 50);

echo $result, "\n";   // ok
echo $calls, "\n";    // 3

Sleep::assertSleptTimes(2);

Discussion

  • Be the first to comment on this lesson.