PHPackages                             rasuvaeff/bulkhead - 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. [Caching](/categories/caching)
4. /
5. rasuvaeff/bulkhead

ActiveLibrary[Caching](/categories/caching)

rasuvaeff/bulkhead
==================

Cross-process concurrency limiter (bulkhead) for PHP-FPM, backed by Redis or APCu

v1.1.1(3w ago)0204[1 PRs](https://github.com/rasuvaeff/bulkhead/pulls)1BSD-3-ClausePHPPHP 8.3 - 8.5CI passing

Since Jul 3Pushed 1mo agoCompare

[ Source](https://github.com/rasuvaeff/bulkhead)[ Packagist](https://packagist.org/packages/rasuvaeff/bulkhead)[ Docs](https://github.com/rasuvaeff/bulkhead)[ RSS](/packages/rasuvaeff-bulkhead/feed)WikiDiscussions master Synced 1w ago

READMEChangelogDependencies (24)Versions (7)Used By (1)

rasuvaeff/bulkhead
==================

[](#rasuvaeffbulkhead)

[![Latest Stable Version](https://camo.githubusercontent.com/b67c3d0947675a628b274351f32c77b0a22237a4af4230f8f9f0df58d4744a15/68747470733a2f2f706f7365722e707567782e6f72672f7261737576616566662f62756c6b686561642f76)](https://packagist.org/packages/rasuvaeff/bulkhead)[![Total Downloads](https://camo.githubusercontent.com/8cd190f537ca4e6a0dd5d457594eabc6ca256f2a6b9b7a87600be76dd43851a7/68747470733a2f2f706f7365722e707567782e6f72672f7261737576616566662f62756c6b686561642f646f776e6c6f616473)](https://packagist.org/packages/rasuvaeff/bulkhead)[![Build](https://github.com/rasuvaeff/bulkhead/actions/workflows/build.yml/badge.svg)](https://github.com/rasuvaeff/bulkhead/actions/workflows/build.yml)[![Static analysis](https://github.com/rasuvaeff/bulkhead/actions/workflows/static-analysis.yml/badge.svg)](https://github.com/rasuvaeff/bulkhead/actions/workflows/static-analysis.yml)[![Psalm level](https://camo.githubusercontent.com/68f7f31799f2b93c710b14ba3877072e7fe07ec9d7cee3fdf67e14beab3e1b6f/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7073616c6d2d6c6576656c5f312d626c75652e737667)](https://github.com/rasuvaeff/bulkhead/actions/workflows/static-analysis.yml)[![PHP](https://camo.githubusercontent.com/cff5abdb4f9e3a7680a1c94b7ec545ca8c4d9b12fa05c41cc03862439b8302c4/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f646570656e64656e63792d762f7261737576616566662f62756c6b686561642f706870)](https://packagist.org/packages/rasuvaeff/bulkhead)[![License](https://camo.githubusercontent.com/6cb285b57819f8de0acfb34923298f4f569f962544e8fe35331da2d163f4e485/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4253442d2d332d2d436c617573652d626c75652e737667)](LICENSE.md)

Cross-process concurrency limiter (bulkhead) for PHP-FPM. Caps the number of **simultaneous** calls to a fragile dependency across the **whole worker pool**, so a spike can't pile every worker onto a downstream that only tolerates a few connections. Over the limit, calls fast-fail (or wait briefly) instead of cascading the failure.

A counter shared in Redis or APCu is the coordination point: in shared-nothing FPM the limit has to live outside the process, because each request runs in its own worker. Complements a circuit breaker (which decides *whether* to try) — a bulkhead decides *how many at once*.

> Using an AI coding assistant? [llms.txt](llms.txt) contains a compact API reference you can share with the model.

Requirements
------------

[](#requirements)

- PHP 8.3+
- [`rasuvaeff/duration`](https://github.com/rasuvaeff/duration) for the typed lease/wait values
- For multi-host cross-process limiting (`RedisBulkheadStore`): a reachable Redis server plus **one** Redis client — [`predis/predis`](https://github.com/predis/predis)^2.2 (pure-PHP, `PredisScriptRunner`) or `ext-redis` (`PhpRedisScriptRunner`). Both are optional dependencies; install the one you use.
- `ext-apcu` for single-host cross-process limiting (`ApcuBulkheadStore`) — optional, not a hard dependency

Installation
------------

[](#installation)

```
composer require rasuvaeff/bulkhead

# for RedisBulkheadStore with the pure-PHP client:
composer require predis/predis
```

Usage
-----

[](#usage)

```
use Predis\Client;
use Rasuvaeff\Bulkhead\BulkheadFullException;
use Rasuvaeff\Bulkhead\Redis\PredisScriptRunner;
use Rasuvaeff\Bulkhead\RedisBulkheadStore;
use Rasuvaeff\Bulkhead\SharedBulkhead;
use Rasuvaeff\Duration\Duration;

$bulkhead = new SharedBulkhead(
    name: 'legacy-api',
    maxConcurrent: 10,
    store: new RedisBulkheadStore(new PredisScriptRunner(new Client(['host' => '127.0.0.1']))),
    lease: Duration::seconds(5),    // a slot is auto-reclaimed after this if not released
    maxWait: Duration::millis(200), // wait up to 200ms for a slot; Duration::zero() = fast-fail
);

try {
    $result = $bulkhead->call(static fn(): string => callDownstream());
} catch (BulkheadFullException $e) {
    // All slots busy — degrade gracefully instead of hammering the dependency.
}
```

With `ext-redis` instead of predis:

```
use Rasuvaeff\Bulkhead\Redis\PhpRedisScriptRunner;

$redis = new \Redis();
$redis->connect('127.0.0.1');
$store = new RedisBulkheadStore(new PhpRedisScriptRunner($redis));
```

Optional knobs:

```
$bulkhead = new SharedBulkhead(
    name: 'legacy-api',
    maxConcurrent: 10,
    store: $store,
    lease: Duration::seconds(5),
    maxWait: Duration::millis(200),
    pollInterval: Duration::millis(50), // polling granularity while waiting
    pollJitter: 0.5,                    // randomize each poll sleep ±50% so waiters
                                        // don't stampede a freed slot in lockstep
    onAccepted: static fn(string $name, Duration $waited) => $metrics->timing("bulkhead.$name.wait", $waited->toMillis()),
    onRejected: static fn(string $name, Duration $waited) => $metrics->increment("bulkhead.$name.rejected"),
);
```

### Public API

[](#public-api)

TypeDescription`Bulkhead`Interface: `call(callable): mixed`, `availableSlots(): int``SharedBulkhead`Limits concurrency using a `BulkheadStore`; fast-fails or waits up to `maxWait`; exposes `name()`, `maxConcurrent()``BulkheadStore`Backing store: `tryAcquire`, `release`, `activeCount``RedisBulkheadStore`Multi-host cross-process store; sorted-set + Lua, atomic acquire, lease TTL`ApcuBulkheadStore`Single-host cross-process store; APCu spinlock, atomic acquire, lease TTL`InMemoryBulkheadStore`Single-process store (tests / CLI); does not coordinate across processes`BulkheadScriptRunner`Typed seam over a Redis script call (implement for another client)`Redis\PredisScriptRunner`predis-backed `BulkheadScriptRunner`; EVALSHA with EVAL fallback`Redis\PhpRedisScriptRunner``ext-redis`-backed `BulkheadScriptRunner`; EVALSHA with EVAL fallback`BulkheadFullException`Thrown when no slot is available within `maxWait`; carries `name`, `maxConcurrent``Sleeper\SleeperInterface`Wait strategy while polling; `SystemSleeper`, `FakeSleeper`### Sizing the knobs

[](#sizing-the-knobs)

- **`maxConcurrent`** — what the *downstream* tolerates, not what the pool can send. If the dependency handles ~10 concurrent connections comfortably and you run 3 app hosts sharing one Redis, `maxConcurrent: 10` caps all hosts together. It must be smaller than your FPM worker count to mean anything — with 50 workers and `maxConcurrent: 100` the bulkhead never engages.
- **`lease`** — strictly greater than the worst-case callback runtime, in practice: downstream timeout + a safety margin. Too short and slots are reclaimed mid-call (limit overshoots); too long and a crashed worker's slot stays occupied for the whole lease (limit undershoots). If the callback is an HTTP call with a 5s timeout, `lease: Duration::seconds(10)` is a sane start.
- **`maxWait`** — how long a request may queue for a slot. `Duration::zero()`fast-fails (shed load immediately); anything longer trades latency for a lower rejection rate. Keep it well under your own request timeout.
- **`pollJitter`** — set it to `0.1`–`0.5` when many workers may wait at once, so a freed slot isn't stampeded by every waiter on the same 50ms tick.

### How the limit holds across workers

[](#how-the-limit-holds-across-workers)

`RedisBulkheadStore` keeps a sorted set per bulkhead: each active slot is a member scored with its lease-expiry. `tryAcquire` runs a single Lua script that prunes expired members, checks the cardinality against the limit, and adds a member — so the check and the add are atomic and two workers cannot both slip past the limit. A worker that dies mid-call leaks nothing: its member's lease score passes and the slot is reclaimed on the next acquire.

`ApcuBulkheadStore` keeps a `token => expiresAt` array per bulkhead in one APCu entry. APCu has no server-side scripting, so atomicity comes from a spinlock instead: `tryAcquire`/`release` take a short-lived APCu key (`apcu_add` as create-if-absent) before reading or writing the slot array, and the lock itself carries a TTL so a worker that dies while holding it doesn't deadlock the others. Only coordinates workers on the **same host** — APCu's shared memory doesn't span machines; use `RedisBulkheadStore` for a pool spread across hosts.

Security
--------

[](#security)

- `name` is validated against `/^[A-Za-z0-9_.:-]+$/` and becomes part of the Redis/APCu key — untrusted names are rejected, not interpolated blindly.
- Values flow into the Lua script as bound `ARGV`, never string-concatenated.
- The package opens no network connections itself; you supply the Redis client.

Caveats
-------

[](#caveats)

- **`lease` must exceed the longest expected callback runtime.** If a call runs longer than its lease, the store reclaims the slot mid-execution and another worker can acquire it — concurrency then briefly exceeds `maxConcurrent`. Size the lease above your downstream timeout.
- `maxWait` is an approximate, poll-based bound (default 50ms granularity): the per-attempt store round-trip is not counted, so real wall time can slightly exceed it.
- **Waiting is not FIFO.** Waiters poll; whoever polls right after a release wins the slot. Under sustained overload a waiter can starve past `maxWait`and be rejected while later arrivals get through.
- `availableSlots()` / `activeCount()` on Redis **write** (they prune expired members), so they can't be pointed at a read-only replica.
- `InMemoryBulkheadStore` is single-process only — it does **not** limit the FPM pool. Use it for tests and CLI tools.
- `ApcuBulkheadStore` only limits workers on the **same machine**. A pool spread across multiple hosts needs `RedisBulkheadStore`. Two sharp edges of the APCu spinlock:
    - `tryAcquire`/`release` spin up to ~100ms (configurable via `lockMaxAttempts`/`lockRetryMicros`) for the internal lock. A failed `tryAcquire` spin reports "full"; a failed `release` spin leaves the slot to expire with its lease.
    - APCu has no compare-and-delete, so `unlock` can't verify ownership: a holder stalled past the 1s lock TTL inside the microsecond-sized critical section could delete a successor's lock. Accepted as negligible for a critical section this small; use Redis if that guarantee matters to you.

Examples
--------

[](#examples)

See [examples/](examples/) for runnable scripts.

ScriptShowsNeeds server?`basic.php`In-memory store, fast-fail when fullno`redis.php`Cross-process limiting with Redisyes (`REDIS_HOST`)`apcu.php`Single-host cross-process limiting with APCuno (needs `ext-apcu`)Development
-----------

[](#development)

No PHP/Composer on the host — run in Docker via the `composer:2` image:

```
docker run --rm -v "$PWD":/app -w /app composer:2 composer install
docker run --rm -v "$PWD":/app -w /app composer:2 composer build
docker run --rm -v "$PWD":/app -w /app composer:2 composer cs:fix
docker run --rm -v "$PWD":/app -w /app composer:2 composer test
```

Integration tests need a Redis server (self-skip unless `REDIS_HOST` is set), `ext-apcu` (self-skip via `ApcuBulkheadStore::isAvailable()`) and `ext-redis`(self-skip via `extension_loaded('redis')`); the base `composer:2` image has none of them, so run the suite in an image carrying `apcu`, `pcntl` and `redis`(plus `apc.enable_cli=1`):

```
docker run -d --name bh-redis -p 6379:6379 redis:7-alpine
docker run --rm --network host -v "$PWD":/app -w /app -e REDIS_HOST=127.0.0.1 \
   vendor/bin/testo --suite=Integration
docker rm -f bh-redis
```

License
-------

[](#license)

[BSD-3-Clause](LICENSE.md)

###  Health Score

46

—

FairBetter than 92% of packages

Maintenance93

Actively maintained with recent releases

Popularity16

Limited adoption so far

Community12

Small or concentrated contributor base

Maturity55

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 66.7% 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 ~7 days

Total

4

Last Release

26d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/b0812d5572a7041dfe36e222d295b2e6dc55833a605350fcde58a51a5965ed30?d=identicon)[rasuvaeff](/maintainers/rasuvaeff)

---

Top Contributors

[![rasuvaeff](https://avatars.githubusercontent.com/u/1352718?v=4)](https://github.com/rasuvaeff "rasuvaeff (6 commits)")[![dependabot[bot]](https://avatars.githubusercontent.com/in/29110?v=4)](https://github.com/dependabot[bot] "dependabot[bot] (3 commits)")

---

Tags

apcubulkheadconcurrencyfault-tolerancelimiterphprate-limitingredisresilienceconcurrencyredisapcufpmresiliencebulkhead

###  Code Quality

Static AnalysisPsalm, Rector

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/rasuvaeff-bulkhead/health.svg)

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

###  Alternatives

[gordalina/cachetool

Manage your OPcache &amp; APCu cache through the CLI

1.8k3.9M5](/packages/gordalina-cachetool)[robinn/phpcacheadmin

A web dashboard for your favorite caching system.

4484.0k2](/packages/robinn-phpcacheadmin)

PHPackages © 2026

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