sugarcommerce / rate-limit
Sliding-window rate limiting with atomic counters. APCu, SQLite, file and in-memory stores, standard rate-limit headers, and an API that makes forgetting the identity key impossible. Zero dependencies.
Requires
- php: >=8.5
Requires (Dev)
- phpunit/phpunit: ^13.0
Suggests
- ext-apcu: Shared-memory counters — the right store for a single server.
- ext-pdo_sqlite: Counters in a table, shared by every worker and kept across restarts (SQLite 3.35+).
- sugarcommerce/sqlite: A configured SQLite connection to hand the SQLite store.
Provides
None
Conflicts
None
Replaces
None
README
Sliding-window rate limiting with atomic counters. APCu, SQLite, file and in-memory stores, standard headers, and an API that makes forgetting the identity key impossible. Zero dependencies.
Features
- 🎯 Identity is mandatory — the API refuses a limit with nothing to count against.
- 📉 Sliding window — no double-allowance burst across the window boundary.
- ⚛️ Atomic counters, atomic decisions — no read-then-write race in the store, and the allow/deny is decided on the count the increment returned, so a concurrent burst gets exactly the allowance.
- 🌐 One caller, one counter — an IPv6 address counts as its /64, addresses are compared in canonical form, case is folded beyond ASCII.
- 🗄️ Pluggable stores — APCu, SQLite, file, memory; anything else is one interface.
- 📤 Standard headers —
X-RateLimit-*andRetry-After. - 🔒 Identities are hashed — emails and IPs never become cache keys or filenames.
Requirements
PHP 8.5+. ext-apcu recommended; pdo_sqlite with SQLite 3.35+ for the SQLite store.
Installation
composer require sugarcommerce/rate-limit
Usage
use SugarCommerce\RateLimit\{RateLimiter, Stores\Apcu}; $limiter = new RateLimiter( new Apcu() ); $decision = $limiter->for( 'login', $ip )->check( 5, 60 ); if ( $decision->denied() ) { http_response_code( 429 ); foreach ( $decision->headers() as $name => $value ) { header( "$name: $value" ); } exit; }
Clear the counter when the thing being protected succeeds, so someone who mistyped their password four times isn't left one attempt from a lockout:
$limiter->for( 'login', $ip )->clear( 60 );
The mistake this API prevents
$limiter->check( 'login', 5, 60 ); // one counter for the whole internet $limiter->for( 'login', $ip )->check( 5, 60 ); // one per visitor
The first shares a single counter across every visitor, so five attempts from anyone locks out everybody. An attacker denies your entire customer base by hammering the endpoint from one machine. It reads like rate limiting and is closer to a self-inflicted outage — which is why for() requires the identity rather than accepting it optionally.
Key by the thing being protected, not just the network. IP is right for "requests to this endpoint". For "magic links sent to this address", key on the address — otherwise one mailbox can be flooded from a botnet, and an office behind one NAT gets blocked as a group. Often you want both:
$limiter->for( 'magic-link:ip', $ip )->check( 10, 3600 ); $limiter->for( 'magic-link:email', $email )->check( 3, 3600 );
An IPv6 address is counted as its /64. A single host is routinely handed a whole /64 and can send from any of its 2^64 addresses, so a counter per address is no limit at all. Addresses are also compared in canonical form — 2001:DB8:0::1 is 2001:db8::1, and ::ffff:192.0.2.1 is 192.0.2.1 — and case is folded beyond ASCII when mbstring is loaded. Limit::identity() shows the form that is counted. Normalise anything else yourself — the same way you do before the lookup the limit protects — or a caller with trailing junk on an email address is a new caller each time.
Named rules
An application's limits in one table, so a call site names the action and the numbers live in one place:
use SugarCommerce\RateLimit\{RateLimiter, Rules}; $rules = new Rules( RateLimiter::auto( __DIR__ . '/var/limits' ), array( 'login' => array( 10, 900 ), // [ attempts, seconds ] 'magic-link:mail' => array( 3, 3600 ), ) ); $decision = $rules->check( 'login', $ip ); // counts if ( $decision->denied() ) { return new Response( 429, '', $decision->headers() ); } $rules->allows( 'token.miss', $ip ); // looks, without counting
An action with no rule, or an empty key, is never limited: the answer is Decision::unlimited(), allowed and with no headers. A typo in an action name should not take an endpoint down, and every caller with nothing to count against sharing one '' counter would let any of them lock out the rest. has() is there for a test that proves every action you use has a rule.
RateLimiter::auto( $directory ) picks the APCu store when APCu is enabled in this process, and the file store otherwise. Checking extension_loaded( 'apcu' ) or apc.enabled is the common mistake: both are true on a command line where apc.enable_cli is off, the APCu store throws, and every test run and cron job dies on the servers that have APCu for their web workers.
Why sliding window
A fixed window resets on a clock boundary, so the full allowance can be spent at 0:59 and again at 1:01 — double the limit across two seconds, which is precisely the burst you were trying to stop.
This weights the previous window by how much of it is still in view. Ten seconds into a sixty-second window, fifty seconds of the previous one still fall inside the trailing minute, so it counts for 50/60. Same storage cost, no boundary hole.
Stores
| Store | Shared across | Notes |
|---|---|---|
Apcu |
all workers on one server | Fast, atomic in the extension. The default choice. |
Sqlite |
every worker that opens the database | Survives restarts, no APCu needed. Atomic in one upsert; prunes itself. |
File |
all workers on one server | No extension needed. Touches disk per request; call prune() from cron. Throws when it cannot open or lock a counter, rather than counting the attempt as a first. |
Memory |
one process | Tests, and long-running workers. Useless for FPM — each worker gets its own counters. |
SQLite
For an application already on SQLite: counters that every PHP-FPM worker shares and that survive a restart, without APCu.
use SugarCommerce\RateLimit\{RateLimiter, Stores\Sqlite}; $pdo = new PDO( 'sqlite:' . __DIR__ . '/var/app.sqlite' ); $pdo->exec( 'PRAGMA journal_mode = WAL' ); $pdo->exec( 'PRAGMA busy_timeout = 5000' ); $store = new Sqlite( $pdo ); // table `rate_limits`; or new Sqlite( $pdo, 'throttle' ) $store->install(); // CREATE TABLE / INDEX IF NOT EXISTS — or put $store->schema() in a migration $limiter = new RateLimiter( $store );
Each increment is a single INSERT … ON CONFLICT DO UPDATE … RETURNING, so concurrent workers never read the same count. The table name is interpolated into SQL and must be a plain identifier; anything else throws.
The limiter writes a new key every window, so expired rows build up. One increment in 1,000 also deletes them — change the odds with the third argument, or pass 0 and call $store->prune() from cron. Set a busy_timeout, or a worker that finds the database locked fails instead of waiting.
Behind a load balancer, per-machine stores mean the effective limit multiplies by the server count. Implement Store against Redis or your database when that matters — it's three methods, and the only requirement is that increment() is atomic.
Testing
composer install
composer test
92 tests. Includes an explicit boundary-burst test proving a fixed-window implementation would let double the allowance through, a concurrent burst that must get exactly the allowance, IPv6 /64 and canonical-address counting, named rules with their never-limited cases, the store auto() picks, and the SQLite store's window semantics, pruning and table-name checks.
License
GPL-2.0-or-later