jacyimp/api-platform-rate-limiter
A Laravel/PHP rate limiter for API platforms, providing middleware-driven request throttling to protect endpoints and control traffic. Lightweight, easy to integrate, and configurable to fit existing API stacks.
Install the package and confirm which version you're on — v0.2.0 is a breaking release, so composer require on a fresh project gets you the new API while any pinned ^0.1 dependency will not behave the same.
composer require <vendor>/<package>
php artisan vendor:publish --tag=<package>-config # if the package ships config
Where to look first, in this order:
RateLimit value object constructor. It is now the entire public surface: limit, interval, cost, bucketResolver, plus the dynamic resolver options. Everything removed in v0.2.0 (DynamicLimit, DynamicCost, DynamicBucket, Identity, Condition, Interval) collapsed into named parameters here.class-string<T> — so the package's resolver interfaces are what you implement, not what you extend or configure.First use case — put a single limit on one resource and confirm you can trip it:
new Post(
extraProperties: [
new RateLimit(limit: 5, interval: '1 minute'),
],
);
Hit the endpoint six times, expect a 429 with rate-limit headers, then delete the limit. Do this before wiring anything else; it validates that the package can resolve identity in your app (see the Symfony Security gotcha below — this is the step where that bites).
Stack multiple windows instead of picking one. The v0.2.0 API is designed for composition, and this is now the idiomatic shape:
new Post(
extraProperties: [
new RateLimit(limit: 100, interval: '1 minute'),
new RateLimit(limit: 1_000, interval: '1 hour'),
new RateLimit(limit: 10_000, interval: '1 day'),
],
);
All declared limits must pass. There is no "first match wins" — think of them as independent counters, not tiers.
Resource-level default + operation-level tightening. Set a coarse ceiling once on the resource class, then add narrower limits on expensive operations (search, export, bulk write). Because resource-level and operation-level limits now combine rather than replace, the resource limit acts as a floor of protection you can't accidentally drop by adding a per-operation limit — but it also means an operation limit can never raise the ceiling.
Separate bucket from identity deliberately. The v0.2.0 semantics are explicit: identity is who is being limited, bucket is which counter they hit. For tenant-aware limits, use bucketResolver to derive the bucket (e.g. tenant:{id}) while identity stays the user or IP. Getting this backwards is how you end up with one tenant exhausting another tenant's quota or with per-tenant limits that are actually global.
new RateLimit(
limit: 1_000,
interval: '1 hour',
bucketResolver: TenantBucketResolver::class, // class-string<T>
);
Dynamic values as resolver class names. Anything that varies per request (cost of an operation, limit from a plan table) is a resolver class string, resolved from the container. Keep them stateless and cheap — a constructor that queries the database will run on every limited request.
Laravel integration tips:
throttle-style middleware groups and keep the package's limits as the business limits (plan quotas) — don't duplicate Laravel's built-in throttle for the same purpose.limit: 2) and asserting the 429 and headers, rather than hammering a realistic limit — and flush the cache store between tests, since counters survive RefreshDatabase.Migration pattern from v0.1.x: delete every Dynamic*, Identity, Condition, and Interval usage; fold each DynamicLimit into a RateLimit whose limit:/cost: is a resolver class-string; replace DynamicBucket with bucketResolver:; replace new Interval(...) with a string like '1 hour' or a native DateInterval; remove RateLimitChecking listeners and move their side effects into middleware or a 429 response handler.
Limits combine — audit your numbers after upgrading. If v0.1.x relied on an operation-level limit replacing a resource-level one, the effective quota in v0.2.0 is now the intersection. Expect newly over-restrictive endpoints immediately after the upgrade and check them before shipping.
Symfony Security is optional, and on Laravel it isn't installed. The release notes state authenticated Symfony users are limited by user identity when Security is present, and applications without it fall back cleanly to client IP. In a Laravel app that fallback is your default. If you want per-user limits, you must supply identity yourself via a resolver — otherwise every user behind a NAT behaves as one client.
The IP fallback makes TrustProxies load-bearing. Behind a load balancer, CDN, or API gateway, the resolved IP can be the proxy's, collapsing all traffic into a single bucket and causing "phantom" 429s under modest load. Check App\Http\Middleware\TrustProxies and the $proxies/$headers configuration before blaming the package.
RateLimitChecking is gone. Anything you hung off that event (metrics, logging, alerting on near-limit traffic, custom rejection payloads) needs a new home: a middleware after the limiter, a listener on Laravel's exception handler for ThrottleRequestsException/429 responses, or the package's response headers.
Interval strings vs DateInterval. Strings are the readable default ('1 minute', '1 hour', '1 day'). If you pass a native DateInterval, avoid month/year units — they aren't fixed durations and make the window boundary non-deterministic between requests. Stick to seconds/minutes/hours/days.
A resolver that returns an empty or null bucket silently shares a counter. Guard your bucketResolver implementations: a missing tenant ID shouldn't fall through to a global bucket. Fail loudly (throw) or return a distinct fallback key.
Resolvers are container-resolved per request. They can't hold request state across calls, and they can't be closures — they're class-string<T>, so the class must be autoloadable and constructible. This also means an unresolvable constructor dependency turns into a runtime error on a rate-limited route, not at boot; add a smoke test that hits each limited endpoint once.
Costs multiply against every declared window. A dynamic cost resolver returning 10 on a route with three windows decrements all three by 10. Verify that's what you want for batch endpoints before shipping a bulk import behind a limit.
Debugging checklist for an unexpected 429: confirm which identity resolved (log user ID vs IP), confirm the bucket key, confirm the proxy/TrustProxies setup, then confirm whether another limit on the same resource is the one tripping. Headers on the 429 (remaining/limit/reset, Retry-After) tell you which window fired faster than reading code.
Extension points that survived the simplification: resolver class strings and bucketResolver are now the only intended customisation surface. If you find yourself wanting a hook that no longer exists, the package's answer is composition — declare another RateLimit — not lifecycle events.
Pin the major. Given a 0.x line that just removed five public classes, constrain to ^0.2 and read the changelog before each minor.
How can I help you explore Laravel packages today?