Unified resilience patterns for modern PHP: retry, circuit breaker, timeout, bulkhead, rate limiter and fallback. Zero required dependencies.

Maintainers

Package info

github.com/matheus85/duat

pkg:composer/matheus85/duat

Transparency log

Statistics

Installs: 10

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.0 2026-07-04 04:13 UTC

This package is auto-updated.

Last update: 2026-07-04 05:29:54 UTC


README

CI Packagist PHP License

Unified resilience patterns for modern PHP: retry, circuit breaker, timeout, bulkhead, rate limiter and fallback behind one fluent API or PHP 8 attributes. Zero required dependencies, no framework coupling, no HTTP client coupling. Duat wraps callables, nothing else.

use Duat\Duat;

$user = Duat::for('users-api')
    ->retry(maxAttempts: 3)
    ->fallback(fn () => ['name' => 'cached user', 'source' => 'fallback'])
    ->call(fn () => json_decode(file_get_contents('https://jsonplaceholder.typicode.com/users/1'), true));

In Egyptian mythology the Duat is the underworld the sun crosses every night, fighting through the dark to be reborn at dawn. Failure, crossing, recovery, in an endless cycle. That is the exact life of a circuit breaker (closed, open, half-open, closed again), so the name stuck.

Why another library?

PHP never got its Resilience4j. Java has it, .NET has Polly, Node has cockatiel. Here the landscape is fragmented: Ganesha does circuit breaking well, PrestaShop's circuit breaker is tied to specific HTTP clients, and retry logic usually ends up as a hand-rolled loop with sleep() calls spread across the codebase. I wanted the whole toolbox in one place, framework agnostic and fully testable without touching a real clock, so I built it.

Duat Ganesha PrestaShop/circuit-breaker
Retry with backoff yes no no
Circuit breaker yes yes yes
Timeout (deadline) yes no per HTTP client
Fallback yes no yes
Bulkhead yes no no
Rate limiter yes no no
PHP 8 attributes yes no no
Required dependencies none none HTTP client

If you come from Java think Resilience4j, if you come from .NET think Polly. That toolbox, in PHP: every pattern above is shipped and tested.

Install

composer require matheus85/duat

PHP 8.3 or newer, nothing else required. Optional: any PSR-16 cache or Redis for shared state, any PSR-14 dispatcher for events.

The pipeline

Policies compose like middleware around your callable. Method order defines the nesting, outermost first, and fallback() always sits at the very outside no matter where you declare it:

use Duat\Backoff\Backoff;
use Duat\Duat;
use Duat\Store\RedisStore;

$result = Duat::for('sefaz-nfe')
    ->retry(maxAttempts: 3, backoff: Backoff::exponential(baseMs: 200, capMs: 10_000))
    ->circuitBreaker(failureRateThreshold: 0.5, minimumCalls: 10)
    ->timeout(seconds: 5.0)
    ->fallback(fn (Throwable $e) => ReceiptStatus::unavailable())
    ->store(new RedisStore($redis))
    ->call(fn () => $sefaz->queryReceipt($key));

Builders are immutable: every method returns a new instance, so you can configure a chain once, inject it anywhere and reuse it freely.

Order matters

retry()->circuitBreaker() retries around the breaker. When the circuit opens, the rejection stops the retry loop immediately: Duat never retries CircuitOpenException, because hammering an open circuit defeats its purpose. circuitBreaker()->retry() puts the whole retry burst inside a single breaker call instead, so one exhausted retry counts as one failure in the window. Both arrangements are legitimate, pick one consciously.

Attributes

Prefer declaring resilience where the method lives? Annotate it and wrap the instance:

use Duat\Attributes\CircuitBreaker;
use Duat\Attributes\Fallback;
use Duat\Attributes\Retry;
use Duat\Proxy\ProxyFactory;

final class PaymentGateway
{
    #[Retry(maxAttempts: 3, backoffMs: 200)]
    #[CircuitBreaker(failureRateThreshold: 0.5, cooldownSeconds: 30)]
    #[Fallback(method: 'queueForLater')]
    public function charge(Order $order): Receipt
    {
        // talk to the acquirer
    }

    public function queueForLater(Order $order, Throwable $e): Receipt
    {
        // same arguments, plus the exception
    }
}

$factory = new ProxyFactory(store: new RedisStore($redis));
$gateway = $factory->wrap(new PaymentGateway());

$gateway->charge($order);

Attribute order defines the pipeline order and #[Fallback] is always the outermost layer, exactly like the fluent builder. #[Timeout], #[Bulkhead] and #[RateLimiter] work the same way. Attribute arguments only accept constant expressions, so backoff is configured with scalars (backoffMs, capMs, jitter and backoff: BackoffType::Linear). Shared state is keyed by Class::method; keep a single factory per process so every proxy shares one store.

The proxy trade-off, upfront

wrap() returns a composition proxy built on __call, and that has two consequences you should know before choosing attributes:

  • The proxy is not an instanceof of your class, so it cannot be passed where the original class or one of its interfaces is type-hinted.
  • Static analysis and IDE autocompletion lose the method signatures: calls go through a magic method and return mixed.

When either of those matters, use the fluent API: same engine, full typing. A generated-proxy mode (a real subclass) may come later if the __call approach proves limiting in practice.

Policies

Retry

->retry(
    maxAttempts: 4,
    backoff: Backoff::exponential(baseMs: 200, capMs: 10_000, jitter: true),
    retryOn: [TransportException::class],
    abortOn: [AuthException::class],
    onRetry: fn (Throwable $e, Context $ctx) => $log->warning("attempt {$ctx->attempt} failed"),
)

Backoffs: Backoff::constant(), Backoff::linear() and Backoff::exponential() with cap and full jitter (the AWS flavor: the delay is drawn uniformly from zero to the doubling base). The default is exponential, 200ms base, 10s cap, jitter on.

abortOn wins over retryOn. Exhaustion throws RetryExhaustedException carrying the last failure as previous. And when composed with timeout(), retry gives up as soon as the next wait would not fit the remaining budget, rethrowing the real failure instead of sleeping past the deadline.

Circuit breaker

->circuitBreaker(
    failureRateThreshold: 0.5, // opens at 50% failures...
    minimumCalls: 10,          // ...once the window holds 10 calls
    windowSeconds: 60,         // time-based sliding window
    cooldownSeconds: 30,       // time in OPEN before probing
    halfOpenMaxCalls: 1,       // probes allowed in HALF_OPEN
    recordOn: [Throwable::class],
)

The window is a set of per-second buckets in the state store, updated with atomic increments, so multiple PHP-FPM workers share one view of the resource. Each resource name gets its own circuit. Transitions emit events (see below) and rejections throw CircuitOpenException.

Timeout, honestly

->timeout(seconds: 5.0)                           // report late successes
->timeout(seconds: 5.0, throwOnLateSuccess: true) // punish them

What timeout() does not do: synchronous PHP cannot interrupt a blocking call, and Duat refuses to pretend otherwise (no pcntl tricks). The policy registers a deadline in the context, inner layers read it through Context::remainingBudget(), and when the callable comes back late you either get the result plus a DeadlineExceeded event (default) or a TimeoutExceededException (strict mode).

Real cancellation belongs in the client. Set your cURL, Guzzle or stream timeouts, and feed them from the context if you want a single budget across retries.

Fallback

->fallback(
    fn (Throwable $e, Context $ctx) => Status::degraded(),
    on: [CircuitOpenException::class, RetryExhaustedException::class],
)

Always the outermost layer, so it catches failures from every policy and from the callable itself. on filters which exceptions trigger it; the rest propagate untouched.

Bulkhead

->bulkhead(maxConcurrent: 25, leaseSeconds: 60)

Caps how many calls run at the same time against the resource, across all workers sharing the store. When full it throws BulkheadFullException immediately: no queue, because parking a synchronous PHP worker to wait for a slot just moves the pile-up somewhere worse.

The active-call counter takes a safety lease when created, so slots leaked by a process that died mid-call heal themselves after leaseSeconds. Keep the lease comfortably above your slowest expected call.

Rate limiter

->rateLimiter(maxCalls: 100, perSeconds: 60)

Fixed window: one shared counter per window, so the cap holds across every worker on the store. Exceeding it throws RateLimitExceededException carrying retryAfterSeconds until the next window opens. Rejected attempts count too.

One honest caveat: fixed windows allow a burst of up to twice the limit right around a window boundary. Cheap and predictable; if that ever hurts your use case, open an issue and a sliding variant gets prioritized.

Shared state

Circuit breaker, bulkhead and rate limiter state has to live somewhere. Pick a store:

Store Backend Atomicity
InMemoryStore process array single process only, default
Psr16Store any PSR-16 cache read-modify-write, benign races documented
RedisStore ext-redis or Predis atomic (Lua increments, SET NX leases)

The default InMemoryStore does not cross PHP-FPM workers: each worker would run its own circuit, count its own bulkhead slots and enforce its own rate limit. Fine for CLI tools, queue workers and tests, but production web workloads want ->store(new RedisStore($client)).

Events

Pass any PSR-14 dispatcher with ->events($dispatcher) and Duat emits readonly event objects: RetryAttempted, CircuitOpened, CircuitHalfOpened, CircuitClosed, CallRejected, DeadlineExceeded and FallbackExecuted. CallRejected carries a RejectionReason enum telling whether the circuit was open, the bulkhead was full or the rate limit was hit. No dispatcher, no events, no overhead. The proxy factory takes the same dispatcher in its constructor.

Watch it live

examples/flaky-api ships a dockerized API that alternates health and failure every 15 seconds, plus a demo script that crosses it with retry, circuit breaker, timeout and fallback while printing every event:

cd examples/flaky-api
docker compose up -d
php demo.php

You will see retries, the circuit opening at the failure threshold, fast rejections with fallback answers, probes during cooldown and the circuit closing once the API recovers.

examples/attributes tells the same story through an annotated payment gateway, failures simulated in-process, no docker required.

Testing your own code

Time and randomness are injectable everywhere. Implement the two tiny interfaces Duat\Contract\Clock and Duat\Contract\Randomizer, pass them with ->clock() and ->randomizer(), and your resilience tests run in milliseconds with zero real sleeps. Duat's own unit suite works exactly like that and finishes in a fraction of a second.

Overhead

Resilience is not free, but it is cheap. On the development machine (PHP 8.4, Windows), a successful call through the full chain (retry, circuit breaker, timeout and fallback over the in-memory store) costs about 4 microseconds on top of the bare callable, and the attribute proxy sits around 3. Failure paths cost more by design: they hit the window, sleep through backoffs and walk the fallback. Run php benchmarks/overhead.php for numbers on your hardware.

Roadmap

Next up is a first-class Laravel bridge as a separate package (matheus85/duat-laravel): service provider, cache-backed stores, HTTP client macro, native events.

License

MIT.