eljam/circuit-breaker
eljam/circuit-breaker adds the circuit breaker pattern to Laravel: it tracks failures from flaky services, opens the circuit to fail fast instead of cascading, and closes it again once the service recovers. Configurable thresholds, fallbacks, and custom handlers prevent downtime from spreading.
Install with Composer:
composer require eljam/circuit-breaker
The package has no framework coupling. Its only hard dependency of note is symfony/event-dispatcher (used to emit lifecycle events), and it ships a MemoryStorage so you can be running in a couple of minutes.
Minimal wiring:
use Eljam\CircuitBreaker\CircuitBreaker;
use Eljam\CircuitBreaker\Storage\MemoryStorage;
use Symfony\Component\EventDispatcher\EventDispatcher;
$breaker = new CircuitBreaker(new EventDispatcher(), new MemoryStorage());
$circuit = $breaker->getCircuit('payments-api'); // name, and optionally threshold/timeout
if ($circuit->isAvailable()) {
try {
$response = $client->charge($payload);
$circuit->success();
return $response;
} catch (\Throwable $e) {
$circuit->failure();
throw $e;
}
}
throw new \RuntimeException('payments-api circuit is open');
Where to look first, in this order:
Eljam\CircuitBreaker\Circuit — the state machine (closed / open / half-open), the failure counter, threshold and timeout.Eljam\CircuitBreaker\Storage\StorageInterface — this is the piece you will almost certainly replace to make the breaker useful in production.Eljam\CircuitBreaker\CircuitEvents — the event names (FAILURE, SUCCESS, OPEN, CLOSE, HALF_OPEN) you'll subscribe to for logging and metrics.First realistic use case: put the breaker around one flaky outbound dependency — a payments gateway, a geocoding API, a legacy SOAP service — where repeated timeouts are dragging down your request latency. Ship it with default threshold/timeout and an event listener that logs transitions. Don't wire it across the whole app on day one.
The guard / success / failure triad. Every call site is structurally identical: check isAvailable(), do the work, report success() or failure(). Wrap it once so it isn't copy-pasted:
function guarded(Circuit $circuit, callable $fn, ?callable $fallback = null) {
if (!$circuit->isAvailable()) {
return $fallback ? $fallback() : throw new CircuitOpenException($circuit->getName());
}
try {
$result = $fn();
$circuit->success();
return $result;
} catch (\Throwable $e) {
$circuit->failure();
throw $e;
}
}
Decorate the client, not every caller. For Guzzle or your own HTTP wrapper, a middleware or a thin decorator class is the cleanest integration: the circuit name is derived from the host or a config constant, and every request through that client is automatically guarded. Do the same for a repository or an internal service client. Callers stay unaware the breaker exists.
Name circuits per dependency, not per endpoint. payments-api, geo-api, legacy-crm. A circuit per endpoint fragments the failure signal and you'll never reach the threshold; a circuit per host gives you the "this dependency is down" signal you actually want. If a single endpoint is dramatically more fragile, give it a child circuit like payments-api.refund.
Persistent storage is the whole point. MemoryStorage resets on every request, so under PHP-FPM it can never meaningfully open. Before shipping, implement StorageInterface on top of Redis, APCu, or your DB, keyed by circuit name, storing state, failure count, and last failure timestamp. A per-request breaker is theater.
Subscribe to events for observability. Listen on CircuitEvents::FAILURE, OPEN, CLOSE, HALF_OPEN. Log the transition once (not per request), increment a metric, and alert on OPEN for critical dependencies. Most of the day-to-day value of this package shows up here — the breaker doubles as a dependency-health signal for free.
Half-open is your probe. Once the timeout elapses the circuit goes half-open and the next call is the probe. Your guarded helper must still call failure() on a failed probe, or you'll wedge the circuit permanently in half-open. With N PHP workers waking up at once you may get N probes; if that matters, use a lock or a "first caller wins" flag in your storage.
Fallbacks live at the call site. Cached/stale data for reads, a queued job for writes, a friendly empty state for UI. The breaker tells you when; the call site knows what.
Tune two knobs per circuit. Threshold (consecutive failures before opening) and timeout (cooldown before half-open). Start at the defaults and adjust per dependency — a payment gateway deserves a lower threshold than an analytics ping.
failure() is entirely manual. The number-one bug is catching an exception, returning a fallback, and forgetting to report the failure — the circuit never opens and you've built a no-op.failure() or you'll open circuits on ordinary client errors.isAvailable() followed by the call is not atomic; several workers can all pass while the circuit is opening. Usually fine (you're limiting damage, not enforcing a hard quota) — just don't expect strict semantics.success() resets the failure counter. That's the intended "consecutive failures" model. If you need a rate-based breaker (failures per time window), this package won't give it to you out of the box; extend Circuit or keep the window counters in your storage.new EventDispatcher() is fine — and prefer a dedicated instance over your app's global dispatcher so these events don't leak into unrelated listeners.StorageInterface implementation. Make the open/half-open transition atomic (Redis SET NX / a Lua script / a transaction) and set a TTL slightly longer than the timeout so abandoned circuits clean themselves up.Circuit and CircuitBreaker before spreading usage across the codebase.MemoryStorage makes tests deterministic; still add one integration test against your real persistent storage.How can I help you explore Laravel packages today?