A robust and feature-rich implementation of the Circuit Breaker pattern for PHP applications. This library helps prevent cascading failures by monitoring service calls and automatically switching between three states: Closed, Open, and Half-Open.
- Features
- Installation
- Quick Start
- Configuration
- Symfony Integration
- Circuit Breaker States
- State Transitions
- Monitoring & Metrics
- Architecture
- Thread Safety
- Roadmap & Future Features
- Contributing
- License
- Further Reading
- Three-State Circuit Breaker: Implements the classic Closed β Open β Half-Open β Closed cycle
- Multi-Worker Support: Persistent state storage for distributed environments
- Thread Safety: Built-in locking mechanisms for concurrent access
- Flexible Configuration: Customizable failure thresholds, timeouts, and recovery settings
- Factory Pattern: Centralized management of multiple circuit breakers
- Type Safety: Full PHP 8.1+ type declarations and PHPStan level 8 compliance
- Zero Dependencies: No external runtime dependencies
git clone git@github.com:nullodyssey/circuit-breaker.git<?php
use NullOdyssey\CircuitBreaker\CircuitBreaker;
use NullOdyssey\CircuitBreaker\CircuitBreakerException;
// Create a circuit breaker for your service
$circuitBreaker = new CircuitBreaker(
serviceName: 'external-api',
failureThreshold: 5, // Open after 5 failures
recoveryTimeoutSeconds: 60, // Wait 60 seconds before trying again
halfOpenMaxCalls: 3 // Allow 3 test calls in half-open state
);
// Wrap your service calls
try {
$result = $circuitBreaker->call(function () {
// Your potentially failing service call
return file_get_contents('https://api.example.com/data');
});
echo "Success: " . $result;
} catch (CircuitBreakerException $e) {
echo "Circuit breaker is open: " . $e->getMessage();
} catch (Exception $e) {
echo "Service call failed: " . $e->getMessage();
}<?php
use NullOdyssey\CircuitBreaker\CircuitBreakerFactory;
// Create a factory with default settings
$factory = new CircuitBreakerFactory(
defaultFailureThreshold: 5,
defaultRecoveryTimeoutSeconds: 60,
defaultHalfOpenMaxCalls: 3
);
// Get circuit breakers for different services
$userServiceCB = $factory->circuitFor('user-service');
$paymentServiceCB = $factory->circuitFor('payment-service');
// Each service has its own independent circuit breaker
$userServiceCB->call(fn() => $userApi->getUser($id));
$paymentServiceCB->call(fn() => $paymentApi->processPayment($data));For applications running across multiple processes or workers, use the persistent circuit breaker:
<?php
use NullOdyssey\CircuitBreaker\CircuitBreakerFactory;
use NullOdyssey\CircuitBreaker\InMemoryStore;
// Create a state store (implement your own for Redis, Database, etc.)
$stateStore = new InMemoryStore();
// Create factory with state store
$factory = new CircuitBreakerFactory(
defaultFailureThreshold: 5,
defaultRecoveryTimeoutSeconds: 60,
defaultHalfOpenMaxCalls: 3,
stateStore: $stateStore // Enable persistence
);
// Circuit breaker state is now shared across all workers
$circuitBreaker = $factory->circuitFor('shared-service');| Parameter | Description | Default |
|---|---|---|
serviceName |
Unique identifier for the service | Required |
failureThreshold |
Number of failures before opening the circuit | 5 |
recoveryTimeoutSeconds |
Time to wait before attempting recovery | 60 |
halfOpenMaxCalls |
Maximum calls allowed in half-open state | 3 |
Implement CircuitBreakerStoreInterface for custom persistence:
<?php
use NullOdyssey\CircuitBreaker\CircuitBreakerStoreInterface;
use NullOdyssey\CircuitBreaker\CircuitBreakerStateData;
class RedisStateStore implements CircuitBreakerStoreInterface
{
public function __construct(private Redis $redis) {}
public function load(string $serviceName): ?CircuitBreakerStateData
{
$data = $this->redis->get("circuit_breaker:$serviceName");
return $data ? CircuitBreakerStateData::fromArray(json_decode($data, true)) : null;
}
public function save(string $serviceName, CircuitBreakerStateData $stateData): void
{
$this->redis->set("circuit_breaker:$serviceName", json_encode($stateData->toArray()));
}
public function lock(string $serviceName, callable $callback): mixed
{
$lockKey = "circuit_breaker_lock:$serviceName";
if (!$this->redis->set($lockKey, '1', ['nx', 'ex' => 10])) {
throw new \RuntimeException("Could not acquire lock for $serviceName");
}
try {
$currentState = $this->load($serviceName);
return $callback($currentState);
} finally {
$this->redis->del($lockKey);
}
}
public function delete(string $serviceName): void
{
$this->redis->del("circuit_breaker:$serviceName");
}
public function exists(string $serviceName): bool
{
return $this->redis->exists("circuit_breaker:$serviceName");
}
public function clear(): void
{
$keys = $this->redis->keys('circuit_breaker:*');
if ($keys) {
$this->redis->del($keys);
}
}
}The Circuit Breaker library integrates seamlessly with Symfony applications using the Lock component and Redis component for distributed state management.
composer require nullodyssey/circuit-breaker
composer require symfony/lock
composer require symfony/redis-messengerCreate a Symfony-compatible Redis state store using Symfony's Lock component:
<?php
// src/CircuitBreaker/SymfonyRedisStateStore.php
namespace App\CircuitBreaker;
use NullOdyssey\CircuitBreaker\CircuitBreakerStoreInterface;
use NullOdyssey\CircuitBreaker\CircuitBreakerStateData;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\LockInterface;
use Symfony\Component\Lock\Store\RedisStore;
use Symfony\Contracts\Cache\CacheInterface;
class SymfonyRedisStateStore implements CircuitBreakerStoreInterface
{
private const CACHE_PREFIX = 'circuit_breaker';
private const LOCK_PREFIX = 'circuit_breaker_lock';
private const LOCK_TTL = 30; // seconds
public function __construct(
private CacheInterface $cache,
private LockFactory $lockFactory,
) {}
public function load(string $serviceName): ?CircuitBreakerStateData
{
$cacheKey = self::CACHE_PREFIX . '.' . $serviceName;
$data = $this->cache->get($cacheKey, function () {
return null;
});
return $data ? CircuitBreakerStateData::fromArray($data) : null;
}
public function save(string $serviceName, CircuitBreakerStateData $stateData): void
{
$cacheKey = self::CACHE_PREFIX . '.' . $serviceName;
// Cache for 1 hour (circuit breaker will refresh as needed)
$this->cache->set($cacheKey, $stateData->toArray(), 3600);
}
public function lock(string $serviceName, callable $callback): mixed
{
$lockKey = self::LOCK_PREFIX . '.' . $serviceName;
$lock = $this->lockFactory->createLock($lockKey, self::LOCK_TTL);
if (!$lock->acquire()) {
throw new \RuntimeException("Could not acquire lock for service: $serviceName");
}
try {
$currentState = $this->load($serviceName);
$result = $callback($currentState);
// If callback returns state data, save it
if ($result instanceof CircuitBreakerStateData) {
$this->save($serviceName, $result);
}
return $result;
} finally {
$lock->release();
}
}
public function delete(string $serviceName): void
{
$cacheKey = self::CACHE_PREFIX . '.' . $serviceName;
$this->cache->delete($cacheKey);
}
public function exists(string $serviceName): bool
{
$cacheKey = self::CACHE_PREFIX . '.' . $serviceName;
return $this->cache->get($cacheKey, function () {
return false;
}) !== false;
}
public function clear(): void
{
$this->cache->clear();
}
}Configure the circuit breaker services in your Symfony application:
# config/services.yaml
services:
# Circuit breaker state store
App\CircuitBreaker\SymfonyRedisStateStore:
arguments:
- '@cache.app'
- '@lock.factory'
# Circuit breaker factory
circuit_breaker.factory:
class: NullOdyssey\CircuitBreaker\CircuitBreakerFactory
arguments:
$defaultFailureThreshold: 5
$defaultRecoveryTimeoutSeconds: 60
$defaultHalfOpenMaxCalls: 3
$stateStore: '@App\CircuitBreaker\SymfonyRedisStateStore'
# Alias for easier injection
NullOdyssey\CircuitBreaker\CircuitBreakerFactory: '@circuit_breaker.factory'Use the circuit breaker in your Symfony controllers:
<?php
// src/Controller/ApiController.php
namespace App\Controller;
use NullOdyssey\CircuitBreaker\CircuitBreakerFactory;
use NullOdyssey\CircuitBreaker\CircuitBreakerException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class ApiController extends AbstractController
{
public function __construct(
private CircuitBreakerFactory $circuitBreakerFactory,
private HttpClientInterface $httpClient,
) {}
#[Route('/api/external-data', name: 'external_data', methods: ['GET'])]
public function getExternalData(): JsonResponse
{
$circuitBreaker = $this->circuitBreakerFactory->circuitFor('external-api');
try {
$data = $circuitBreaker->call(function () {
// Call external API with timeout
$response = $this->httpClient->request('GET', 'https://api.example.com/data', [
'timeout' => 5,
'headers' => [
'Accept' => 'application/json',
],
]);
if ($response->getStatusCode() !== 200) {
throw new \RuntimeException('API returned non-200 status');
}
return $response->toArray();
});
return new JsonResponse([
'success' => true,
'data' => $data,
'circuit_state' => $circuitBreaker->getState()->value,
]);
} catch (CircuitBreakerException $e) {
// Circuit breaker is open - return cached data or error
return new JsonResponse([
'success' => false,
'error' => 'Service temporarily unavailable',
'circuit_state' => 'open',
'retry_after' => $circuitBreaker->nextAttemptTime()?->format('c'),
], Response::HTTP_SERVICE_UNAVAILABLE);
} catch (\Exception $e) {
// Service call failed - circuit breaker recorded the failure
return new JsonResponse([
'success' => false,
'error' => 'External service failed',
'circuit_state' => $circuitBreaker->getState()->value,
], Response::HTTP_BAD_GATEWAY);
}
}
#[Route('/api/circuit-breaker/status', name: 'circuit_status', methods: ['GET'])]
public function getCircuitBreakerStatus(): JsonResponse
{
$services = ['external-api', 'payment-service', 'user-service'];
$status = [];
foreach ($services as $serviceName) {
$circuitBreaker = $this->circuitBreakerFactory->circuitFor($serviceName);
$status[$serviceName] = [
'state' => $circuitBreaker->getState()->value,
'failure_count' => $circuitBreaker->failureCount(),
'half_open_calls' => $circuitBreaker->halfOpenCallCount(),
'half_open_successes' => $circuitBreaker->halfOpenSuccessCount(),
'last_failure' => $circuitBreaker->lastFailureTime()?->format('c'),
'next_attempt' => $circuitBreaker->nextAttemptTime()?->format('c'),
];
}
return new JsonResponse($status);
}
#[Route('/api/circuit-breaker/reset/{serviceName}', name: 'circuit_reset', methods: ['POST'])]
public function resetCircuitBreaker(string $serviceName): JsonResponse
{
try {
$circuitBreaker = $this->circuitBreakerFactory->circuitFor($serviceName);
$circuitBreaker->reset();
return new JsonResponse([
'success' => true,
'message' => "Circuit breaker for {$serviceName} has been reset",
'state' => $circuitBreaker->getState()->value,
]);
} catch (\Exception $e) {
return new JsonResponse([
'success' => false,
'error' => $e->getMessage(),
], Response::HTTP_BAD_REQUEST);
}
}
}Create a dedicated service for external API calls:
<?php
// src/Service/ExternalApiService.php
namespace App\Service;
use NullOdyssey\CircuitBreaker\CircuitBreakerFactory;
use NullOdyssey\CircuitBreaker\CircuitBreakerException;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Psr\Log\LoggerInterface;
class ExternalApiService
{
private const SERVICE_NAME = 'external-api';
public function __construct(
private CircuitBreakerFactory $circuitBreakerFactory,
private HttpClientInterface $httpClient,
private LoggerInterface $logger,
) {}
public function fetchUserData(int $userId): array
{
$circuitBreaker = $this->circuitBreakerFactory->circuitFor(self::SERVICE_NAME);
try {
return $circuitBreaker->call(function () use ($userId) {
$this->logger->info('Calling external API for user', ['user_id' => $userId]);
$response = $this->httpClient->request('GET', "/api/users/{$userId}", [
'timeout' => 3,
'max_redirects' => 2,
]);
if ($response->getStatusCode() !== 200) {
throw new \RuntimeException('API returned status: ' . $response->getStatusCode());
}
$data = $response->toArray();
$this->logger->info('External API call successful', ['user_id' => $userId]);
return $data;
});
} catch (CircuitBreakerException $e) {
$this->logger->warning('Circuit breaker is open for external API', [
'service' => self::SERVICE_NAME,
'next_attempt' => $circuitBreaker->nextAttemptTime()?->format('c'),
]);
throw $e;
} catch (\Exception $e) {
$this->logger->error('External API call failed', [
'service' => self::SERVICE_NAME,
'error' => $e->getMessage(),
'failure_count' => $circuitBreaker->failureCount(),
]);
throw $e;
}
}
}Create a console command to monitor circuit breaker status:
<?php
// src/Command/CircuitBreakerStatusCommand.php
namespace App\Command;
use NullOdyssey\CircuitBreaker\CircuitBreakerFactory;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'circuit-breaker:status',
description: 'Display circuit breaker status for all services',
)]
class CircuitBreakerStatusCommand extends Command
{
private const SERVICES = [
'external-api',
'payment-service',
'user-service',
'notification-service',
];
public function __construct(
private CircuitBreakerFactory $circuitBreakerFactory,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$rows = [];
foreach (self::SERVICES as $serviceName) {
$circuitBreaker = $this->circuitBreakerFactory->circuitFor($serviceName);
$state = $circuitBreaker->getState()->value;
$stateIcon = match($state) {
'closed' => 'π’',
'open' => 'π΄',
'half_open' => 'π‘',
default => 'β',
};
$rows[] = [
$serviceName,
$stateIcon . ' ' . strtoupper($state),
$circuitBreaker->failureCount(),
$circuitBreaker->halfOpenCallCount(),
$circuitBreaker->lastFailureTime()?->format('Y-m-d H:i:s') ?? 'Never',
$circuitBreaker->nextAttemptTime()?->format('Y-m-d H:i:s') ?? 'N/A',
];
}
$io->table(
['Service', 'State', 'Failures', 'Half-Open Calls', 'Last Failure', 'Next Attempt'],
$rows
);
return Command::SUCCESS;
}
}This comprehensive Symfony integration provides:
- Redis-based state storage with Symfony's Lock component
- Service container configuration for dependency injection
- Controller examples showing real-world usage
- Dedicated service classes for clean architecture
- Console command for monitoring
- Error handling and logging integration
- Thread-safe operations across multiple Symfony processes
- Default state: All calls pass through normally
- Failure tracking: Counts consecutive failures
- Transition: Opens when failure threshold is reached
- Fail fast: All calls immediately throw
CircuitBreakerException - Recovery timer: Waits for the configured timeout period
- Transition: Moves to Half-Open after timeout expires
- Testing phase: Allows limited number of calls to test service recovery
- Success tracking: Counts successful calls in this state
- Transitions:
- To Closed: When all test calls succeed
- To Open: On any failure
Closed ββ[failures β₯ threshold]ββ> Open
β β
β [timeout expires]
β β
βββ[success calls β₯ limit]ββ Half-Open
β
β
[any failure]
β
Open
<?php
// Check current state
if ($circuitBreaker->isOpen()) {
echo "Circuit is open - failing fast";
} elseif ($circuitBreaker->isHalfOpen()) {
echo "Circuit is half-open - testing recovery";
} else {
echo "Circuit is closed - operating normally";
}
// Get detailed metrics
echo "Failure count: " . $circuitBreaker->failureCount() . "\n";
echo "Half-open calls: " . $circuitBreaker->halfOpenCallCount() . "\n";
echo "Half-open successes: " . $circuitBreaker->halfOpenSuccessCount() . "\n";
$lastFailure = $circuitBreaker->lastFailureTime();
if ($lastFailure) {
echo "Last failure: " . $lastFailure->format('Y-m-d H:i:s') . "\n";
}
$nextAttempt = $circuitBreaker->nextAttemptTime();
if ($nextAttempt) {
echo "Next attempt: " . $nextAttempt->format('Y-m-d H:i:s') . "\n";
}<?php
// Get all managed circuit breakers
$allCircuits = $factory->circuitBreakers();
foreach ($allCircuits as $serviceName => $circuitBreaker) {
echo "$serviceName: " . $circuitBreaker->getState()->value . "\n";
}
// Reset specific service
$factory->resetService('problematic-service');
// Reset all services
$factory->resetAll();CircuitBreakerInterface
βββ CircuitBreaker (basic implementation)
βββ PersistentCircuitBreaker (with state store)
CircuitBreakerFactoryInterface
βββ CircuitBreakerFactory
CircuitBreakerStoreInterface
βββ InMemoryStore (basic implementation)
CircuitBreakerStateData (DTO for persistence)
CircuitBreakerState (enum: CLOSED, OPEN, HALF_OPEN)
CircuitBreakerException (thrown when circuit is open)
The persistent circuit breaker provides thread safety through:
- Locking mechanism: Prevents concurrent state modifications
- Atomic operations: State changes are atomic within locks
- Consistent state: Ensures all workers see the same state
The Circuit Breaker library is continuously evolving. Here's our roadmap for upcoming features and improvements:
| Feature | Impact | Complexity | Priority |
|---|---|---|---|
| Metrics Collection | High | Medium | High |
| Fallback Mechanisms | High | Medium | High |
| Rolling Window | Medium | Low | Medium |
| Database Store | Medium | Medium | Medium |
| Frameworks Integration | High | Medium | Medium |
| Health Checks | Medium | Low | Low |
| OpenTelemetry | Low | High | Low |
We welcome contributions for any of these features! Here's how you can help:
- Metrics Collection Interface - Add comprehensive metrics support
- Fallback Mechanisms - Implement fallback strategies
- Configuration Validation - Add runtime config validation
- Check our GitHub Issues for feature requests
- Join the discussion on feature design
- Submit a pull request with your implementation
- Help with testing and documentation
Have an idea for a new feature? Please:
- Open a GitHub issue with the "enhancement" label
- Provide detailed use cases and examples
- Discuss the API design with the community
- Consider contributing the implementation
We welcome contributions! Please:
- Fork the repository
- Create a feature branch
- Add tests for new functionality
- Ensure all tests pass
- Submit a pull request
# Clone the repository
git clone git@github.com:nullodyssey/circuit-breaker.git
cd circuit-breaker
# Start development environment
make start
# Install dependencies
make composer c='install'
# Run tests
make test
# Run quality checks
make qualityThis project is licensed under the MIT License - see the LICENSE file for details.
- Circuit Breaker Pattern by Martin Fowler
- Release It! by Michael Nygard
- Microservices Patterns
Made with β€οΈ by NullOdyssey