Skip to content

Rate limits

naf/rate-limit counts attempts in a database using fixed time windows. Call it explicitly before a protected operation such as login, verification mail or export. It returns a decision; your handler decides whether to continue or return HTTP 429.

Configure a PDO connection and install the limiter table before using the guard. Installing the plugin alone does not intercept requests.

Required packages

In an existing NAF application:

composer require naf/rate-limit

Check a limit

The package registers a rateLimit guard on the Naf\guard() registry the framework already provides. After the database setup below, call it from a handler:

use function Naf\guard;
use function Naf\json;

$decision = guard()->rateLimit('export:account:' . $accountId, 10, 60);

if (!$decision['allowed']) {
    return json(['error' => 'Too many requests'], 429)
        ->withHeader('Retry-After', (string) $decision['retry_after']);
}

Ten attempts per sixty seconds, counted against that key. The answer is three fields:

Field Meaning
allowed whether this attempt is within the limit
remaining how many are left in the current window
retry_after seconds until the window resets

Check the field, not the array

The result is a non-empty array for both outcomes. Read $decision['allowed'] to decide whether the operation may continue.

guard()->run('rateLimit', $key, $limit, $seconds) does the same thing, and injecting PdoLimiter directly shares the same buckets.

Choosing a key

The key decides what is being limited, and it is yours to choose. Namespace it, so a limit on exports cannot be spent by a limit on logins:

guard()->rateLimit('login:account:' . $accountId, 5, 300);
guard()->rateLimit('login:address:' . $peerAddress, 20, 300);

Keys are stored hashed rather than as raw account identifiers or addresses. Use the direct peer address unless you have deliberately configured a trusted proxy policy — a client-supplied header can be forged.

Rate limiting does not authenticate callers. Derive account identifiers from verified application identities.

Database setup

Installing the package intercepts nothing. Booting it creates no tables, opens no connection and consumes no limit — the limiter is resolved on the first guard call, which is when your connection has to be there. Install and configure naf/database 0.2.4+ as described in Database; it registers the configured connection under PDO::class. For a connection managed outside NAF, bind your own PDO::class service before that first guard call.

Create the table once, in an explicit migration using the configured connection:

use Naf\RateLimit\PdoLimiter;
use function Naf\Database\database;

$pdo = database() ?? throw new LogicException('Rate limiting requires a configured database.');
(new PdoLimiter($pdo))->install();

An existing PDO::class binding is left alone. Binding your own PdoLimiter works even after the default one has already answered a call. MySQL, MariaDB, PostgreSQL and SQLite are supported. Call cleanup() periodically — from a scheduled job, for instance — to delete buckets that expired more than 24 hours ago.

Window boundaries and failures

This is a fixed window, not a sliding one, and the difference shows at the boundary:

For a limit of ten per minute, ten attempts at 12:00:59 and ten more at 12:01:00 are all allowed. Twenty attempts can therefore occur in two seconds around the window boundary.

A fixed window permits bursts around window boundaries. Choose another algorithm when the requirement is a strict rolling-window or burst limit.

Transaction and failure behavior:

  • Counting stays out of your transactions. The limiter runs its own short transaction and refuses to join one you already opened, rather than counting inside work that might still roll back. Consume before you start the domain transaction.
  • Storage errors propagate. Handle them as failures; do not continue the protected operation as though the limit had allowed it.