in PHP, with no framework dependency
When an external API turns slow or stops responding, every calling request in a typical PHP application just waits for its timeout, while PHP-FPM workers pile up in the background and drag the whole application down with them. The circuit breaker pattern breaks this chain: after a configured number of failures it trips the circuit and answers further calls immediately with a fallback instead of letting them run into the same dead end again.
Table of Contents
- 1. The problem: cascading failures from unstable dependencies
- 2. The three states: closed, open, and half open
- 3. A standalone implementation with no framework dependency
- 4. Transitions in detail: thresholds and timers
- 5. Use case: protecting a failing external API from overload
- 6. Persisting state across requests: Redis as the store
- 7. Avoiding race conditions: atomic operations in Redis
- 8. Monitoring and observability for circuit state
- 9. Common mistakes in practical implementations
- 10. Summary
- 11. FAQ
1. The problem: cascading failures from unstable dependencies
In distributed systems, a PHP application regularly calls external services: payment providers, shipping carriers, price comparison feeds, or internal microservices. When one of those services goes down or turns extremely slow, the calling application does not notice right away, it simply keeps waiting for a response until a timeout eventually fires. In a typical PHP-FPM deployment with a limited pool of worker processes, that means more and more workers end up stuck in waiting requests while new incoming requests keep arriving.
The result is a cascading failure: a single unstable third party dependency is enough to exhaust the entire worker pool and take down completely unrelated features of the application that have nothing to do with the failing service at all. This is exactly the scenario the circuit breaker pattern prevents, by detecting repeated failures against a dependency and refusing to even attempt further calls to it for a while.
2. The three states: closed, open, and half open
A circuit breaker behaves like a finite state machine with exactly three states. In the closed state, everything runs normally: calls pass through to the external service as usual, while the failure rate is tracked in the background. Once the number of consecutive failures crosses a configured threshold, the breaker transitions to the open state.
While open, not a single call reaches the real service anymore, instead every call fails immediately with a clearly identifiable exception or returns a defined fallback value, which relieves pressure on the external service and protects the application from pointless waiting. Once a configured timeout elapses, the breaker moves on its own into the half open state, where exactly one probe call is allowed through: if it succeeds, the breaker returns to closed, if it fails, it falls back to open.
3. A standalone implementation with no framework dependency
A framework-independent implementation only needs a small class for the state machine plus a storage abstraction for the state. The core is a call() method that accepts a callback, checks the current state, and either executes it, rejects it, or lets it through as a probe, depending on that state.
It matters that the class itself makes no assumptions about HTTP clients, databases, or any particular framework, and instead works purely with generic callables. That way the same circuit breaker class can protect HTTP calls, database connections, or message queue calls equally well.
<?php
declare(strict_types=1);
namespace App\Resilience;
enum CircuitState: string
{
case Closed = 'closed';
case Open = 'open';
case HalfOpen = 'half_open';
}
interface CircuitBreakerStore
{
public function getState(string $key): CircuitState;
public function getFailureCount(string $key): int;
public function recordSuccess(string $key): void;
public function recordFailure(string $key): void;
public function transitionTo(string $key, CircuitState $state): void;
public function secondsSinceOpened(string $key): int;
}
final class CircuitOpenException extends \RuntimeException
{
}
final class CircuitBreaker
{
public function __construct(
private readonly string $key,
private readonly CircuitBreakerStore $store,
private readonly int $failureThreshold = 5,
private readonly int $openTimeoutSeconds = 30,
) {
}
/**
* @template T
* @param callable():T $operation
* @return T
*/
public function call(callable $operation)
{
$state = $this->store->getState($this->key);
if ($state === CircuitState::Open) {
if ($this->store->secondsSinceOpened($this->key) < $this->openTimeoutSeconds) {
throw new CircuitOpenException("Circuit '{$this->key}' is open");
}
$this->store->transitionTo($this->key, CircuitState::HalfOpen);
}
try {
$result = $operation();
} catch (\Throwable $e) {
$this->store->recordFailure($this->key);
if ($this->store->getFailureCount($this->key) >= $this->failureThreshold) {
$this->store->transitionTo($this->key, CircuitState::Open);
}
throw $e;
}
$this->store->recordSuccess($this->key);
return $result;
}
}
4. Transitions in detail: thresholds and timers
Two parameters largely determine how sensitively a circuit breaker reacts: the failureThreshold, meaning the number of consecutive failures that trigger a switch from closed to open, and the openTimeout, meaning the time the breaker spends in the open state before it tries again. A threshold set too low opens the breaker on ordinary, brief blips, while a threshold set too high lets far too many failed requests through before anything happens at all.
Inside the half open state, a successThreshold is also worth adding, meaning the number of consecutive successful probe calls required before the breaker returns fully to closed. A single successful probe could simply be luck, while several consecutive successes are a far more reliable signal that the external service has genuinely recovered.
5. Use case: protecting a failing external API from overload
A common real-world example is calling a payment provider during checkout. If the payment provider goes down, thousands of checkout requests should not pointlessly wait on a timeout, and the payment provider should not get bombarded with even more requests during its outage, which would only delay its own recovery. A circuit breaker protects both sides at once in this scenario: the application from blocked workers, and the external service from extra load while it is recovering.
Instead of simply failing the checkout outright, a sensible fallback can kick in once the breaker is open: the order moves into a status such as pending payment and gets reprocessed asynchronously once the service becomes reachable again. That keeps the user experience intact even while a downstream system is having a bad day.
<?php
declare(strict_types=1);
namespace App\Payment;
use App\Resilience\CircuitBreaker;
use App\Resilience\CircuitOpenException;
final class PaymentGatewayClient
{
public function __construct(
private readonly CircuitBreaker $breaker,
private readonly string $endpoint,
) {
}
public function charge(string $orderId, int $amountCents): PaymentResult
{
try {
return $this->breaker->call(function () use ($orderId, $amountCents): PaymentResult {
$response = $this->sendRequest($orderId, $amountCents);
return PaymentResult::fromResponse($response);
});
} catch (CircuitOpenException) {
// Fallback: the order moves into a queued "payment pending"
// state instead of blocking the checkout request on a dead gateway.
return PaymentResult::pendingRetry($orderId);
}
}
private function sendRequest(string $orderId, int $amountCents): array
{
$ch = curl_init($this->endpoint . '/charges');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['order_id' => $orderId, 'amount' => $amountCents]),
CURLOPT_TIMEOUT => 3,
CURLOPT_RETURNTRANSFER => true,
]);
$body = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $httpCode >= 500) {
throw new \RuntimeException("Payment gateway request failed with status {$httpCode}");
}
return json_decode($body, true);
}
}
6. Persisting state across requests: Redis as the store
An in-memory implementation of the state only works within a single request, because PHP-FPM workers typically start each request with an empty memory state. Without a shared, external store, the circuit breaker would reset back to closed on every single request and could never fulfil its actual purpose of detecting repeated failures across many requests.
Redis fits this job well, since it offers very fast, atomic operations and is often already part of the stack as a caching layer. The state, the failure counter, and the timestamp of the last transition are stored per circuit breaker key in a Redis hash with a matching TTL, so every PHP-FPM worker reads and writes the same, consistent state.
<?php
declare(strict_types=1);
namespace App\Resilience;
final class RedisCircuitBreakerStore implements CircuitBreakerStore
{
public function __construct(private readonly \Redis $redis)
{
}
public function getState(string $key): CircuitState
{
$value = $this->redis->hGet($this->redisKey($key), 'state');
return $value !== false ? CircuitState::from($value) : CircuitState::Closed;
}
public function getFailureCount(string $key): int
{
return (int) $this->redis->hGet($this->redisKey($key), 'failures');
}
public function recordSuccess(string $key): void
{
$this->redis->hSet($this->redisKey($key), 'failures', '0');
}
public function recordFailure(string $key): void
{
$this->redis->hIncrBy($this->redisKey($key), 'failures', 1);
}
public function transitionTo(string $key, CircuitState $state): void
{
$redisKey = $this->redisKey($key);
$this->redis->hSet($redisKey, 'state', $state->value);
$this->redis->hSet($redisKey, 'opened_at', (string) time());
$this->redis->expire($redisKey, 3600);
}
public function secondsSinceOpened(string $key): int
{
$openedAt = (int) $this->redis->hGet($this->redisKey($key), 'opened_at');
return $openedAt === 0 ? PHP_INT_MAX : time() - $openedAt;
}
private function redisKey(string $key): string
{
return "circuit_breaker:{$key}";
}
}
7. Avoiding race conditions: atomic operations in Redis
Once several PHP-FPM workers detect the same failure at roughly the same time, they can end up trying to increment the failure counter and switch state in parallel. A naive pattern of reading the counter, incrementing it in PHP, and writing it back is vulnerable to a race condition: two workers read the same old counter value, both increment it locally by one, and one of the two writes gets lost, leaving the counter wrongly too low.
The fix lies in atomic Redis operations such as HINCRBY, which reads and increments in a single, uninterruptible step, or a small Lua script for more complex transitions that need to bundle several Redis commands atomically. Redis always runs a Lua script to completion before accepting the next command from another client, which makes the whole transition safe against concurrent access.
<?php
declare(strict_types=1);
// Lua script executed atomically inside Redis: increments the failure
// counter and returns the new value in a single, uninterruptible step,
// instead of a PHP-side "read, add one, write" sequence that two
// concurrent PHP-FPM workers could interleave and corrupt.
$script = <<<'LUA'
local key = KEYS[1]
local count = redis.call('HINCRBY', key, 'failures', 1)
redis.call('EXPIRE', key, 3600)
return count
LUA;
$newFailureCount = $redis->eval($script, ['circuit_breaker:payment_gateway'], 1);
8. Monitoring and observability for circuit state
A circuit breaker that quietly stays open turns a short outage into a long-lasting loss of functionality that nobody notices. Every state transition should therefore be logged in a structured way, including a timestamp, the breaker key, the old and new state, and the underlying error, so an outage can be reconstructed precisely after the fact.
Beyond logging, dedicated metrics captured by a monitoring system like Prometheus or an APM tool pay off: the current duration spent in the open state, how often transitions happen over a given period, and the success rate of probe calls while half open. An alert that fires once a breaker has stayed open longer than a defined threshold turns a silent outage into a visible, actionable event for the operations team.
9. Common mistakes in practical implementations
A widespread mistake is a threshold configured too aggressively, which opens the breaker on completely ordinary, isolated network blips and unnecessarily blocks an otherwise healthy service. Just as problematic is treating every kind of error the same way: a 4xx error caused by an invalid request says nothing about the availability of the service and should not increment the failure counter, while timeouts and 5xx errors clearly should.
Another common mistake is missing a limit on concurrent probe calls in the half open state: without that limit, several simultaneous requests can all be let through as probes at once and overload a service that is only just starting to recover. Finally, teams often use a single, global breaker for all calls to an external provider, even though different endpoints of the same provider can have very different stability and deserve their own breaker instances.
| Parameter | Meaning | Typical starting value | Effect of a wrong setting |
|---|---|---|---|
| failureThreshold | Failures before switching to open | 5 | Too low: breaker opens on brief blips |
| openTimeout | Time spent in the open state | 30 seconds | Too short: service gets hit again while still recovering |
| successThreshold | Successes needed to return to closed | 2 | Too low: quick relapse into open on the next failure |
| Half-open limit | Concurrent probe calls while half open | 1 | Too high: probe phase overloads the recovering service again |
| Storage location | Where the state is kept | Redis with a TTL | In-memory: state is lost on every single request |
Mironsoft
PHP modernization, code quality, and legacy refactoring
Grown PHP code nobody wants to touch anymore?
We modernize PHP codebases to current language standards, introduce static analysis and coding standards, and refactor legacy code step by step without endangering live operations.
Legacy Refactoring
Modernize grown PHP code in a structured, low-risk way.
Establishing Code Quality
Anchor PHPStan, coding standards, and CI checks sustainably in the team.
Version Upgrades
Plan and execute PHP major version upgrades safely, without downtime.
10. Summary
Circuit Breaker Pattern in PHP: The Essentials
State machine
Three clearly defined states: closed for normal operation, open for immediate rejection, half open for cautious probe calls.
Framework independent
A slim class built around generic callables protects HTTP clients, databases, and message queues equally well.
Persistence
Redis stores state and failure counters across individual PHP-FPM requests, atomic operations prevent race conditions.
Observability
Every transition is logged, metrics and alerts make outages visible before customers report them.