Skip to content

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-timeouts health 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's default_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 apcu health 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:health in the command line says it cannot tell (#100).
  • The dashboard health check warns when nobody can open the dashboard: outside the local environment, the app defines neither viewDynamicThrottle nor manageDynamicThrottle, so every visitor gets a 403 that says nothing about why. --strict then 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.throttle fails 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 of cli:unknown. Without the posix extension, the actor now falls back to the USERNAME environment variable after USER (#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:health reported 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-config tag)
    • the DYNAMIC_THROTTLE_ENABLED kill switch
    • a "Dynamic Throttle" section in php artisan about
  • The counter engine:
    • a sliding-window counter, with an exact Retry-After
    • the LimiterStore contract: 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_connection and prefix
    • StorageUnavailableException for every storage failure
  • 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 with php artisan vendor:publish --tag=dynamic-throttle-migrations, then run php artisan migrate.
  • The Rule model, and the backed enums RequestMethod, Scope, RuleMode, FailurePolicy and MissingIdentity.
  • 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:compile compiles 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_seconds instead 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_seconds 0 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, and GET rules also match HEAD. 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.
  • Scopes decide who a rule counts a request for:
    • ip, user, api_token, header and global, plus custom scopes that you register with DynamicThrottle::scope('tenant', fn (Request $request) => ...) or a ScopeResolver class. 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 user scope counts the user the request authenticated as, without a lookup of its own. scopes.user.guards lists guards to ask instead.
    • A request without an identity counts under its IP, or skips the rule, as the rule's on_missing_identity says.
    • 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 with php artisan vendor:publish --tag=dynamic-throttle-migrations, then run php artisan migrate. Until you do, the rules compile without exemptions, and the missing table is reported.
    • The Exemption model: Exemption::forIp('10.0.0.0/8'), Exemption::forUser($user) or Exemption::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 user scope 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:compile also prints the exemptions it compiled and skipped.
  • The dynamic.throttle route 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, auth and auth.basic, and after the middleware you list in middleware.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-cookie and up), the package's own routes, and every request while enabled is off.
    • A limited request gets Laravel's 429 through your exception handler, or the rule's response_override as its JSON body, with Retry-After, X-RateLimit-Reset, X-RateLimit-Limit and X-RateLimit-Remaining. An allowed request gets X-RateLimit-Limit and X-RateLimit-Remaining from 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.
  • Emergency controls: runtime settings that change every rule without a deploy, and end by themselves (ADR-0011).
    • php artisan dynamic-throttle:bypass on|off lets every request through, unlimited. dynamic-throttle:scale <factor>|off multiplies every rule's limit by 0.1 to 10, rounding up, dry-run rules too.
    • --for sets how long, such as 30m, 2h or 1d, and one hour without it. --forever sets 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 --force skips it.
    • The table dynamic_throttle_settings: publish the migrations and run php artisan migrate. Until you do, the rules compile without settings, and the missing table is reported.
  • php artisan dynamic-throttle:list shows every rule with its id, and dynamic-throttle:disable-rule and enable-rule switch one off or on.
  • php artisan dynamic-throttle:status shows 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/orders shows 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, --guard and --header describe the client, and --json prints 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 RateLimitWouldBlock event: 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-After it 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: closed answers 503 (ServiceUnavailableHttpException, rendered by your exception handler) with a Retry-After when 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 StorageUnavailable event, 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 follow on_failure.
  • 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-metrics moves the complete minutes into the table dynamic_throttle_metrics_hourly, one row per rule and hour in UTC, read by the HourlyMetric model. It adds each minute once, even when it runs again or on two servers at once. Publish the migrations and run php artisan migrate.
    • php artisan dynamic-throttle:prune --hours=720 deletes 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) and telemetry.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 RateLimitExceeded event, dispatched after the response for each request an enforcing rule answers 429, with the same values as RateLimitWouldBlock. A request's events go out in order: the would-blocks, then the 429, then StorageUnavailable.
  • The RuleChanged event, 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:health looks 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-ip and ignored.
    • emergency warns about bypass or a scale factor that servers apply with no end time, or with one more than 24 hours away, so --strict in 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, default dynamic-throttle), domain (DYNAMIC_THROTTLE_DASHBOARD_DOMAIN), middleware (default ['web']) and rate_limit (default 120 a minute).
    • The gates viewDynamicThrottle and manageDynamicThrottle. Until your app defines them, only the local environment 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.throttle as global middleware. Laravel's own throttle limits 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": true for 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.
  • 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/audit reads 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/rules takes search, enabled and mode.
    • 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/exemptions takes state, subject and rule=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/feed reads them, and ?since= only the new ones. Config key telemetry.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> in dashboard.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, error or skipped, with what to do, or --json.
    • It exits 1 when a check errs, and with --strict when one warns too. It changes nothing.
  • 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.

Released under the MIT License. An independent package, not affiliated with or endorsed by Laravel.