Skip to content
All posts

Building the Circuit Breaker Pattern Your Laravel App Is Missing

June 21, 2026·Read on Medium·

When your third-party API goes silent, your queue workers shouldn’t keep hammering it.

Your payment gateway goes down at 5 PM on a Tuesday. Not your fault, not your call. The provider’s status page turns red and the incident timer starts ticking.

Here is what happens next, without a circuit breaker.

Your Laravel queue workers pick up pending charge jobs. Each one calls the payment API. Each one waits 30 seconds for a timeout that never resolves. Each one fails, gets pushed back onto the queue with a delay, and rejoins the pile. By the time the gateway recovers an hour later, you have thousands of jobs backed up, a delay queue stretched well past midnight, and legitimate new orders sitting behind a wall of stale retries from the outage window.

The queue is not broken. It did exactly what it was told. Nobody thought to tell it when to stop.

The circuit breaker pattern solves this. It is one of the cleaner pieces of resilience architecture you can add to a production system, and its implementation in Laravel takes less code than most people expect.

What a Circuit Breaker Actually Does

The name comes from Michael Nygard’s book Release It!, which documented the pattern as a way to prevent cascading failures in distributed systems. Martin Fowler described it cleanly on his bliki: wrap a protected call in a circuit breaker object that monitors failures and short-circuits the call when something is clearly wrong.

The circuit does not retry smarter. It stops calling.

When the failure rate crosses a threshold, the circuit opens. Calls fail immediately, without touching the external service. After a configured wait, the circuit moves to a half-open state and allows a small number of probe calls through. If those succeed, the circuit closes and normal operation resumes. If they fail, the circuit opens again.

Three states:

Closed

  • Normal operation. Calls go through to the external service.
  • Failure count is tracked within a rolling time window.
  • When failures hit the threshold within the window, the circuit trips open.

Open

  • The circuit is tripped. All calls fail immediately without contacting the service.
  • The circuit records the time it opened.
  • After a configurable timeout, it moves to half-open.

Half-Open

  • The circuit allows a limited number of probe calls through.
  • If enough probes succeed, the circuit closes and failure counters reset.
  • One failed probe sends it back to open. The wait timer resets.

This is the entire state machine. Implementing it in Laravel is a question of what you use for state storage and how precisely you classify failures.

Redis as the State Store

You could store circuit state in the database, but you do not want to. The whole point of a circuit breaker is to fail fast. Adding a DB query to the critical path of a fast-fail check defeats that. Redis is the natural choice: it handles atomic operations, TTL-based expiry, and sub-millisecond reads without ceremony.

Laravel’s Redis facade exposes everything you need. The implementation below uses three Redis keys per service:

  • circuit_breaker:{service}:state: the current state string
  • circuit_breaker:{service}:failures: the failure counter, with a TTL matching the time window
  • circuit_breaker:{service}:opened_at: the Unix timestamp when the circuit opened
<?php

namespace App\Services\Resilience;
use Illuminate\Support\Facades\Redis;

class CircuitBreaker
{
const STATE_CLOSED = 'closed';
const STATE_OPEN = 'open';
const STATE_HALF_OPEN = 'half_open';
public function __construct(
private readonly string $service,
private readonly int $failureThreshold = 5,
private readonly int $timeWindow = 60,
private readonly int $openTimeout = 30,
private readonly int $halfOpenSuccesses = 2,
)
{}

public function isAvailable(): bool
{
$state = $this->getState();
if ($state === self::STATE_CLOSED) {
return true;
}
if ($state === self::STATE_OPEN) {
$openedAt = (int) Redis::get($this->key('opened_at'));
if ($openedAt && (time() - $openedAt) >= $this->openTimeout) {
Redis::set($this->key('state'), self::STATE_HALF_OPEN);
return true;
}
return false;
}
return true;
}

public function recordSuccess(): void
{
if ($this->getState() !== self::STATE_HALF_OPEN) {
return;
}
$successes = (int) Redis::incr($this->key('half_open_successes'));
if ($successes >= $this->halfOpenSuccesses) {
$this->close();
}
}

public function recordFailure(): void
{
$state = $this->getState();
if ($state === self::STATE_HALF_OPEN) {
$this->open();
return;
}
if ($state === self::STATE_CLOSED) {
$failures = (int) Redis::incr($this->key('failures'));
Redis::expire($this->key('failures'), $this->timeWindow);
if ($failures >= $this->failureThreshold) {
$this->open();
}
}
}

private function open(): void
{
Redis::set($this->key('state'), self::STATE_OPEN);
Redis::set($this->key('opened_at'), time());
Redis::del($this->key('failures'));
Redis::del($this->key('half_open_successes'));
}

private function close(): void
{
Redis::set($this->key('state'), self::STATE_CLOSED);
Redis::del($this->key('failures'));
Redis::del($this->key('opened_at'));
Redis::del($this->key('half_open_successes'));
}

private function getState(): string
{
return Redis::get($this->key('state')) ?? self::STATE_CLOSED;
}

private function key(string $suffix): string
{
return "circuit_breaker:{$this->service}:{$suffix}";
}
}

The INCR command on the failures key is atomic. You do not need locks around it. The EXPIRE call sets a TTL on that counter so old failures age out naturally. After 60 seconds, the slate clears unless the threshold was already tripped. This is what people mean by a "rolling window": failures do not accumulate indefinitely.

Registering in Your Service Container

You probably want one circuit breaker instance per external service, not a new one created on every new PaymentService() call. If the instance is recreated each request, the Redis keys still hold the correct state, so the behavior stays consistent. The object itself is cheap. Still, registering as a scoped binding keeps the intent clear:

<?php

namespace App\Providers;
use App\Services\Resilience\CircuitBreaker;
use App\Services\PaymentService;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton('circuit-breaker.stripe', fn () => new CircuitBreaker(
service: 'stripe',
failureThreshold: 5,
timeWindow: 60,
openTimeout: 30,
halfOpenSuccesses: 2,
));
$this->app->singleton(PaymentService::class, function ($app) {
return new PaymentService(
$app->make('circuit-breaker.stripe')
);
});
}
}

Update PaymentService to accept the circuit breaker through its constructor rather than creating it inline. The practical effect is that any test that boots the application container can swap in a fake circuit breaker with a known state, which makes testing the "circuit is open" path a lot less awkward than setting Redis keys manually.

Testing the closed and open paths in isolation:

<?php

namespace Tests\Unit;
use App\Exceptions\CircuitOpenException;
use App\Services\Resilience\CircuitBreaker;
use App\Services\PaymentService;
use Tests\TestCase;
class PaymentServiceTest extends TestCase
{
public function test_charge_throws_when_circuit_is_open(): void
{
// Force the circuit open by recording failures past the threshold
$circuit = new CircuitBreaker(
service: 'stripe-test',
failureThreshold: 1,
timeWindow: 60,
);
$circuit->recordFailure();
$service = new PaymentService($circuit);
$this->expectException(CircuitOpenException::class);
$service->charge('pm_test_abc', 1000);
}
}

Setting failureThreshold: 1 means one recorded failure trips the circuit, which gives you a predictable way to test the open-circuit path without sleeping through a timeout or touching shared Redis state.

Wrapping a Real Service Call

The circuit breaker class does nothing on its own. You need to wrap it around the actual service call. Here is what that looks like for a payment gateway:

<?php

namespace App\Services;
use App\Exceptions\CircuitOpenException;
use App\Services\Resilience\CircuitBreaker;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\RequestException;
class PaymentService
{
private CircuitBreaker $circuit;

public function __construct()
{
$this->circuit = new CircuitBreaker(
service: 'stripe',
failureThreshold: 5,
timeWindow: 60,
openTimeout: 30,
halfOpenSuccesses: 2,
);
}

public function charge(string $paymentMethodId, int $amountCents): array
{
if (! $this->circuit->isAvailable()) {
throw new CircuitOpenException(
'Payment service temporarily unavailable. The circuit is open.'
);
}
try {
$response = Http::withToken(config('services.stripe.secret'))
->timeout(10)
->post('https://api.stripe.com/v1/charges', [
'amount' => $amountCents,
'currency' => 'usd',
'payment_method' => $paymentMethodId,
]);
if ($response->serverError()) {
$this->circuit->recordFailure();
$response->throw();
}
// 429 and 4xx auth errors mean the service is reachable, do not trip the circuit
$this->circuit->recordSuccess();
return $response->json();
} catch (RequestException $e) {
if ($this->isConnectivityFailure($e)) {
$this->circuit->recordFailure();
}
throw $e;
}
}

private function isConnectivityFailure(\Throwable $e): bool
{
return $e->getCode() >= 500 || str_contains($e->getMessage(), 'cURL');
}
}

The failure classification check at the bottom matters more than most tutorials acknowledge.

A 429 (rate limited) means Stripe is alive and talking to you. It is telling you to slow down. Treating that as a circuit-tripping failure is counterproductive. You would open the circuit in response to a problem the circuit cannot fix. Same with 401 and 403 responses: authentication failures are a configuration problem, not a service availability problem. Only 5xx responses and connection errors should count against the circuit.

Laravel Fuse, the circuit breaker package announced in February 2026, handles this distinction natively. It classifies 429s and auth errors as non-tripping failures by design. If you are building for a queue-heavy application and do not want to maintain the state logic yourself, it is worth reviewing. The source is at github.com/harris21/laravel-fuse.

Connecting It to Queue Jobs

The queue is where the circuit breaker earns its place. Without it, a failed external service causes job retries to pile up. With it, jobs that arrive while the circuit is open can detect the situation quickly and release themselves back to the queue without consuming a retry.

<?php

namespace App\Jobs;
use App\Exceptions\CircuitOpenException;
use App\Services\PaymentService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
class ProcessChargeJob implements ShouldQueue
{
use InteractsWithQueue, Queueable;
public int $tries = 3;
public int $backoff = 15;
public function __construct(
private readonly string $paymentMethodId,
private readonly int $amountCents,
)
{}
public function handle(PaymentService $payment): void
{
try {
$payment->charge($this->paymentMethodId, $this->amountCents);
} catch (CircuitOpenException) {
// Circuit is open: release back to queue without burning a retry
$this->release(30);
}
}
}

The release(30) call puts the job back on the queue with a 30-second delay. It does not count as a failed attempt against $tries. The job waits, and if the circuit closes before the next pickup, the charge goes through normally. If the circuit is still open, the job releases again.

This is cleaner than letting the job exhaust its retry budget on a service that is known to be down. Retries exist for transient errors, not for coordinated outages.

Tuning the Thresholds

The default values in the examples above are a starting point. What they actually mean in production:

Failure threshold (default: 5) Five failures within 60 seconds before the circuit opens. On a low-traffic service, five failures might mean a genuine outage. On a high-traffic service, five failures per minute could be normal variance. Set this relative to your request volume, not as an absolute number.

Time window (default: 60 seconds) The window during which failures are counted. A shorter window reacts faster but generates more false trips. Start at 60 seconds. If you are seeing the circuit trip on normal error variance, widen it.

Open timeout (default: 30 seconds) How long the circuit stays open before testing with a half-open probe. Thirty seconds is aggressive. For services with known recovery SLAs, set this closer to the documented incident response time. A Stripe outage that takes 10 minutes to resolve does not benefit from probing every 30 seconds.

Half-open successes (default: 2) The number of consecutive successful probes needed to close the circuit. Two is conservative enough for most cases. Higher values mean you wait longer before resuming normal traffic, which reduces the risk of re-tripping immediately after a partial recovery.

These four numbers interact. A system with failureThreshold: 3 and timeWindow: 10 will trip the circuit very aggressively on brief error spikes. A system with failureThreshold: 20 and timeWindow: 300 will absorb significant failures before acting. Neither is universally correct.

The useful question is: what is the cost of a false trip (blocking legitimate traffic) versus the cost of a missed trip (hammering a failing service)? For payment processing, a false trip is painful but recoverable. For a notification service, tolerating more false trips to avoid the queue pile-up problem might be the right call.

What to Put on Your Dashboard

A circuit breaker you cannot observe is less useful than it looks. Add a log entry at every state transition:

private function open(): void
{
Redis::set($this->key('state'), self::STATE_OPEN);
Redis::set($this->key('opened_at'), time());
Redis::del($this->key('failures'));
Redis::del($this->key('half_open_successes'));

logger()->warning("Circuit opened for service: {$this->service}", [ 'threshold' => $this->failureThreshold, 'window' => $this->timeWindow, 'opened_at' => now()->toIso8601String(), ]);
}

private function close(): void
{
// ... existing close logic ...
logger()->info("Circuit closed for service: {$this->service}", [ 'closed_at' => now()->toIso8601String(), ]);
}

That gives you a log trail for every open/close event. Push it to whatever you use for observability: Datadog, CloudWatch, a Slack alert.

For the Redis keys themselves, a simple artisan command that reads the current state for each registered service is enough for operational visibility:

// In a console command handle() method
$services = ['stripe', 'mailgun', 'sms-gateway'];

foreach ($services as $service) {
$state = Redis::get("circuit_breaker:{$service}:state") ?? 'closed';
$this->line("{$service}: {$state}");
}

Nothing fancy. You want to know which circuits are currently open before an engineer spends 20 minutes diagnosing why payments are failing.

The Frontend Side of a Tripped Circuit

When the payment circuit opens, your API needs to return something the client can use. A 503 Service Unavailable with a Retry-After header gives a JavaScript client enough information to display a meaningful message and schedule a retry.

// API client handling a circuit-open response
async function submitPayment(payload: PaymentPayload): Promise<PaymentResult> {
const response = await fetch('/api/payments', {
method: 'POST',
body: JSON.stringify(payload),
});


if (response.status === 503) {
const retryAfter = response.headers.get('Retry-After') ?? '30';
throw new ServiceUnavailableError(
`Payment service temporarily unavailable. Retry in ${retryAfter}s.`
);
}
return response.json();
}

The Retry-After value can come directly from openTimeout in your circuit breaker config, passed through to the HTTP response headers. The client shows a reasonable message. Nobody gets a blank failure screen or a spinning loader for 30 seconds.

Build vs Package

You have two choices: maintain the 60-ish lines above yourself, or use something like Laravel Fuse for queue jobs.

Build your own when:

  1. You need circuit breaking across both HTTP clients and queue jobs in a unified way
  2. You want precise control over failure classification logic
  3. You are already using Redis and want minimal additional dependencies

Use a package when:

  1. You need queue-specific features like peak hours configuration
  2. You want a built-in status page without building one
  3. You need the circuit state exposed to queue worker configuration decisions

The implementation above is simple enough that neither choice is obviously wrong. The real mistake is shipping to production without either.

Closing

The circuit breaker is not the most glamorous piece of architecture, and most Laravel applications do not need it until the first time an outage makes the problem visible. At that point, the queue has 8,000 backed-up jobs and the incident channel is loud.

The pattern is three states, four Redis keys, and one rule about failure classification. The rule is the part that actually matters: know which failures mean the service is down versus which failures mean you are doing something wrong. A 429 is not a down signal. A connection timeout is.

Building it before you need it takes an afternoon. Building it during an incident takes longer and the result will have cut corners you do not discover until the next outage.

Found this helpful?

If this article saved you time or solved a problem, consider supporting — it helps keep the writing going.

Originally published on Medium.

View on Medium
Building the Circuit Breaker Pattern Your Laravel App Is Missing — Hafiq Iqmal — Hafiq Iqmal