vkartaviy/retry
A Laravel retry helper that re-runs failed operations with configurable attempts, delays, and backoff. Wrap closures or jobs to handle transient errors gracefully, with support for conditional retries and custom exception handling.
Install and verify first. The package is zero-dependency but last released in 2015, so the very first step is a compatibility check before you build anything on it:
composer require vkartaviy/retry
php -r "require 'vendor/autoload.php'; var_dump(class_exists('Vkartaviy\\Retry\\Retry'));"
Then open vendor/vkartaviy/retry/src/ and read the main class plus its tests — at 29 stars and a decade old, the source is the only reliable documentation. Confirm three things before writing production code: the exact builder method names, the delay unit (milliseconds vs. microseconds passed to usleep), and whether failures are typed against Exception or Throwable.
Where to look first. The tests directory is effectively the usage manual — copy a working chain from there rather than from the README, which may describe an API that drifted.
First use case. Wrap a flaky third-party HTTP call:
use Vkartaviy\Retry\Retry;
$response = Retry::make()
->attempts(3)
->delay(100) // verify: ms or µs
->multiplier(2) // 100, 200, 400
->jitter(true)
->retryIf(fn ($e) => $e instanceof \GuzzleHttp\Exception\ConnectException)
->onAttempt(fn ($attempt, $e) => Log::warning('retry', ['attempt' => $attempt]))
->run(fn () => $client->get('https://api.example.com/prices'));
If no global retry() helper ships with the package, wrap the builder in your own small helper class — do not rely on the function existing.
Centralise the policy, not the call site. Inline ->attempts(5) scattered across a codebase is unmaintainable. Put policies in config/retries.php keyed by downstream service, and have a thin RetryFactory (container-bound) build a configured instance:
// config/retries.php
'payments' => ['attempts' => 4, 'delay' => 200, 'multiplier' => 2, 'jitter' => true],
This also gives you a single place to zero out delays in tests.
Only retry idempotent work. GET/PUT/DELETE and read queries are safe; a POST that charges a card is not. If you must retry a write, add an idempotency key at the HTTP layer or a dedupe guard at the DB layer — the package has no concept of idempotency.
Database deadlocks: open the transaction inside the closure. This is the single most common mistake:
Retry::make()->attempts(3)->retryIf(fn ($e) => str_contains($e->getMessage(), 'Deadlock'))
->run(function () {
DB::transaction(function () { /* the actual work */ }); // fresh txn per attempt
});
Wrapping the retry outside an already-open DB::transaction() retries inside a poisoned transaction and fails identically every time.
Queue jobs: retry the dependency, not the job. Use the package inside handle() around the external call, and let Laravel's $tries/$backoff handle job-level retries. Stacking 4 attempts × 3 queue tries × Guzzle's retry middleware gives 36 requests to a service that is already down — retry amplification is how you turn an outage into an incident.
Exception filtering is the real feature. Retry ConnectException, HTTP 429/5xx, deadlocks, and lock timeouts. Never retry 400/401/422, ModelNotFoundException, or unique-constraint violations — they are deterministic and retrying just burns your latency budget.
Callbacks for observability. Use onAttempt to log attempt number and elapsed time (metrics on retry rate is how you discover a degrading dependency before it pages you), and the final-failure callback to report to Sentry. Confirm the last exception is still rethrown after the callback runs — swallowing it silently is the worst possible failure mode.
Testing. Set delay/attempts from config so the test environment uses attempts = 1 or zero delay; otherwise your suite really sleeps and CI crawls. Assert on a counter closure incrementing per invocation to prove the retry actually happened.
retry(). Laravel has shipped retry($times, $callback, $sleep, $when) for years. If the package defines a global helper, you get a fatal redeclare or a confusing shadow. Prefer the namespaced class, or alias explicitly. For simple fixed-delay cases, Laravel's native helper is usually the better answer; reach for this package only when you need jitter and per-attempt callbacks.Throwable support (2015 codebase). PHP 7+ TypeError, Error, and most \RuntimeException subclasses typed as Throwable may fall straight through. Test it, and if broken, subclass or wrap the builder rather than patching vendor/.max_execution_time.composer.json and put it behind your own Retrier interface so that swapping to symfony/retry or Laravel's native helper later is a one-file change instead of a grep-and-replace.How can I help you explore Laravel packages today?