PHPackages                             matheus85/duat - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. matheus85/duat

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

matheus85/duat
==============

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

v0.2.0(1mo ago)010MITPHPPHP &gt;=8.3CI passing

Since Jul 3Pushed 1mo agoCompare

[ Source](https://github.com/matheus85/duat)[ Packagist](https://packagist.org/packages/matheus85/duat)[ RSS](/packages/matheus85-duat/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (2)Dependencies (6)Versions (3)Used By (0)

Duat
====

[](#duat)

[![CI](https://github.com/matheus85/duat/actions/workflows/ci.yml/badge.svg)](https://github.com/matheus85/duat/actions/workflows/ci.yml)[![Packagist](https://camo.githubusercontent.com/faa78e9f51c69613931ada97be7ae81f17f757525dfae6426ea238dae7870612/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6d61746865757338352f64756174)](https://packagist.org/packages/matheus85/duat)[![PHP](https://camo.githubusercontent.com/6508ac9c52fd55299933b9dacb73435dab1a474b6e2046f5eef2c2013c4e4bf8/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d382e332532422d373737626234)](https://camo.githubusercontent.com/6508ac9c52fd55299933b9dacb73435dab1a474b6e2046f5eef2c2013c4e4bf8/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d382e332532422d373737626234)[![License](https://camo.githubusercontent.com/f8df3091bbe1149f398a5369b2c39e896766f9f6efba3477c63e9b4aa940ef14/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d677265656e)](https://camo.githubusercontent.com/f8df3091bbe1149f398a5369b2c39e896766f9f6efba3477c63e9b4aa940ef14/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d677265656e)

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?
--------------------

[](#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.

DuatGaneshaPrestaShop/circuit-breakerRetry with backoffyesnonoCircuit breakeryesyesyesTimeout (deadline)yesnoper HTTP clientFallbackyesnoyesBulkheadyesnonoRate limiteryesnonoPHP 8 attributesyesnonoRequired dependenciesnonenoneHTTP clientIf 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
-------

[](#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
------------

[](#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

[](#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
----------

[](#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

[](#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
--------

[](#policies)

### Retry

[](#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

[](#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-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)

```
->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)

```
->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

[](#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
------------

[](#shared-state)

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

StoreBackendAtomicity`InMemoryStore`process arraysingle process only, default`Psr16Store`any PSR-16 cacheread-modify-write, benign races documented`RedisStore`ext-redis or Predisatomic (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
------

[](#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
-------------

[](#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
---------------------

[](#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
--------

[](#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
-------

[](#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
-------

[](#license)

MIT.

###  Health Score

37

—

LowBetter than 81% of packages

Maintenance90

Actively maintained with recent releases

Popularity5

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity40

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

2

Last Release

48d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/25907363?v=4)[Matheus Marques](/maintainers/matheus85)[@matheus85](https://github.com/matheus85)

---

Top Contributors

[![matheus85](https://avatars.githubusercontent.com/u/25907363?v=4)](https://github.com/matheus85 "matheus85 (40 commits)")

---

Tags

bulkheadcircuit-breakerfallbackfault-tolerancephprate-limiterresilienceresilience4jretrytimeoutrate-limitertimeoutretryfallbackcircuit breakerbackoffresiliencebulkhead

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/matheus85-duat/health.svg)

```
[![Health](https://phpackages.com/badges/matheus85-duat/health.svg)](https://phpackages.com/packages/matheus85-duat)
```

###  Alternatives

[symfony/rate-limiter

Provides a Token Bucket implementation to rate limit input and output in your application

26957.2M355](/packages/symfony-rate-limiter)[eventsauce/backoff

Back-off strategy interface

70865.3k8](/packages/eventsauce-backoff)[florianv/exchanger

PHP exchange rate provider layer for currency conversion: 30+ services, chain fallback, and caching.

1865.1M20](/packages/florianv-exchanger)[ejsmont-artur/php-circuit-breaker

PHP Circuit Breaker component

1681.0M4](/packages/ejsmont-artur-php-circuit-breaker)[vkartaviy/retry

The library for repeatable and retryable operations

29232.7k2](/packages/vkartaviy-retry)[yriveiro/php-backoff

Simple backoff / retry functionality

2682.8k1](/packages/yriveiro-php-backoff)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
