symfony/rate-limiter
Symfony Rate Limiter provides a token bucket implementation for rate limiting input and output in PHP applications. Configure policies and storage with RateLimiterFactory, then reserve tokens to wait or consume tokens to check acceptance.
Install the package (pin a patched version):
composer require symfony/rate-limiter:^5.4.54 # or ^7.x / ^8.1.6+ depending on your Symfony line
The burst-capping fix (#65747) shipped in two maintenance branches: v8.1.6 and v5.4.54. If you are on the 5.4 LTS line you are only protected from v5.4.54 onward; older 5.4 patches (v5.4.47 and earlier) still compute an uncapped burst.
Define a rate limiter:
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\RateLimiter\Storage\RedisStorage;
$storage = new RedisStorage('redis://localhost');
$factory = new RateLimiterFactory([
'id' => 'api_endpoint',
'policy' => 'token_bucket',
'limit' => 100,
'rate' => ['interval' => '1 minute'],
], $storage);
$limiter = $factory->create();
First use case: Laravel middleware:
use Symfony\Component\HttpFoundation\Response;
class RateLimitMiddleware
{
public function __construct(private RateLimiter $limiter) {}
public function handle($request, Closure $next): Response
{
if (!$this->limiter->consume(1)->isAccepted()) {
return response()->json(['error' => 'Too many requests'], 429)
->header('Retry-After', $this->limiter->getRetryAfter()->getTimestamp());
}
return $next($request);
}
}
InMemoryStorage (dev/tests), RedisStorage (prod), custom StorageInterface for database-backed limiting.token_bucket, fixed_window, sliding_window.burst values will admit less traffic than before. Re-verify effective limits on both the 5.4 and 8.1 lines.v5.4.54 is tagged [SECURITY]. The only changelog entry is the burst capping fix, so treat the capping as the security-relevant change: a limiter that silently admitted more requests than configured is a quiet form of limit bypass. There is no CVE in the changelog — check Symfony security advisories for the advisory ID and affected ranges before you file your upgrade justification.Bind a named limiter in a service provider and resolve it per route group:
// app/Providers/RateLimitServiceProvider.php
public function register(): void
{
$this->app->bind('limiter.api', fn () => (new RateLimiterFactory([
'id' => 'api',
'policy' => 'token_bucket',
'limit' => 100,
'rate' => ['interval' => '1 minute'],
], $this->app->make(RedisStorage::class)))->create());
}
// routes/api.php
Route::middleware(['throttle.api'])->group(function () { /* ... */ });
Register throttle.api as a middleware alias so controllers and jobs share the same instance definition.
Throttle in the job's middleware() method and release rather than fail:
public function middleware(RateLimiter $limiter): array
{
return [new ThrottlesRequests($limiter)];
}
// inside the middleware
if (!$limiter->consume(1)->isAccepted()) {
$this->release($limiter->getRetryAfter()->getTimestamp() - now()->timestamp);
}
Release, never delete() — a deleted job silently drops work when the limiter is hot.
Use CompoundLimiter when an endpoint must satisfy several budgets at once (per-user and per-IP and global):
$limiter = new CompoundLimiter([$perUser, $perIp, $global]);
$result = $limiter->consume(1);
The compound limiter consumes from all sub-limiters before deciding, so a rejection still burns tokens in the others. That is intentional (prevents probing), but budget for it.
Long-running commands and scheduled tasks are easiest to protect with a window-based limiter around the whole run, not per item:
if (!$limiter->consume(1)->isAccepted()) {
$this->error('Skipped: prior run still within its window.');
return self::SUCCESS;
}
Use fixed_window here — you want "one run per N minutes" semantics, not token-bucket smoothing.
Use Case: Adjust limits per environment or feature flag.
// config/rate_limits.php
return [
'api' => [
'limit' => env('API_RATE_LIMIT', 100),
'interval' => env('API_RATE_INTERVAL', '1 minute'),
'policy' => env('API_RATE_POLICY', 'token_bucket'), // Explicitly set policy
],
'auth' => [
'limit' => 5,
'interval' => '5 minutes',
],
];
// In middleware:
$config = config('rate_limits.api');
$limiter = (new RateLimiterFactory([
'id' => 'api',
'policy' => $config['policy'],
'limit' => $config['limit'],
'rate' => ['interval' => $config['interval']],
], $storage))->create();
Security Note: Validate serialized data if using custom storage. The PHPStan rule (Unsafe unserialize) helps detect potential risks in deserialization logic.
Use Case: Allow short bursts above the steady-state rate (e.g. webhook fan-in, batch imports).
$factory = new RateLimiterFactory([
'id' => 'webhooks',
'policy' => 'token_bucket',
'limit' => 60, // steady-state refill target
'burst' => 20, // short-lived headroom
'rate' => ['interval' => '1 minute'],
], $storage);
burst as a hint, not a guarantee — assert against the actual capacity the limiter reports (getLimit() / first-consume behaviour) rather than the configured number.limit + rate and use burst only for the headroom you genuinely need; oversized burst values are the ones most likely to be clamped.Storage Persistence
RedisStorage in production; InMemoryStorage does not survive the request lifecycle and gives you a limiter that never limits in real traffic.Token Bucket Edge Cases (Updated)
reserve()->wait() for blocking operations.symfony/rate-limiter alone is not enough on a framework that constrains it transitively. Check composer why symfony/rate-limiter and confirm the resolved version satisfies the patched constraint for your branch.Unsafe unserialize) flags unsafe practices:
// Avoid this in custom storage:
$data = unserialize($redis->get('limiter_data')); // Risky!
Time Synchronization
Retry-After Headers
DateTimeInterface to a Unix timestamp, not a formatted string:
->header('Retry-After', $limiter->getRetryAfter()->getTimestamp())
Compound Limiter Short-Circuiting
Security Patch Release Behaviour Change
v5.4.54 is both a security release and a behaviour change. The effective burst capacity is lower than in v5.4.47 and earlier. Pin the version in composer.json, run your rate-limit test suite, and only then roll forward — but do roll forward, since the pre-patch behaviour is the vulnerability.Inspect Tokens
$tokens = $limiter->getTokens();
$retryAfter = $limiter->getRetryAfter();
Log Rate Limit Events
if (!$limiter->consume(1)->isAccepted()) {
\Log::warning('Rate limit exceeded', [
'retry_after' => $limiter->getRetryAfter()->format('Y-m-d H:i:s'),
'remaining' => $limiter->getTokens(),
]);
}
Compare Configured vs. Effective Burst
\Log::debug('burst config', [
'configured' => config('rate_limits.webhooks.burst'),
'effective' => $limiter->getLimit(),
]);
Test Locally with InMemoryStorage
$storage = new InMemoryStorage();
$factory = new RateLimiterFactory([...], $storage);
$storage->delete('your_limiter_id');
Static Analysis for Security
Unsafe unserialize rule to catch serialization risks:
vendor/bin/phpstan analyse --level=7 src/
Confirm the Resolved Branch Version
composer show symfony/rate-limiter
>=5.4.54; on an 8.1 app, >=8.1.6. Anything else means the cap is not applied yet, whatever your root composer.json says.Custom Storage
Implement StorageInterface for database-backed storage.
Security Hardening
class JsonRedisStorage implements StorageInterface
{
public function load($id): array
{
$data = $this->redis->get($id);
return $data ? json_decode($data, true) : [];
}
public function save($id, array $data): void
{
$this->redis->set($id, json_encode($data));
}
}
Policy Validation
$validPolicies = ['token_bucket', 'fixed_window', 'sliding_window'];
if (!in_array($config['policy'], $validPolicies)) {
throw new \InvalidArgumentException('Invalid rate limit policy');
}
Capacity Assertions in Tests
public function assertBurstCapacity(RateLimiter $limiter, int $max): void
{
$this->assertLessThanOrEqual($max, $limiter->getLimit());
}
How can I help you explore Laravel packages today?