Alpha ZealPHP is early-stage and under active development. APIs may change between minor versions until v1.0. Feedback and bug reports welcome on GitHub.
API Index — Namespaces, Packages, Reports, Indices

RateLimitMiddleware
in package
implements MiddlewareInterface

Rate-Limit Middleware (sliding window, per-IP, shared across workers)

Tracks request counts per client IP in a shared Store table. When a single IP exceeds $limit requests inside $window seconds, further requests receive a configurable HTTP error (default 429 Too Many Requests) with a Retry-After header.

nginx parity

limit_req_zone $binary_remote_addr zone=one:10m rate=60r/m; limit_req zone=one burst=5 nodelay;

ZealPHP equivalent:

$app->addMiddleware(new RateLimitMiddleware( limit: 60, window: 60, burst: 5, nodelay: true, tableName: 'rate_limit', ));

Algorithm note — fixed window vs leaky bucket

nginx's limit_req uses a leaky-bucket (token-drain) algorithm with millisecond precision. ZealPHP uses a fixed window that resets every $window seconds. The practical difference is the "thundering-herd at boundary" problem: a fixed window allows up to $limit requests at the tail of one window and another $limit at the head of the next window — a worst-case 2× burst. A leaky bucket smooths this out. For spam/abuse defence the fixed window is sufficient; for billing-grade fairness or tight concurrency control, consider implementing a leaky-bucket variant.

burst= / nodelay= / delay=

  • burst=N — allow up to N extra requests above the limit per window before rejecting. Requests within the burst quota are forwarded.
  • nodelay=true — burst requests are forwarded immediately (no artificial delay). This matches nginx limit_req ... nodelay.
  • Without burst (default burst=0), any request over the limit is rejected.

Trusted-proxy / X-Forwarded-For integration (B2 fix)

The zone key is now resolved via App::clientIp() which honours App::$trusted_proxies. Operators deploying behind Traefik/nginx must configure App::trustedProxies() so the rate-limit key is the real client IP rather than the proxy's IP.

Store-full failure policy (B10 fix)

When the OpenSwoole Table is full, Store::set() returns false. The middleware detects this, logs a warning via elog(), and fails open (passes the request through). This is an explicit policy choice: rejecting an unknown IP because the table is full would be overly aggressive. Operators should size their table generously and monitor the log for Store table full warnings.

Dry-run mode

dryRun=true runs all accounting and logs what would have been blocked, but forwards every request regardless. Use this to calibrate rate-limit settings on production traffic without impacting availability.

Configurable reject status

rejectStatus defaults to 429. Pass 503 for nginx parity (nginx historically uses 503 for rate-limit rejections). Any 4xx/5xx code is accepted.

Loopback bypass

By default, requests from 127.0.0.1 / ::1 are not rate-limited so the integration test suite can run repeatedly without php app.php restart. Set ZEALPHP_RATE_LIMIT_LOOPBACK=1 to opt in (useful when testing the limiter).

Store table schema (create before $app->run())

Store::make('rate_limit', 16384, [ 'ip' => [\OpenSwoole\Table::TYPE_STRING, 64], 'count' => [\OpenSwoole\Table::TYPE_INT, 4], 'reset' => [\OpenSwoole\Table::TYPE_INT, 4], ]);

$app->addMiddleware(new \ZealPHP\Middleware\RateLimitMiddleware( limit: 60, window: 60, tableName: 'rate_limit', ));

If the table doesn't exist when the request arrives the middleware fails-open (passes the request through) and logs once via elog().

Table of Contents

Interfaces

MiddlewareInterface

Properties

$burst  : int
$dryRun  : bool
$limit  : int
$nodelay  : bool
$rejectStatus  : int
$tableName  : string
$warnedMissingTable  : bool
$window  : int

Methods

__construct()  : mixed
process()  : ResponseInterface
isLoopback()  : bool
logDryRunBlock()  : void
tooMany()  : ResponseInterface

Properties

Methods

__construct()

public __construct([int $limit = 60 ][, int $window = 60 ][, string $tableName = 'rate_limit' ][, int $burst = 0 ][, bool $nodelay = false ][, int $rejectStatus = 429 ][, bool $dryRun = false ]) : mixed
Parameters
$limit : int = 60
$window : int = 60
$tableName : string = 'rate_limit'
$burst : int = 0
$nodelay : bool = false
$rejectStatus : int = 429
$dryRun : bool = false

process()

public process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface
Parameters
$request : ServerRequestInterface
$handler : RequestHandlerInterface
Return values
ResponseInterface

isLoopback()

private isLoopback(string $ip) : bool
Parameters
$ip : string
Return values
bool

logDryRunBlock()

private logDryRunBlock(string $ip, int $count, int $retryAfterSeconds) : void
Parameters
$ip : string
$count : int
$retryAfterSeconds : int

tooMany()

private tooMany(int $retryAfterSeconds) : ResponseInterface
Parameters
$retryAfterSeconds : int
Return values
ResponseInterface
On this page