Changelog
All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. Before 1.0, minor versions may contain breaking changes; they are called out under Changed with an upgrade note.
Unreleased
Changed
- The documentation site is for the package's users: it no longer shows the design decisions (ADRs), and its pages no longer link to editing them on GitHub. The ADRs stay in the repository, and the guide's links to them open them on GitHub.
0.1.0-beta.2 - 2026-10-25
The second beta: three new health checks, a documentation site, and fixes for Windows, for a Redis that hangs and for the dashboard's light theme. Upgrading needs no change to your app. But dynamic-throttle:health --strict fails on any warning, and two of the new checks can warn from the command line: a Redis connection without timeouts (redis-timeouts; Laravel's default Redis config sets none), and a dashboard nobody can open outside the local environment (dashboard). A deploy script that runs it may stop on them.
Added
- The
redis-timeoutshealth check warns when a Redis connection that requests use, for the counters or the manifest, has no timeout, or one above a second, so a Redis that hangs holds requests that long or more. Without timeouts, phpredis waits PHP'sdefault_socket_timeout, 60 seconds by default, before the limits fail open. The docs now show a dedicated connection with 0.25-second timeouts (timeouts), and the runbook has a "Redis hangs" scenario (#99). - The
apcuhealth check warns when the web server's PHP, such as PHP-FPM, runs without APCu: every request then reads the rules from the cache first, and an outage costs every request a try. The dashboard's Settings page runs it in the web server;dynamic-throttle:healthin the command line says it cannot tell (#100). - The
dashboardhealth check warns when nobody can open the dashboard: outside thelocalenvironment, the app defines neitherviewDynamicThrottlenormanageDynamicThrottle, so every visitor gets a 403 that says nothing about why.--strictthen exits 1, so a deploy script catches a forgotten gate (SPEC-033). - The guide has a page on removing the package: first what names it, since a route that still runs
dynamic.throttlefails with a 500 once the package is gone, then the package. It lists what stays behind and how to drop the tables. - The documentation is also a website, with search: https://hanzala-afzal-dev.github.io/laravel-dynamic-throttle/. It shows the guide, the runbook and the design decisions, built from the same Markdown (ADR-0029).
Fixed
- On Windows, the audit log names the user who ran an Artisan command, such as
cli:Hanzala, instead ofcli:unknown. Without theposixextension, the actor now falls back to theUSERNAMEenvironment variable afterUSER(#96). - A request that cannot read the manifest no longer calls the cache again for the rebuild's lock, so a Redis that hangs no longer holds it for a second timeout (#99).
- The README's install steps now compile the rules once, with
php artisan dynamic-throttle:compile. Without it,dynamic-throttle:healthreported a missing manifest as an error right after install, until the scheduler or a first limited request built it. - In the dashboard's light theme, the warning colour is darker, amber-600, so the health cards' "Warnings", the traffic chart's would-block bars and the status dots stand out from the white cards: 3.19:1, where WCAG 2.1 AA asks 3:1 for large text and graphics. It was 2.15:1.
0.1.0-beta.1 - 2026-10-18
The first public beta.
Added
- Package skeleton for Laravel 12.69+ and 13.30+ on PHP 8.3+:
- an auto-discovered service provider
- a publishable config file (
dynamic-throttle-configtag) - the
DYNAMIC_THROTTLE_ENABLEDkill switch - a "Dynamic Throttle" section in
php artisan about
- The counter engine:
- a sliding-window counter, with an exact
Retry-After - the
LimiterStorecontract:attempt()counts a request,release()uncounts it when a later rule rejects it - a Redis and Valkey driver (Lua scripts, time from Redis
TIME) and a driver for any Laravel cache store - config keys
driver(auto,redis,cache),store,redis_connectionandprefix StorageUnavailableExceptionfor every storage failure
- a sliding-window counter, with an exact
- Config key
database.connection(DYNAMIC_THROTTLE_DB_CONNECTION): the connection for the package's tables. - The package's tables store their times in UTC, whatever your app's time zone, so a daylight-saving change cannot move an exemption's or a setting's end time by the repeated hour. The models give them back in your app's zone.
- A published config keeps working across upgrades: new nested defaults are added, and lists stay as you wrote them.
- The rules table,
dynamic_throttle_rules: publish it withphp artisan vendor:publish --tag=dynamic-throttle-migrations, then runphp artisan migrate. - The
Rulemodel, and the backed enumsRequestMethod,Scope,RuleMode,FailurePolicyandMissingIdentity. - The rules manifest: requests read the rules from the cache, never from the database.
- Saving a rule recompiles it after the transaction commits.
- Its cache key names its format, such as
dt:m:3, so during a rolling deploy, servers of two versions of the package keep one manifest each. php artisan dynamic-throttle:compilecompiles it from deploy scripts, and the scheduler runs it every five minutes, so a cleared cache heals itself (manifest.schedule).- Octane and queue workers keep it in memory, and check the cache every
manifest.refresh_seconds(DYNAMIC_THROTTLE_MANIFEST_REFRESH, default 2). - On PHP-FPM with the APCu extension, a server's workers share it in APCu, so a server reads the cache about once per
refresh_secondsinstead of on every request, and a request builds only the rules it matches. With 100 rules, a request takes about 0.27 ms less at p50 and 0.6 ms less at p95, and on Redis it saves a round trip. Each app of the server, and each installed version of the package, has an entry of its own.refresh_seconds0 turns it off. - When the cache cannot be read, the last manifest the process read, or with APCu the server read, still applies, so fail-closed rules keep answering 503 through an outage.
- Rule matching and stacking:
- A rule matches by method and by a pattern on the URL-decoded path.
*matches every method, andGETrules also matchHEAD. In patterns,*matches any characters, and a leading slash is ignored. - Every matching rule counts the request, in priority order. The first one that rejects it stops, and the others' counts are released. Dry-run rules never reject.
- A request is counted in one call to the counter store. On Redis and Valkey that is one Lua script, so one round trip, however many rules match. Redis Cluster, and the other cache stores, make one call per rule.
- Config key
matching.max_rules_per_request(DYNAMIC_THROTTLE_MAX_RULES, default 5): at most this many enforcing rules count a request, and as many dry-run rules. Rules that do not apply to the request take no slot, and adding a dry-run rule never switches off an enforcing one. Rules beyond the cap are ignored.
- A rule matches by method and by a pattern on the URL-decoded path.
- Scopes decide who a rule counts a request for:
ip,user,api_token,headerandglobal, plus custom scopes that you register withDynamicThrottle::scope('tenant', fn (Request $request) => ...)or aScopeResolverclass. Per-tenant limits are one rule with a custom scope.- IPv6 clients are grouped by
scopes.ip.ipv6_prefix(DYNAMIC_THROTTLE_IPV6_PREFIX, default 64), so rotating addresses inside a /64 does not escape a limit. IPv4 clients seen as IPv4-mapped (::ffff:…) or NAT64 (64:ff9b::…) addresses count as their IPv4 address. - By default, the
userscope counts the user the request authenticated as, without a lookup of its own.scopes.user.guardslists guards to ask instead. - A request without an identity counts under its IP, or skips the rule, as the rule's
on_missing_identitysays. - A scope that cannot be resolved, such as a custom resolver that throws, fails only the rules that use it. The other rules still count the request.
- Identities are hashed with a key derived from
APP_KEY, so no raw IP, token or user id reaches a cache key.
- Exemptions free a client from one rule, or from every rule, until an optional expiry:
- The table
dynamic_throttle_exemptions: publish its migration withphp artisan vendor:publish --tag=dynamic-throttle-migrations, then runphp artisan migrate. Until you do, the rules compile without exemptions, and the missing table is reported. - The
Exemptionmodel:Exemption::forIp('10.0.0.0/8'),Exemption::forUser($user)orExemption::forCustom('tenant', 'acme'), then->forRule($rule),->until($time)and->because('reason'). Deleting a rule deletes its exemptions. - An IP exemption names an address or a CIDR range, IPv4 or IPv6, and checks the client IP your trusted proxies give. A user exemption names the user the
userscope finds, and a custom one a custom scope's value, exactly. - Saving or deleting an exemption recompiles the manifest after the transaction commits. The manifest holds user and custom values only as hashes.
- A failed check, such as a custom resolver that throws, exempts nothing, so it can never switch a limit off.
php artisan dynamic-throttle:compilealso prints the exemptions it compiled and skipped.
- The table
- The
dynamic.throttleroute middleware applies the rules to requests:- It is attached nowhere until you add it to your route groups, or list them in
middleware.auto_attach. - It runs after Laravel's authentication middleware,
authandauth.basic, and after the middleware you list inmiddleware.after, wherever a group lists it. - It lets through, untouched, the paths in
except(by default Horizon, Telescope, Pulse, Nova, Livewire 3 and 4,broadcasting/auth,sanctum/csrf-cookieandup), the package's own routes, and every request whileenabledis off. - A limited request gets Laravel's 429 through your exception handler, or the rule's
response_overrideas its JSON body, withRetry-After,X-RateLimit-Reset,X-RateLimit-LimitandX-RateLimit-Remaining. An allowed request getsX-RateLimit-LimitandX-RateLimit-Remainingfrom its most restrictive enforcing rule. Dry-run rules set no headers. - When something fails while deciding, such as the counter store, a config value or one of your scope resolvers, the request goes through, unless a rule fails closed (below), and the error is reported.
- It is attached nowhere until you add it to your route groups, or list them in
- Emergency controls: runtime settings that change every rule without a deploy, and end by themselves (ADR-0011).
php artisan dynamic-throttle:bypass on|offlets every request through, unlimited.dynamic-throttle:scale <factor>|offmultiplies every rule's limit by 0.1 to 10, rounding up, dry-run rules too.--forsets how long, such as30m,2hor1d, and one hour without it.--foreversets no end time. Every server stops applying a setting at its end time, even without a compile.- In production, turning bypass on, a factor below 1 and disabling an enforcing rule ask for confirmation, and
--forceskips it. - The table
dynamic_throttle_settings: publish the migrations and runphp artisan migrate. Until you do, the rules compile without settings, and the missing table is reported.
php artisan dynamic-throttle:listshows every rule with its id, anddynamic-throttle:disable-ruleandenable-ruleswitch one off or on.php artisan dynamic-throttle:statusshows what servers apply: the kill switch, the driver, the manifest, and each setting with its end time. It exits 1 when the config is invalid, so a deploy script can stop on it.php artisan dynamic-throttle:explain GET /api/ordersshows what the middleware does with a request, without counting it: the rules it matches, in order, who the client counts as under each, the counters and what remains, the route's middleware, and the decision the next request gets, with its headers.--ip,--user,--guardand--headerdescribe the client, and--jsonprints the explanation as JSON.- The audit log,
dynamic_throttle_audit_log: each change the commands make writes one append-only entry, with the actor, the action, the target, and the state before and after. DynamicThrottle::fake()for your app's tests: the counters, the manifest and the telemetry live in memory, so tests need no Redis and each one starts from zero.- The
RateLimitWouldBlockevent: a dry-run rule dispatches it, after the response, on exactly the requests an enforcing copy of the rule would reject.- One event for each dry-run rule that would block, in the order the rules run.
- It carries plain values, so queued listeners work: the rule's id, name, limit, window and scope, the client's identity hash, the
Retry-Afterit would have got, and the request's method and path. - A listener that throws is reported, and the other events are still dispatched.
- The failure policy:
- A rule with
on_failure: closedanswers 503 (ServiceUnavailableHttpException, rendered by your exception handler) with aRetry-Afterwhen its counter cannot be checked: the counter store fails, or the rule's identity cannot be resolved. The counts made before it are released when the store works. Dry-run rules never answer 503, and an earlier rule's 429 wins. - Every matching rule's identity is resolved before the store is called, so a scope resolver's error is reported even when an earlier rule rejects the request.
- Config key
failure.cooldown_seconds(DYNAMIC_THROTTLE_COOLDOWN, default 10): after the counter store fails, it is skipped for that long, so an outage costs one failed call per cooldown. The cooldown lives in the process, and in APCu when the extension is enabled, where a server's PHP-FPM workers share it. - Each cause of a failure is reported at most once per cooldown.
- A counter store that cannot be built, such as for an unknown
driver, counts as a store that failed. - The
StorageUnavailableevent, dispatched after the response when the counter store failed, at most once per request. It carries the error's class and message, whether it failed on a release, and the cooldown. DynamicThrottle::strict(): the middleware rethrows config mistakes and errors from your own code, such as a custom scope resolver that throws, instead of reporting them. Storage errors, the manifest and a client IP that cannot be read still followon_failure.
- A rule with
- Telemetry: after each response, the middleware records what each rule did with the request, per minute, in the counter store: its hits, blocks and would-be blocks.
- On Redis and Valkey, it also keeps each rule's 100 top offenders and its number of distinct clients, per hour. A request's numbers are one script call, after the response, or one call per key on Redis Cluster.
php artisan dynamic-throttle:flush-metricsmoves the complete minutes into the tabledynamic_throttle_metrics_hourly, one row per rule and hour in UTC, read by theHourlyMetricmodel. It adds each minute once, even when it runs again or on two servers at once. Publish the migrations and runphp artisan migrate.php artisan dynamic-throttle:prune --hours=720deletes the rows of hours that ended longer ago.- The scheduler runs the flush every minute and the prune daily (
telemetry.schedule,telemetry.retention_hours, default 720). The store keeps the numbers for 2 hours, so the scheduler must run. - Config keys
telemetry.enabled(DYNAMIC_THROTTLE_TELEMETRY) andtelemetry.sample_rate(DYNAMIC_THROTTLE_SAMPLE_RATE, from 0 to 1, default 1): a sampled request counts for as many as it stands for, so the totals stay right on average. - A write that fails is reported at most once per cooldown, never affects the request, and is skipped while the counter store's cooldown runs.
- The
RateLimitExceededevent, dispatched after the response for each request an enforcing rule answers 429, with the same values asRateLimitWouldBlock. A request's events go out in order: the would-blocks, then the 429, thenStorageUnavailable. - The
RuleChangedevent, dispatched after the commit when a rule is created, updated or deleted through the model, with the change and the changed columns before and after. php artisan dynamic-throttle:healthlooks for setup mistakes that limit less than you think, or nothing at all. It checks the config, the counter store,cache:clear, the app key, the routes, the manifest, an emergency setting left on, the flush, and what the rules saw in the last hour:config,store,cache-clear,app-key,attached,except,two-limiters,middleware-order,manifest,emergency,metrics,guests,dominant-ipandignored.emergencywarns about bypass or a scale factor that servers apply with no end time, or with one more than 24 hours away, so--strictin a deploy script catches a forgotten--forever.
- The dashboard's access, ahead of its pages:
- Config section
dashboard:enabled(DYNAMIC_THROTTLE_DASHBOARD),path(DYNAMIC_THROTTLE_DASHBOARD_PATH, defaultdynamic-throttle),domain(DYNAMIC_THROTTLE_DASHBOARD_DOMAIN),middleware(default['web']) andrate_limit(default 120 a minute). - The gates
viewDynamicThrottleandmanageDynamicThrottle. Until your app defines them, only thelocalenvironment gets in. A denied request gets a 403, never a redirect. - No rule ever applies to the dashboard, by its routes' names and by its path, also with
dynamic.throttleas global middleware. Laravel's ownthrottlelimits it per user, or per IP for a guest. - Its first endpoint,
GET {path}/api/me, says what the user may do. - Validation for what its API will save, so a saved rule always compiles, at most 500 enabled rules, and
"confirm": truefor dangerous changes: disabling, deleting or dry-running an enforcing rule, bypass on, a factor below 1, and an IP exemption wider than a /8 or a /32.
- Config section
- The dashboard's JSON API, under
{path}/api(internal until declared stable):- rules and exemptions: list, show, create, change and delete; bypass and the scale factor, as the CLI sets them;
- each rule's numbers per minute for the last hour, or per hour for a day or a week, its top offenders by identity hash, and the health report;
POST api/explain, with the view gate, which masks bearer tokens and header values;- every change writes one audit entry, with the user, the IP and the user agent, in the same transaction.
GET api/auditreads the audit log, newest first, filtered by action, target, actor and time.
- The dashboard's pages, at
/dynamic-throttle, with nothing to install or publish: React and shadcn/ui, built into the package (ADR-0028).- A sidebar, the app's environment and your role in the header, light and dark themes that follow your system, and a banner on every page while bypass or a scale factor is on, with a button to turn it off.
- The Overview: health, the rule counts, the last hour's blocks and would-blocks, a traffic chart over an hour, a day or a week, the top rules by blocks, and the controls for bypass and the scale factor, with a confirmation for the dangerous ones.
GET api/rules/counts: the enabled rules by mode, and the disabled ones.- The Rules pages: the list with search and filters, the form with templates, a rule's page with its traffic, offenders, exemptions and history, and promote, duplicate and delete.
GET api/rulestakessearch,enabledandmode.- The Exemptions page: the exemptions with their filters, and adding one for 1 hour to 30 days or with no end, or removing it.
GET api/exemptionstakesstate,subjectandrule=none.- The Settings page: every health check with what it looks for, the configuration servers run with, and the commands for when the dashboard is down.
GET api/config: the counters' driver, the manifest store, the package's config, the custom scopes and strict mode.- The Explain page: the decision a request gets, in words, its matching rules with their identities and counters, the route's middleware, and the context, without counting it. Its form's values travel in the body, never the URL.
- The Audit log page: every change, newest first, with what changed in one line and the full before and after on demand, filtered by action, target, actor and time in the page's URL, and 100 more at a time.
- The Activity page: the live feed of the last hour's blocks, new ones every 5 seconds, with pause and filters, "Exempt…" for a manager, and any rule's offenders this hour or the last.
- The live feed: on Redis and Valkey, telemetry keeps the last hour's blocks and would-blocks, the newest 500 a minute, with the client's IP and the user the request already held, never its path, a token or a header's value.
GET api/feedreads them, and?since=only the new ones. Config keytelemetry.live_feed(DYNAMIC_THROTTLE_LIVE_FEED, default true) turns it off. It costs no round trip: the entries go in the request's telemetry call. - A strict Content Security Policy,
X-Frame-Options: DENY, and assets cached for a year. - in a dashboard request, explain never takes the admin who sends it for the request it explains.
- behind
auth:<guard>indashboard.middleware, a user id alone still means a user of the app's default guard, in exemptions and in explain.
- The runbook,
docs/runbook.md: what to do when Redis is down, the manifest was evicted, a bad rule blocks/api, or an emergency change was left on. A test follows each scenario's steps. - On PHP-FPM with APCu, a manifest the cache cannot give is reported at most once per 30 seconds per server, as the counter store's errors are once per cooldown.
- It prints a line per check,
ok,warning,errororskipped, with what to do, or--json. - It exits 1 when a check errs, and with
--strictwhen one warns too. It changes nothing.
- It prints a line per check,
- Telemetry also records, per rule and minute, the guests a user rule counted, the requests a rule skipped for want of an identity, and those it was ignored for over the cap. On Redis and Valkey, it also counts each hour's requests per client. The health checks read them, and the hourly table does not change.