fusonic/rate-limit-bundle
A Symfony bundle for declarative rate limiting. Apply attributes to controllers and services, configure limits and intervals, and use flexible storage backends to throttle APIs and protect actions with minimal setup and clean Symfony integration.
Read this first: it is a Symfony bundle, not a Laravel package. There is no service provider, no config/rate_limit.php, no facade. If you composer require fusonic/rate-limit-bundle into a Laravel app, Composer will happily install it and nothing will happen — the bundle only activates inside a Symfony kernel via config/bundles.php. Treat this assessment as "what would I actually get out of this as a Laravel developer," which is: a reference implementation, not a dependency.
What changed in v4.0.0: the release notes are a single line — "Compatibility with PHP 8." That is the first release since 2020-12-11, and it is a major version bump, so treat it as a constraint change, not a feature release. No new limiter algorithms, no Symfony 6/7 work is claimed, no migration guide is offered.
Minimal steps if you are genuinely evaluating it:
composer show fusonic/rate-limit-bundle — check the abandoned flag and repo archive status. A one-line compat release does not mean maintenance resumed; look at commit dates and open-issue response before concluding otherwise.composer.json between v3.x and v4.0.0 before anything else. In a "PHP 8 compatibility" major, the payload is the constraint bump (php: ^7.x → ^8.0, Symfony floor raises, possibly symfony/* and PHPUnit). That diff is your breaking-change list; the one-line changelog will not tell you.src/DependencyInjection/Configuration.php before the README. The config schema in code is the source of truth; the docs drift, and they drifted for three years.EventSubscriber on kernel.request or similar) and the limiter/storage class. That pair is the entire package — it is small.config/bundles.php, add limits in config/packages/fusonic_rate_limit.yaml, clear the container cache, then hit an endpoint in a loop until you see the 429.Illustrative config shape (verify keys against Configuration.php):
# config/packages/fusonic_rate_limit.yaml
fusonic_rate_limit:
default:
limit: 100
interval: '1 minute'
routes:
app_login: { limit: 5, interval: '1 minute' }
app_password_reset: { limit: 3, interval: '15 minutes' }
First use case: protecting login and password-reset endpoints by route name. That is literally the package's pitch — "rate limiting based on routes" — and it's the one scenario where a route-keyed limiter beats a controller-level one.
The Laravel equivalent you should reach for today (this is what you'd actually ship):
// AppServiceProvider::boot()
RateLimiter::for('login', fn (Request $r) => Limit::perMinute(5)->by($r->ip()));
// routes/api.php
Route::post('/login', LoginController::class)->middleware('throttle:login');
Laravel's throttle middleware and the RateLimiter facade have covered this ground since 5.8. Symfony's own symfony/rate-limiter (5.1+) superseded community bundles like this one — and it has kept pace with Symfony 6/7. The honest reason to read this package is to borrow its keying and windowing choices, or because you've inherited a Symfony app that already depends on it. v4.0.0 removes the "won't even install on PHP 8" objection; it does not create a new reason to adopt it.
v4.0.0 is a compatibility release — the runtime behavior is unchanged. Every pattern below carries over from v3.x verbatim; only the composer constraints moved. Don't re-derive the design from the release note.
Route-name keying, not path keying. The bundle builds its counter key off the matched route, so two routes pointing at the same controller need two config entries, and a request that 404s (no matched route) may be unmetered entirely. If you're mirroring the behavior in Laravel, decide deliberately: key off $request->route()->getName() for precision, or $request->path() for coverage.
Layered limits. The pattern that works: a permissive global default, then tight overrides for auth, password reset, OTP send, invite emails, and webhook receive. Everything else inherits. Keep the overrides in one config block so you can audit them.
Per-client keying and the proxy trap. Nearly every implementation keys on REMOTE_ADDR + route. Behind a load balancer or CDN, REMOTE_ADDR is the balancer, so all users share one bucket and your first busy minute 429s the whole site. In Symfony this means Request::setTrustedProxies(); in Laravel, TrustProxies middleware. Validate this before you trust any load test.
Mirroring into Laravel. If you're porting the idea rather than the code, keep one source of truth for the limit map and express it as named limiters:
// config/rate_limits.php -> ['login' => [5, 'minute'], 'webhook' => [120, 'minute']]
foreach (config('rate_limits') as $name => [$max, $per]) {
RateLimiter::for($name, fn (Request $r) => Limit::per($per)->by($name.'|'.$r->ip()));
}
Then ->middleware('throttle:login'). This gives you the per-route granularity with Laravel's Redis-backed store.
Do not stack two limiters. If you're mid-migration and both the bundle's listener and a Laravel throttle middleware are active on the same route, you double-count and produce 429s that nobody can explain from either config. Pick one layer per route.
Standardize the 429 contract at the edge. Whichever layer emits it, normalize the body and set Retry-After. Clients that only ever see one shape are much easier to debug than "sometimes it's Symfony's HTML error page."
Testing. Feature-test it: issue N requests, assert 200s, then assert 429 on N+1. Freeze time (Carbon::setTestNow, or Symfony's clock) if the limiter uses fixed windows, otherwise a slow CI run can straddle a window boundary and flake. On the Symfony side, run the package's own test suite against the PHP version you actually deploy — the 4.0.0 CI matrix was very likely just 8.0.
Storage. If the bundled storage is in-memory/per-process, it does not survive PHP-FPM workers, and it definitely doesn't survive multiple app nodes. Anything production means Redis-backed counters — check what the bundle actually ships before assuming. The compat release did not modernize storage.
ReturnTypeWillChange deprecations, on 8.2 dynamic-property deprecations, on 8.4 implicit-nullable deprecations — all potentially fatal if your error handler escalates deprecations. Smoke-test on your exact runtime before pinning.^4.0, and read the major-version bump as a constraint change. The breaking change is that php: ^7.x consumers can no longer install it. If you're still on PHP 7.4 anywhere, stay on the 3.x line (or, better, don't add this dependency).abandoned, archive status, and unanswered issues. No stated Symfony 6/7 support means you may still be blocked at the framework layer even though PHP installs fine.bin/console cache:clear leaves you debugging a config that isn't loaded. Laravel devs are used to config:cache being optional; here it isn't.Configuration.php is authoritative for key names, defaults, and whether limits are integers or interval strings ('1 minute' vs 60 vs a \DateInterval). Read the code.RateLimiter::for() plus a custom cache store; there's no reason to route through a bundle for this.How can I help you explore Laravel packages today?