leocarmo/circuit-breaker-php
A circuit breaker implementation for PHP that protects applications from cascading failures by monitoring failing dependencies and temporarily blocking calls until they recover. Configure thresholds, timeouts, and storage adapters to build resilient service integrations.
Install with Composer:
composer require leocarmo/circuit-breaker-php
Requirements depend on the adapter you pick — the core package is storage-agnostic, but the shipped adapters target specific clients (ext-redis / Predis, ext-memcached, ext-mongodb). If you're unsure, start with Redis: it's the adapter most teams deploy, and it works across multiple PHP workers so failure counts aren't per-process.
Where to look first
README.md — adapter list and the exact setter names (setCircuit, setTimeWindow, setFailureThreshold, setTimeout).src/CircuitBreaker.php — the entire public surface (isAvailable, success, failure, getState). It's a small class; read it once and you'll know the whole package.src/Adapters/ — RedisAdapter, PredisAdapter, MemcachedAdapter, MongoDBAdapter. Each implements the same storage contract, so the circuit logic is identical.Minimal first use case — protect one flaky outbound call:
use LeoCarmo\CircuitBreaker\CircuitBreaker;
use LeoCarmo\CircuitBreaker\Adapters\RedisAdapter;
$redis = new \Redis();
$redis->connect('127.0.0.1', 6379);
$breaker = new CircuitBreaker(new RedisAdapter($redis));
$breaker->setCircuit('billing-api')
->setTimeWindow(60) // seconds to aggregate failures
->setFailureThreshold(5) // failures within window to open
->setTimeout(30); // seconds before a half-open probe
if ($breaker->isAvailable()) {
try {
$response = $http->post('https://billing.internal/charge', $payload);
$breaker->success();
} catch (\Throwable $e) {
$breaker->failure();
throw $e;
}
} else {
// fast fallback: queue the charge, return cached result, etc.
}
That's the whole loop: check availability → call → report success()/failure() → fall back when open.
Bind it once, configure it per circuit. In a Laravel app, put the CircuitBreaker (and its adapter) in a singleton binding, then wrap configuration in a small helper that sets the circuit name and thresholds before every call. Don't rely on the previous caller's config — the setters mutate the instance.
// app/Providers/AppServiceProvider.php
$this->app->singleton(CircuitBreaker::class, function () {
$redis = new \Redis();
$redis->connect(config('database.redis.default.host'), config('database.redis.default.port'));
return new CircuitBreaker(new RedisAdapter($redis));
});
// app/Support/Circuit.php
final class Circuit
{
public function __construct(private CircuitBreaker $breaker) {}
public function run(string $name, array $cfg, Closure $call, Closure $fallback)
{
$this->breaker
->setCircuit($name)
->setTimeWindow($cfg['window'])
->setFailureThreshold($cfg['threshold'])
->setTimeout($cfg['timeout']);
if (! $this->breaker->isAvailable()) {
return $fallback();
}
try {
$result = $call();
$this->breaker->success();
return $result;
} catch (\Throwable $e) {
$this->breaker->failure();
throw $e;
}
}
}
Wrap the dependency, not the controller. Circuit breakers belong in the gateway/repository class that talks to a remote service. Keeping the breaker at the HTTP client layer means retries, retries with backoff, and multiple endpoints all share one circuit — which is usually what you want, since a degraded downstream fails on all of them anyway.
One circuit per downstream service. Name circuits after the dependency (billing-api, inventory, payments-stripe) rather than per endpoint. Fine-grained circuits blow past thresholds only under specific traffic mixes and rarely open when you need them.
Pair with timeout + retry, not instead of. The breaker prevents hammering a dead dependency; retries smooth over transient blips. Order matters: retry the individual call inside the breaker's isAvailable() check, and report a single failure() when the retry budget is exhausted. A retry loop outside the breaker defeats the whole purpose.
Use it to fail fast, then degrade gracefully. The else branch is the real product surface — return a cached value, queue the operation, or return a default. If your fallback just re-throws the exception, the breaker hasn't bought you anything except slower timeouts.
Half-open is your probe channel. When setTimeout expires, the next isAvailable() flips to half-open and lets one request through. If it succeeds the circuit closes; if it fails it reopens. Keep the first post-open request cheap (health check, HEAD, read-only lookup) so a still-broken service doesn't take another hit.
Laravel HTTP client middleware. For outbound calls that go through Http::, wrap the breaker in a middleware so every request goes through the same logic:
Http::withMiddleware(new CircuitBreakerMiddleware($breaker))->post(...);
The middleware checks availability before the request, reports success()/failure() after, and throws a CircuitOpenException (custom) so callers can catch it distinctly from a network error.
Test against a fake adapter. For feature tests, swap the storage adapter for a stub that records failures and returns isAvailable() based on a configurable flag. This lets you exercise the open path without flapping real circuits in shared Redis. If you point tests at a real Redis, use a distinct prefix per test run (see gotchas).
Log state transitions, not just calls. Emitting a structured log when the state transitions CLOSED → OPEN or OPEN → HALF_OPEN turns the breaker into a monitoring signal — you'll see which dependencies are degrading before pager alerts fire.
Setters are instance state, not circuit state. setCircuit(), setTimeout(), etc. mutate the breaker object. In a singleton (or Octane worker) that means the last configured circuit wins for any code that doesn't re-set it. Always configure before use, or make a Circuit::for($name) factory that returns a fresh, configured instance.
Redis key collisions across environments. The Redis adapter uses the circuit name in its keys. If staging and prod share a Redis, or two services use a generic name like api, they'll share counters. Prefix circuit names (prod:billing-api) or use a separate Redis database.
Fail-open vs. fail-closed is your choice — the package won't decide for you. If Redis is unreachable, the adapter throws on isAvailable(). That's fail-closed at the breaker level, which usually surfaces as a 500 in the caller. Wrap the breaker call in a try/catch and decide explicitly: for critical flows, log and proceed (fail-open); for optional calls, skip.
Half-open has no locking. The package doesn't coordinate which concurrent request gets the probe. Under load, several in-flight requests can pass through half-open at once and stampede a recovering service. If that matters, layer your own mutex (Redis SET NX with a short TTL) around the probe.
success() in an OPEN state can close the circuit prematurely. Only call success() on requests that were actually let through by isAvailable(). Background warmup jobs, health checks, and cache refreshes that call success() will quietly heal circuits that should stay open.
Count the right failures. A 422 from a validation error is not a downstream outage; a 503 is. Classify before calling failure() — otherwise routine user errors will trip the breaker. Conversely, connection timeouts, 5xx, and 429s belong in the failure bucket.
Tune thresholds from data, not defaults. The default setFailureThreshold is low. In a 50-rps service, 5 failures is a blip; in a 0.5-rps service, it's real. Set the window wide enough to smooth traffic noise (60–120s is typical) and the timeout long enough that a restarting service can come up (30–60s).
Clock skew matters less than you think, but still matters. The time window is evaluated against stored timestamps. If Redis and your app servers disagree by minutes, thresholds behave erratically. Keep NTP healthy and prefer a single shared adapter over per-server in-memory state.
Octane / long-running workers. A singleton CircuitBreaker holds a persistent Redis connection. If that connection drops, the adapter throws on every subsequent call. Bind it per-request, or wrap the adapter's connection acquisition in a retry with a short timeout — and never let a broken breaker take down the whole request path.
Testing is easy — mock the storage interface. The StateStorage contract is small; a hand-rolled in-memory fake (isAvailable, success, failure, getState) is trivial and much faster than a real Redis, and it lets you assert that failure() was called for the right exceptions.
Extension points are the storage layer. If your team uses DynamoDB or Postgres, implement the storage interface — nothing else changes. That's the right place to add TTL cleanup, metrics emission, or per-tenant keying. Don't fork CircuitBreaker itself; it's small enough to leave alone.
How can I help you explore Laravel packages today?