ahmednour / circuit-breaker
Distributed circuit breaker for Laravel: Redis-backed shared state, single-probe half-open locking, and configurable behaviour when the breaker's own infrastructure fails.
Requires
- php: ^8.2
- ext-redis: *
- illuminate/contracts: ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^10.0 || ^11.0 || ^12.0 || ^13.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.8
- illuminate/config: ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/container: ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/redis: ^10.0 || ^11.0 || ^12.0 || ^13.0
- laravel/pint: ^1.18
- mockery/mockery: ^1.6
- phpunit/phpunit: ^10.5 || ^11.0 || ^12.0
- predis/predis: ^2.2 || ^3.0
Suggests
- guzzlehttp/guzzle: Required for the built-in HTTP failure classifier to recognise Guzzle connection and 5xx exceptions.
Provides
None
Conflicts
None
Replaces
None
- dev-master
- v2.0.0
- v1.0.0
- dev-release/2.0.0
- dev-readme-claims-and-ci-coverage-do-not-match-the-c-2
- dev-readme-claims-and-ci-coverage-do-not-match-the-c
- dev-probe-lock-expires-during-a-slow-probe-allowing
- dev-4xx-responses-half-open-circuit-stays-half-open
- dev-circuit-state-updates-are-not-atomic-failure-cou
- dev-probe-lock-never-reports-busy-with-laravels-phpr
- dev-callback-runs-twice-when-redis-fails-after-the-p
This package is auto-updated.
Last update: 2026-10-11 12:00:22 UTC
README
A distributed Circuit Breaker package for PHP 8.2+ and Laravel. Built with clean architecture principles to protect your applications from cascading failures when third-party APIs (SMS gateways, payment processors, CRMs, email providers, etc.) become degraded or unavailable.
Key Features
- Distributed State Management: Shared Redis circuit state across multiple app instances and background workers.
- Distributed Probe Locking: A Redis lock lets a single worker at a time probe the external service in
HALF_OPENstate, limiting retry stampedes. The lock has a TTL (half_open_lock_timeout); a probe that outlives it can overlap with another worker's probe, so set it above your slowest call. - Intelligent HTTP Failure Classification: Differentiates client errors (
4xxvalidation/bad requests) from infrastructure/server outages (5xx, Guzzle/Laravel connection exceptions). Matching on exception-message text such astimeoutis opt-in:new HttpFailureClassifier(matchMessagePatterns: true). - Self-Infrastructure Failure Resilience: Storage-agnostic handling for its own infrastructure failures (e.g. Redis outages) supporting Fail-Open, Fail-Closed, and Fallback strategies.
- Octane & Horizon Ready: Seamless connection lifecycle management to prevent stale Redis connections across long-running workers.
- Strict Separation of Concerns: Pure domain logic decoupled from Laravel, Redis, and Guzzle.
- Tested: Unit, integration, and Redis infrastructure tests run in CI against PHP 8.2–8.4 and Laravel 10–13, with raw
\Redisand Laravel's phpredis and predis connections. - Low overhead: A successful call on a healthy closed circuit costs one Redis command (a
GET) when using Laravel's Redis manager.
Architecture & State Machine
stateDiagram-v2
[*] --> Closed
Closed --> Open: Failure threshold reached\n(e.g., 5 consecutive 5xx/timeouts;\nany success or 4xx resets the count)
Closed --> Closed: Successful call / 4xx error (resets failure count)
Open --> Open: Calls fail fast with CircuitOpenException\n(No external HTTP requests made)
Open --> HalfOpen: Recovery timeout elapsed\n(e.g., 60s passed)
HalfOpen --> Closed: Probe succeeds or fails with a 4xx (provider reachable; acquires lock, recovers circuit)
HalfOpen --> Open: Probe fails (trips back to Open)
HalfOpen --> HalfOpen: Another worker holds probe lock\n(throws CircuitBusyException)
Loading
The Four Distinct Failure Types
A resilient circuit breaker must distinguish between four completely different failure scenarios:
| Failure Type | Example | Behavior |
|---|---|---|
| 1. Provider Failure | Twilio returns 503 Service Unavailable or cURL timeout |
Increment failure count; trip to OPEN if threshold is reached. Re-throws provider exception. |
| 2. Circuit Open | Provider is down and recovery timeout has not passed | Fails fast immediately by throwing CircuitOpenException without touching the provider. |
| 3. Circuit Busy | Another worker is currently executing a probe in HALF_OPEN |
Throws CircuitBusyException to avoid concurrent probes against an unhealthy dependency. |
| 4. Infrastructure Failure | Redis server down, connection timeout, network partition | Caught by CircuitBreaker and delegated to the configured policy (fail_open, fail_closed, or fallback). |
Installation
composer require ahmednour/circuit-breaker
Laravel Service Provider
If package auto-discovery is enabled, CircuitBreakerServiceProvider is loaded automatically. Otherwise, add it to your config/app.php providers array:
'providers' => [ // ... AhmedNour\CircuitBreaker\Providers\CircuitBreakerServiceProvider::class, ],
Publish the configuration file:
php artisan vendor:publish --tag=circuit-breaker-config
Probe lock TTL
half_open_lock_timeout (default 60 s) is the TTL of the half-open probe lock. It is not extended while the probe runs, so it must be greater than the timeout of the protected call (e.g. your HTTP client timeout). If the lock expires mid-probe, another worker can start a second probe. Set the optional call_timeout on a policy to have the package reject a policy whose lock TTL is not greater than it. As a safeguard, a probe that finishes after its lock expired does not write its result to the circuit state.
The published config file is located at config/circuit-breaker.php:
return [ /* |-------------------------------------------------------------------------- | Default Circuit Breaker Policy |-------------------------------------------------------------------------- | Default policy applied when a provider does not specify custom rules. */ 'default' => [ 'failure_threshold' => (int) env('CIRCUIT_BREAKER_DEFAULT_THRESHOLD', 5), 'recovery_timeout' => (int) env('CIRCUIT_BREAKER_DEFAULT_RECOVERY_TIMEOUT', 60), 'half_open_lock_timeout' => (int) env('CIRCUIT_BREAKER_DEFAULT_LOCK_TIMEOUT', 60), // Optional: timeout of the protected call. When set, half_open_lock_timeout must exceed it. // 'call_timeout' => 30, ], /* |-------------------------------------------------------------------------- | Provider-Specific Policies |-------------------------------------------------------------------------- | Define custom thresholds, recovery timeouts, and probe lock TTLs per service. */ 'providers' => [ // Shipped empty. Example: // 'payment-gateway' => [ // 'failure_threshold' => 3, // 'recovery_timeout' => 60, // 'half_open_lock_timeout' => 60, // ], ], /* |-------------------------------------------------------------------------- | Redis Connection & Key Prefixes |-------------------------------------------------------------------------- */ 'redis' => [ 'connection' => env('CIRCUIT_BREAKER_REDIS_CONNECTION', 'default'), 'prefix' => 'circuit:', 'lock_suffix' => ':probe-lock', ], /* |-------------------------------------------------------------------------- | Infrastructure Failure Handling |-------------------------------------------------------------------------- | Strategy when Circuit Breaker storage (e.g. Redis) is unavailable: | - 'fail_open' : Allow the operation to execute (default). | - 'fail_closed' : Reject the operation with CircuitInfrastructureException. | - 'fallback' : Delegate to a configured InfrastructureFallback implementation. */ 'infrastructure_failure' => [ 'strategy' => env('CIRCUIT_BREAKER_INFRASTRUCTURE_FAILURE_STRATEGY', 'fail_open'), 'fallback' => null, // e.g., App\Services\CircuitBreaker\DatabaseFallback::class ], ];
Usage Examples
1. Basic Execution (Controllers / Services)
Wrap any external service call in the execute method:
namespace App\Services; use AhmedNour\CircuitBreaker\Application\CircuitBreaker; use AhmedNour\CircuitBreaker\Domain\Exceptions\CircuitOpenException; use AhmedNour\CircuitBreaker\Domain\Exceptions\CircuitBusyException; class SmsService { public function __construct( private readonly CircuitBreaker $circuitBreaker, private readonly TwilioClient $twilio, ) {} public function sendSms(string $to, string $message): string { try { return $this->circuitBreaker->execute('twilio-sms', function () use ($to, $message) { return $this->twilio->messages->create($to, [ 'body' => $message, ]); }); } catch (CircuitOpenException $e) { // Service is known to be down — fallback or notify user logger()->warning("Twilio circuit is OPEN. Failing fast."); throw new ServiceUnavailableException("SMS gateway is temporarily unavailable."); } catch (CircuitBusyException $e) { // Another worker is currently testing the connection logger()->info("Twilio circuit is HALF-OPEN probe busy."); throw new ServiceBusyException("SMS gateway is recovering, please retry shortly."); } } }
2. Laravel Queues & Horizon Integration
Handle circuit open or busy states cleanly in queue jobs with automatic delay releases:
namespace App\Jobs; use AhmedNour\CircuitBreaker\Application\CircuitBreaker; use AhmedNour\CircuitBreaker\Domain\Exceptions\CircuitBusyException; use AhmedNour\CircuitBreaker\Domain\Exceptions\CircuitOpenException; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; class SendSmsNotificationJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public int $tries = 5; public function __construct( public readonly string $phone, public readonly string $message, ) {} public function handle(CircuitBreaker $circuitBreaker, TwilioClient $twilio): void { try { $circuitBreaker->execute('twilio-sms', function () use ($twilio) { $twilio->send($this->phone, $this->message); }); } catch (CircuitOpenException) { // Circuit is OPEN: Delay release by recovery window (e.g., 300s) $this->release(300); } catch (CircuitBusyException) { // Circuit is HALF_OPEN and another worker is probing: retry in 10s $this->release(10); } } }
Infrastructure Failure Strategies
If the Circuit Breaker's own storage (e.g. Redis) is down, the package uses the configured infrastructure_failure.strategy:
At-most-once guarantee: the protected callback runs at most once per
execute()call under every strategy. The strategy only applies when the breaker's storage fails before the callback runs (reading state, acquiring the half-open lock, moving open → half-open). If storage fails after the callback ran, recording the outcome is best-effort: a successful result is still returned, and a provider exception is rethrown unchanged. The callback is never re-invoked.
1. Fail Open (fail_open — Default)
Executes the protected callback anyway. Ideal when service availability is prioritized over protection to prevent a Redis outage from taking down your entire application.
Redis DOWN ──► FailOpenHandler ──► Execute Protected Operation
2. Fail Closed (fail_closed)
Rejects the operation and throws CircuitInfrastructureException. Ideal for high-risk operations (e.g. payments, money transfers, hard rate-limited APIs) where executing without safety guarantees is dangerous.
Redis DOWN ──► FailClosedHandler ──► Throws CircuitInfrastructureException
3. Fallback (fallback)
Delegates to an implementation of InfrastructureFallback (e.g. database-backed store or in-memory fallback).
use AhmedNour\CircuitBreaker\Domain\CircuitExecution; use AhmedNour\CircuitBreaker\Domain\Contracts\InfrastructureFallback; use Throwable; class DatabaseCircuitFallback implements InfrastructureFallback { public function execute(CircuitExecution $execution, Throwable $exception): mixed { // Custom recovery logic, logging, or fallback execution return ($execution->callback)(); } }
Configure in config/circuit-breaker.php:
'infrastructure_failure' => [ 'strategy' => 'fallback', 'fallback' => App\Services\DatabaseCircuitFallback::class, ],
HTTP Failure Classification
The default HttpFailureClassifier intelligently distinguishes real provider outages from normal client errors:
- Does NOT Trip (Client Errors):
400 Bad Request,401 Unauthorized,403 Forbidden,404 Not Found,422 Unprocessable Entity- Application bugs and invalid user inputs do not count toward circuit trips.
- Trips Circuit (Server & Network Errors):
500 Internal Server Error,502 Bad Gateway,503 Service Unavailable,504 Gateway Timeout- Guzzle
ConnectExceptionandServerException - Laravel HTTP Client
ConnectionException - Network error messages (
connection timed out,cURL error 28,connection refused,DNS resolution failed,reset by peer) only when opted in withnew HttpFailureClassifier(matchMessagePatterns: true)
Testing
Run the test suite using PHPUnit:
php vendor/bin/phpunit
To run with sail or Docker:
./vendor/bin/sail bin phpunit
Standalone In-Memory Testing
Domain logic and state machines are fully decoupled and can be tested without Redis using the provided test doubles in Tests\Support:
InMemoryCircuitStoreFakeLockFakeClockAlwaysTripFailureClassifier/NeverTripFailureClassifier
License
This package is open-sourced software licensed under the MIT license.