Rate Limiting

True\RateLimiter is a fixed-window throttle with pluggable storage. It supports per-IP API limits, per-user buckets, multi-window stacks (burst + sustained), and integrates with the router via RateLimitMiddleware so the check/hit/headers dance lives in one place.

Storage backends

StoreUse whenNotes
ApcuStoreSingle-server deploysFastest. Requires ext-apcu. Buckets are per PHP-FPM pool — they do not cross servers.
FileStoreLow/medium traffic, no APCuNo extension required. Atomic via flock(). Files self-expire and GC on writes.

For multi-server deploys, implement True\RateLimiting\StoreInterface against Redis or Memcached.

Setup

Construct the limiter at the top of routes.php and stash it on $App:

// APCu (recommended on a single server)
$App->rateLimiter = new \True\RateLimiter(new \True\RateLimiting\ApcuStore);

// File-backed fallback
$App->rateLimiter = new \True\RateLimiter(
    new \True\RateLimiting\FileStore(BP . '/app/data/rate-limits')
);

Defining limits

Each limit has a discriminator (what scopes the bucket — IP, user id, email, …), a max attempts, and a decay window. True\RateLimiting\Limit provides factories for common windows:

use True\RateLimiting\Limit;

Limit::perSecond(10);
Limit::perMinute(60);          // 60 / 1 minute (default)
Limit::perMinute(100, 5);      // 100 / 5 minutes
Limit::perHour(1000);
Limit::perDay(10000);
Limit::none();                 // pass-through (never limits)

Chain ->by() to set the discriminator, and optionally attach ->after() / ->response() callbacks:

Limit::perMinute(5)
    ->by($_POST['email'] . '|' . $_SERVER['REMOTE_ADDR'])
    ->after(fn(Limit $l) => error_log("hit on {$l->key}"))
    ->response(fn(Limit $l, int $retryAfter) =>
        $App->response(['error' => 'Slow down'], 'json', 429)
    );

The middleware — quick path

For most cases, drop RateLimitMiddleware on a route group with an inline rate. The bucket keys by client IP and namespaces under the name you pass:

$App->router->group('/api/*', function () use ($App) {

    $App->router->get('/api/user/*:id', function ($request) use ($App) {
        echo $request->route->id;
    });

}, [ new \True\Middleware\RateLimitMiddleware('api', 100) ]);

That's 100 attempts per minute per IP. The third constructor argument overrides the window in seconds — ('api', 1000, 3600) is 1000 per hour, ('upload', 5, 60) is 5 per minute.

Named limiter — full control

When you need a non-IP discriminator, per-user vs. per-guest limits, or a custom 429 response, register the limit once via for() and omit the inline rate:

$App->rateLimiter->for('login', fn($request) =>
    Limit::perMinute(5)
        ->by(($_POST['email'] ?? '') . '|' . $_SERVER['REMOTE_ADDR'])
);

$App->router->group('/login', function () use ($App) {

    $App->router->post('/login', function ($request) use ($App) {
        // ...
    });

}, [ new \True\Middleware\RateLimitMiddleware('login') ]);

When the limit is exceeded the middleware returns false, the router skips the group, and a 429 Too Many Requests with Retry-After + X-RateLimit-* headers goes back to the client. If the limit has a ->response() callback, that runs instead of the default body.

The attempt() shortcut

Run a callback only if the bucket is under the limit, then record the hit in one call:

$result = $App->rateLimiter->attempt(
    key:          'login:' . $email,
    maxAttempts:  5,
    callback:     fn() => authenticate($email, $password),
    decaySeconds: 60,
);

if ($result === false) {
    $retryAfter = $App->rateLimiter->availableIn('login:' . $email);
    $App->error("Too many attempts. Try again in {$retryAfter}s.", 'warning');
    $App->go('/login');
}

If the callback returns null, attempt() returns true so you can distinguish "ran successfully" from "blocked".

Common patterns

Per-user vs. per-guest

$App->rateLimiter->for('api', function () use ($App) {
    if ($userId = $App->auth->userId()) {
        return Limit::perMinute(1000)->by('user:' . $userId);
    }
    return Limit::perMinute(60)->by('ip:' . $_SERVER['REMOTE_ADDR']);
});

Bypass for trusted users

$App->rateLimiter->for('api', function () use ($App) {
    if ($App->auth->isAdmin()) return Limit::none();
    return Limit::perMinute(100)->by($_SERVER['REMOTE_ADDR']);
});

Burst + sustained on one endpoint

Return an array of limits to enforce more than one window simultaneously:

$App->rateLimiter->for('contact-form', fn() => [
    Limit::perMinute(3)->by($_SERVER['REMOTE_ADDR']),  // burst
    Limit::perHour(20)->by($_SERVER['REMOTE_ADDR']),   // sustained
]);

Stacking with other middleware

Middleware order matters. Put the limiter before auth to throttle anonymous traffic before doing expensive lookups; put it after to apply per-user limits using the authenticated identity:

$App->router->group('/api/*', function () use ($App) {
    // ... routes
}, [
    new \True\Middleware\RateLimitMiddleware('api-burst'),   // cheap pre-auth throttle
    new \App\AuthMiddleware,                                  // then authenticate
    new \True\Middleware\RateLimitMiddleware('api-user'),    // then per-user throttle
]);

Notes & caveats

  • Single-server only with ApcuStore. APCu memory is per PHP-FPM pool; buckets do not cross servers.
  • Fixed window, not sliding. A user who exhausts the quota at the end of a window can refill immediately on the next tick.
  • Lockout vs. counter. tooManyAttempts() consults a separate timer key, so even if the counter expires the user stays locked out until the window timer ends.
  • CLI tasks with APCu. Set apc.enable_cli=1 if you call the limiter from queue workers, or those calls silently no-op.
  • FileStore directory. Make sure it's writable and excluded from any backup/sync that might fight flock().