Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Rate Limiter Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Minimal Setup

  1. 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.

  2. 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();
    
  3. 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);
        }
    }
    

Key Starting Points

  • Documentation: Symfony RateLimiter Docs
  • Storage Options: InMemoryStorage (dev/tests), RedisStorage (prod), custom StorageInterface for database-backed limiting.
  • Policies: token_bucket, fixed_window, sliding_window.
  • Upgrade note (v5.4.54 / v8.1.6, bug #65747): token-bucket burst size — and the duration derived from it — are now capped. Configurations with large explicit burst values will admit less traffic than before. Re-verify effective limits on both the 5.4 and 8.1 lines.
  • Security release: 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.
  • Security Note (v8.1.1): PHPStan rule for unsafe unserialize. Keep static analysis tooling updated to catch serialization risks in custom storages.

Implementation Patterns

1. Laravel Middleware Integration

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.

2. Queue Job Throttling

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.

3. Compound Rate Limiting

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.

4. CLI Command Rate Limiting

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.

5. Dynamic Limits via Config

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.

6. Token Bucket Bursting (Reviewed for v5.4.54 / v8.1.6)

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);
  • As of v5.4.54 and v8.1.6, the burst size and the duration computed from it are capped. Treat burst as a hint, not a guarantee — assert against the actual capacity the limiter reports (getLimit() / first-consume behaviour) rather than the configured number.
  • Prefer expressing throughput in limit + rate and use burst only for the headroom you genuinely need; oversized burst values are the ones most likely to be clamped.
  • If you operate both a 5.4 and an 8.x application, apply the same re-validation to both — the cap landed twice, on different branch timelines.

Gotchas and Tips

Pitfalls

  1. Storage Persistence

    • Use RedisStorage in production; InMemoryStorage does not survive the request lifecycle and gives you a limiter that never limits in real traffic.
  2. Token Bucket Edge Cases (Updated)

    • Negative tokens: Handle gracefully with reserve()->wait() for blocking operations.
    • Burst capping (v5.4.54 / v8.1.6): Burst size and the derived duration are now capped. Tests that asserted on previously-computed capacity may need their expectations adjusted. Do not hard-code the burst-derived window length — read it from the limiter.
    • Two upgrade paths: pinning 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.
    • Security: Avoid deserializing untrusted data. The PHPStan rule (Unsafe unserialize) flags unsafe practices:
      // Avoid this in custom storage:
      $data = unserialize($redis->get('limiter_data')); // Risky!
      
    • Fix: Use typed storage or validate data before deserialization.
  3. Time Synchronization

    • Ensure NTP synchronization across servers. Window boundaries are wall-clock derived; drift between nodes means inconsistent accept/reject decisions behind a load balancer.
  4. Retry-After Headers

    • Convert DateTimeInterface to a Unix timestamp, not a formatted string:
      ->header('Retry-After', $limiter->getRetryAfter()->getTimestamp())
      
  5. Compound Limiter Short-Circuiting

    • Ensure all sub-limiters are configured correctly; a misconfigured sub-limiter that always accepts masks the others.
  6. 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.

Debugging Tips

  1. Inspect Tokens

    $tokens = $limiter->getTokens();
    $retryAfter = $limiter->getRetryAfter();
    
  2. 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(),
        ]);
    }
    
  3. Compare Configured vs. Effective Burst

    • After upgrading, log both the config value and the limiter's reported capacity to spot clamping:
      \Log::debug('burst config', [
          'configured' => config('rate_limits.webhooks.burst'),
          'effective'  => $limiter->getLimit(),
      ]);
      
  4. Test Locally with InMemoryStorage

    $storage = new InMemoryStorage();
    $factory = new RateLimiterFactory([...], $storage);
    
    • Reset storage between tests:
      $storage->delete('your_limiter_id');
      
  5. Static Analysis for Security

    • Run PHPStan with the Unsafe unserialize rule to catch serialization risks:
      vendor/bin/phpstan analyse --level=7 src/
      
  6. Confirm the Resolved Branch Version

    composer show symfony/rate-limiter
    
    • On a 5.4 app you must see >=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.

Extension Points

  1. Custom Storage Implement StorageInterface for database-backed storage.

  2. Security Hardening

    • Avoid deserialization: Use JSON or native types for storage.
    • Example: Safe Redis storage with JSON:
      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));
          }
      }
      
  3. Policy Validation

    • Validate policies dynamically (e.g., via config):
      $validPolicies = ['token_bucket', 'fixed_window', 'sliding_window'];
      if (!in_array($config['policy'], $validPolicies)) {
          throw new \InvalidArgumentException('Invalid rate limit policy');
      }
      
  4. Capacity Assertions in Tests

    • Wrap burst expectations in a helper that reads effective capacity, so future capping changes don't break the suite:
      public function assertBurstCapacity(RateLimiter $limiter, int $max): void
      {
          $this->assertLessThanOrEqual($max, $limiter->getLimit());
      }
      
    • Assert at most the configured burst — never exactly the configured burst. That single convention is what makes the v5.4.54 / v8.1.6 clamp a non-event for your test suite.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log