leuchtdiode/mezzio-fence

Mezzio module for per ip rate limiting

Maintainers

Package info

github.com/leuchtdiode/mezzio-fence

Homepage

pkg:composer/leuchtdiode/mezzio-fence

Transparency log

Statistics

Installs: 7

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-08-27 21:54 UTC

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.