stechstudio/backoff
A PHP package for retrying operations with exponential backoff. Wrap any callback in the backoff helper to automatically retry failures with configurable attempts, delays, and jitter, with optional callbacks for logging or handling each attempt.
Install with Composer; the package is framework-agnostic but drops cleanly into Laravel:
composer require stechstudio/backoff
The entry point is STS\Backoff\Backoff. The smallest useful example wraps a flaky call and retries it with a backoff curve:
use STS\Backoff\Backoff;
$result = (new Backoff('exponential'))->run(function () use ($client) {
return $client->get('https://api.example.com/things');
});
run() returns whatever the callback returns and throws the last exception once attempts are exhausted — so for a first integration you only need to wrap the call site and move on.
Where to look first:
src/Backoff.php — the whole public surface (run, setStrategy, setMaxAttempts, setWaitCap, setWaitTime, setJitter, setExceptionHandler, setFallback) and the defaults. Read these defaults before shipping anything to production.src/Strategies/ — one class per curve (Constant, Linear, Exponential, Polynomial, Fibonacci, plus jitter handling). This is the best reference for how the wait time is calculated per attempt.First concrete use case: wrap a single outbound HTTP/SDK call that occasionally returns 5xx or times out, with exponential + a waitCap, and a setExceptionHandler that logs each failure. That gives you the 80% benefit with about ten lines.
Config array instead of constructor string. For anything beyond a toy, construct with an array so the policy is self-documenting:
$backoff = new Backoff([
'strategy' => 'exponential',
'maxAttempts' => 5,
'waitTime' => 100, // base delay, milliseconds
'waitCap' => 10000, // ceiling, milliseconds
]);
Use the fluent setters (setStrategy, setMaxAttempts, setWaitCap) when the policy is decided at runtime.
Pick the strategy to match the failure mode.
constant — fixed delay; good for rate-limit-ish retries where the dependency recovers on a known cadence.linear / polynomial — gentle growth; good for internal services that are usually healthy.exponential — the default choice for external APIs and anything that can amplify load during an outage.fibonacci — exponential-like growth with less aggressive early waits; useful when you want quick first retries but a slow ceiling.Custom strategy via closure. If none of the built-ins fit, pass a callable that receives the attempt number and returns milliseconds:
$backoff->setStrategy(fn (int $attempt) => min(100 * 2 ** $attempt, 5000));
This is the cleanest extension point and avoids writing a custom strategy class.
Log per-attempt failures with setExceptionHandler. It fires on each caught exception before the next wait, which is where you want structured logging or metrics — the final exception thrown by run() is the only one you'd otherwise see:
$backoff->setExceptionHandler(function (\Throwable $e, int $attempt) use ($logger) {
$logger->warning('retrying', ['attempt' => $attempt, 'error' => $e->getMessage()]);
});
Graceful degradation with setFallback. When every attempt fails, the fallback is invoked instead of rethrowing, and its return value becomes run()'s return value. Use it for cache warming, default responses, or queueing the work for later:
$backoff->setFallback(fn (\Throwable $e) => Cache::get('last_known_good'));
Laravel integration. Bind a factory in a service provider so call sites don't re-specify policy:
$this->app->bind('backoff.external', fn () => new Backoff([
'strategy' => 'exponential', 'maxAttempts' => 5, 'waitCap' => 10000,
]));
Then wrap Http:: calls or Guzzle handlers. For queued work, prefer the job's own retry semantics ($tries, backoff()) over in-process sleeping; use this package for the outbound dependency inside the job, with a small waitCap.
Keep the closure narrow. The retried unit should be one HTTP call or one SDK call, not a multi-step transaction — otherwise you risk partially-applied side effects on retry.
usleep-style). A waitCap of 30s across five attempts can park a PHP-FPM worker for over a minute. In web requests keep caps small; in long jobs, understand you're consuming a worker.maxAttempts includes the first try. Setting it to 1 disables retrying and is the easiest way to make tests fast and deterministic.waitTime and waitCap are ms, not seconds — a waitCap of 30 is 30ms, not 30s.\Exception, not \Throwable). A TypeError or other \Error will bypass retry entirely; if your wrapped code can throw those, catch and rethrow as Exception inside the closure or add your own guard.return false to signal failure unless you've verified it in the version you pinned.maxAttempts(1); otherwise your test suite inherits real sleeps. Assert on the exception handler and fallback rather than on wall-clock timing.Strategies/ (mirror an existing one), and the exceptionHandler/fallback hooks for cross-cutting behavior like metrics and degradation. There's no built-in circuit breaker — if you need "stop trying entirely for N minutes," layer that on top yourself.How can I help you explore Laravel packages today?