ksaveras/circuit-breaker-bundle
Symfony bundle implementing the circuit breaker pattern to stop cascading failures. It tracks failing service calls and opens the circuit once a threshold is hit, failing fast until recovery is detected.
This package (ksaveras/circuit-breaker-bundle) is a Symfony bundle wrapper around a PHP circuit breaker implementation, not a Laravel package. v1.0.0 is its first stable release. For a Laravel codebase you have two realistic options:
Either way, budget for a small service provider; there is no extra.laravel auto-discovery.
Requirements: PHP 8.1+ (this is the breaking change in v1.0.0), and the breaker's own dependency was bumped, so pin ^1.0 and re-read the underlying library's changelog before upgrading an existing install.
Where to look first, in order:
composer.json — confirm the exact underlying breaker package and version constraint.DependencyInjection/ extension class — this is the closest thing to documentation for available configuration options and default thresholds.First use case: guard exactly one flaky, high-latency outbound dependency — a payment gateway, SMS provider, or internal service — so that when it degrades you fail fast instead of stacking up 30-second timeouts across every request.
Minimal bootstrap in Laravel:
// app/Providers/CircuitBreakerServiceProvider.php
public function register(): void
{
$this->app->singleton(PaymentGatewayBreaker::class, fn () => new PaymentGatewayBreaker(
failureThreshold: 5,
recoveryTimeout: 30,
storage: $this->app->make(CacheStorage::class),
));
}
Before writing any of this, run a throwaway smoke test that trips the breaker deliberately and confirms it opens and later half-opens. You want to know the failure counts and timing behaviour of this version before it is load-bearing.
Wrap at a choke point, not at call sites. The single most important pattern: put the breaker inside the gateway/client class that owns the outbound call, so every caller is protected and no caller has to remember to wrap.
final class PaymentGateway
{
public function __construct(
private HttpClient $http,
private CircuitBreaker $breaker,
private LoggerInterface $log,
) {}
public function charge(Charge $charge): Result
{
return $this->breaker->call(function () use ($charge) {
return $this->http->post('/charges', $charge->toArray());
});
}
}
Consumers then depend on PaymentGateway and get the breaker for free.
One named breaker per downstream dependency. Never share a single breaker across unrelated services — one flaky SMS provider should not open the circuit on your payment gateway. Either register one singleton per dependency or key the storage by name.
Choose storage deliberately. In-memory state dies at the end of the request. Under PHP-FPM that makes a purely in-memory breaker close to decorative, because each request starts from zero failures. Use a shared store (Redis is the natural fit in Laravel) so state survives across requests and across horizontal pods. Under Octane or in queue workers, in-memory state persists across jobs and can silently accumulate — so shared storage plus an explicit reset policy is the safer default both ways.
Design the fallback before you need it. An open circuit throws. Decide per dependency what happens next:
Do not let the breaker exception bubble into a generic 500 — that is worse than the timeout it replaced.
Treat the breaker as an observability feature, not just a resilience one. Log state transitions (closed → open, open → half-open) with the dependency name, and emit a counter you can alert on. A breaker that opens silently is a breaker nobody trusts, and ops will ask you to remove it.
Testing pattern (PHPUnit 10.2): use attributes, not annotations — the docblock @test / @dataProvider style is deprecated in this version.
#[Test]
public function it_opens_after_threshold_and_serves_cached_value(): void
{
$breaker = $this->breakerWith(failureThreshold: 2);
// drive two failures, assert third call short-circuits
}
Assert the behaviour at the seam (caller gets fallback, upstream is not hit) rather than poking at internal state. If you do inspect state, inject a fake clock rather than sleeping.
Queue jobs: wrap the job's outbound work in the breaker, but be aware a failed() handler will not fire for a circuit-open rejection — that is a different failure class from a real job failure. Decide whether an open circuit should release the job back onto the queue with a delay or fail it permanently.
This is a Symfony bundle. Expect a Bundle class, a DI Extension, and YAML configuration. Laravel's container will not resolve any of it. If you install the bundle, treat it as a thin dependency and register the breaker yourself; if you skip the bundle, you lose nothing except the pre-built service definitions.
PHP 8.1 minimum is a real upgrade gate. Check your CI matrix, composer.json require.php, and any Docker base image still on 8.0. Also re-check indirect dependencies that cap PHP versions — this is the most common cause of a failed upgrade on this kind of release.
The breaker bump is the risky line item. "Bump circuit breaker" is listed as a breaking change, which means threshold defaults, state transition rules, or the exception type may have changed. Existing tuning values are not guaranteed to mean the same thing. Re-run your trip/recover tests against v1.0.0 explicitly rather than trusting a green build.
A breaker is not a timeout. It counts failures; it does not prevent a hung request from blocking for 30 seconds. You must set connect and read timeouts on the underlying HTTP client, or the first few failures will each cost you the full timeout before the circuit opens.
Only count dependency failures. 4xx responses, validation errors, and business-logic rejections should not trip the breaker — if they do, a spike in bad user input will take down a healthy integration. Conversely, if your HTTP client throws on 4xx by default, you will need to unwrap those before they reach the breaker.
Half-open needs to be a single probe. If dozens of requests are allowed through the moment the recovery timeout expires, you recreate the outage you were protecting against. Verify how this version arbitrates concurrent half-open traffic.
Give ops a manual override. An artisan command or config kill switch to force-open or force-reset a named breaker saves you at 2am:
// php artisan breaker:reset payment-gateway
Testing traps: wall-clock-dependent recovery timeouts make suites slow and flaky. Inject a clock (or a very short recovery timeout in test config) and never sleep() through a half-open window.
CI change: Travis config was removed in this release. If you were mirroring its pipeline, assume GitHub Actions and verify the test matrix still covers your PHP version.
Keep the breaker off the happy path's critical budget. It is a few microseconds, but wrapping every internal call in a breaker "just in case" adds noise, log volume, and false opens. Reserve it for genuinely remote, genuinely unreliable dependencies.
How can I help you explore Laravel packages today?