leuchtdiode / mezzio-fence
Mezzio module for per ip rate limiting
Requires
- php: ~8.4.0 || ~8.5.0
- leuchtdiode/mezzio-common: ^2.0.0
- leuchtdiode/mezzio-console: ^0.1.0
- leuchtdiode/mezzio-log: ^1.1.0
- psr/http-server-middleware: ^1.0.0
Requires (Dev)
- phpunit/phpunit: ^13.0.0
This package is auto-updated.
Last update: 2026-08-27 21:57:56 UTC
README
Per ip rate limiting for a mezzio application. Everything that is routed is counted; an action that wants its own numbers says so with an attribute.
use Try2catch\Fence\Fence; #[Fence(limit: 5, perSeconds: 3600)] class Start extends BaseJsonAction { }
Install
composer require leuchtdiode/mezzio-fence
Add the config provider in config/config.php:
use Try2catch\Fence\ConfigProvider as FenceConfigProvider; $aggregator = new ConfigAggregator([ // ... FenceConfigProvider::class, ]);
And pipe the middleware in config/pipeline.php, directly after MethodNotAllowedMiddleware:
$app->pipe(RouteMiddleware::class); $app->pipe(ImplicitHeadMiddleware::class); $app->pipe(ImplicitOptionsMiddleware::class); $app->pipe(MethodNotAllowedMiddleware::class); $app->pipe(FenceMiddleware::class);
That position matters in both directions. Above it, the routing middlewares have put the
RouteResult on the request, which is the only way the action behind the route is known. Below it
come the session and the parsed body, which a refused request then never pays for.
Log\Middleware\GlobalLogMiddleware has to be piped before it - as the outermost middleware, which
is where it belongs anyway - because the fence logs.
Add the counter directory to .gitignore.
Config
'fence' => [ 'enabled' => true, 'directory' => 'data/fence', 'default' => [ 'limit' => 300, 'perSeconds' => 60, ], 'trustedIps' => [], 'responder' => Try2catch\Fence\Refusal\JsonResponder::class, 'message' => 'Too many requests', 'pruneAfterSeconds' => 86400, ],
directory is relative to the working directory, which for a mezzio application is the project
root. It must be writable and must not be inside the document root.
trustedIps takes wildcards - 10.0.*.*, 2001:db8:*:*::* - and is where whatever polls your
health route belongs. A trusted address is recognised before any file is touched.
The attribute
#[Fence] goes on the action class. Whatever is left null keeps the configured default, so
#[Fence(limit: 5)] // five a minute, if the default window is a minute #[Fence(limit: 5, perSeconds: 3600)] // five an hour #[Fence(enabled: false)] // never counted
An action that carries the attribute is also counted in a bucket of its own. Everything else shares one bucket per address, so a visitor touching a dozen endpoints in a minute owns one counter rather than a dozen.
The attribute is looked for up the class hierarchy, so a base class of related actions is a perfectly good place to put one. What is counted stays the concrete action either way.
What a refusal looks like
429, a Retry-After header saying how many seconds are left of the window, and a body in the
shape the rest of the api answers in:
{
"success": false,
"data": null,
"meta": null,
"errors": [
{
"code": "TOO_MANY_REQUESTS",
"message": "Too many requests"
}
]
}
An application that answers screens rather than json writes its own Refusal\Responder, puts it in
its own namespace and names it in fence.responder.
fence:prune
php vendor/bin/mezzio-console fence:prune
Removes the counters of addresses that have not been back inside pruneAfterSeconds. Nothing
depends on it: a counter that is hit again finds its window has turned over and starts from nought,
so every address still visiting keeps exactly one file however long it keeps visiting. Schedule it
if you like a tidy disk.
What it does not do
The count is per node. The counters are files, so two application servers keep two counts and a limit of 300 is effectively 600. That is a deliberate trade. A database counter would have to be written before the application has read anything, and on a primary/replica connection the first write pins the whole request to the primary - every read after it, on every request, would leave the replicas idle. Isolating that means a second connection, a second entity manager, a schema filter so the application's own migrations diff stops trying to drop the table, and a migration in every application that installs this. For a guard whose job is to stop one script hammering one host, per node counting is the better bargain - nginx's own limiter counts per worker for the same reason.
Locking wants a local filesystem. flock is reliable on one, and is not on NFS. Put the
directory on local disk, the same as your php session files.
The address has to be trustworthy. Common\Util\IpUtil reads X-Real-IP before REMOTE_ADDR.
A reverse proxy that sets that header and overwrites whatever the client sent - traefik and nginx
both do - is what makes it worth anything, and the application must not be reachable around that
proxy. Without one in front, remove X-Real-IP at the edge or the guard can be walked past with a
header.
It counts requests, not people. A proxy farm defeats it, as it defeats any per address limit. This raises the floor from nothing to something; it is not a bot detector.
Privacy
Nothing written to disk is an address anybody can read back: the key is a sha256, and a v6 address is cut to its /64 first so that a single customer's allocation is one counter rather than 18 quintillion. The address itself appears in one place, the warning logged as a limit is crossed, where it rotates with the rest of the log.