ismailnakkar / laravel-flood-control
Rate-limit reported exceptions and repeated log lines in Laravel.
Package info
github.com/ismailnakkar/laravel-flood-control
pkg:composer/ismailnakkar/laravel-flood-control
Requires
- php: ^8.2
- laravel/framework: ^12.21 || ^13.0
- monolog/monolog: ^3.0
- psr/log: ^1.0 || ^2.0 || ^3.0
Requires (Dev)
- larastan/larastan: ^3.12
- laravel/pint: ^1.18
- laravel/pulse: ^1.0
- orchestra/testbench: ^10.0 || ^11.0
- phpunit/phpunit: ^11.5 || ^12.0
Suggests
- laravel/pulse: Keeps an unthrottled exception rate signal alongside the throttled reports.
Provides
None
Conflicts
None
Replaces
None
README
Rate-limits reported exceptions and repeated log lines in Laravel, while Laravel Pulse and your own counters still see every reported exception.
Install
composer require ismailnakkar/laravel-flood-control
php artisan vendor:publish --tag=flood-control-config # optional
Requires PHP 8.2+, Laravel 12.21+ or 13, and Monolog 3. laravel/pulse 1.x is optional. The provider
is auto-discovered and wires itself into the exception handler: no bootstrap/app.php edit.
Throttle state lives in the cache.limiter store (the default store unless set). Use one every worker
and server shares, such as Redis or the database, or each process or server throttles on its own.
Configuration
| Key | Env | Default | Meaning |
|---|---|---|---|
enabled |
FLOOD_CONTROL_ENABLED |
true |
Exception throttling, per-call limits included. Counting and LogThrottle ignore it. |
limit |
FLOOD_CONTROL_LIMIT |
10 |
Reports per exception class per window. |
window |
FLOOD_CONTROL_WINDOW |
300 |
Window in seconds. |
classes |
[] |
Per-class budgets, below. | |
counters |
[] |
Your own counters, see Counting. | |
pulse |
FLOOD_CONTROL_PULSE |
true |
Pulse counts in front of the gate; false counts survivors only. |
Anywhere in this package, a limit or window below 1 means no limit, not never. To silence a class, use
$exceptions->dontReport().
Per-class budgets
'classes' => [ \Illuminate\Database\QueryException::class => ['limit' => 1, 'window' => 3600], \Throwable::class => ['limit' => 20], // catch-all: last ],
- An entry covers its subtypes (class or interface) and the most specific one wins. Unrelated entries are tried in order, so put a catch-all last.
- An entry sets the budget, not the bucket: each concrete class gets its own, so a catch-all of 20 gives every subclass 20.
- Absent keys fall back to
limitandwindow. AFloodControl\Budgetworks too (Budget::perHour(3)); aLimitdoes not surviveconfig:cache. - An unknown class or a malformed entry throws at boot, so
config:cachefails on it. Guard an optional package's exception:
...class_exists(\Aws\Exception\AwsException::class) ? [\Aws\Exception\AwsException::class => ['limit' => 1]] : [],
Exceptions
report($e) and uncaught exceptions are throttled per class with no change at the call site. The
gate runs in shouldntReport(), ahead of the log write and every reportable (Sentry, Flare, …). It
fails open: while the cache.limiter store is down, every exception is reported, so a budget on the
store's own exception (RedisException) caps a blip, not an outage.
Per call
use FloodControl\Report; use Illuminate\Cache\RateLimiting\Limit; Report::exception($e, ['analyzer' => $analyzer::class]); Report::exception($e, limit: Limit::perHour(1)); Report::exception($e, limit: Limit::perMinute(5)->by("tenant:{$tenant->id}"));
Context is scoped to that report (Context::scope()). A limit caps that one report, also when
$exceptions->map() wraps the exception as previous. Without by(), a class's per-call limits share
one bucket, apart from its plain reports, and the first hit sets its window; by() names the bucket
(per tenant, or across classes).
Messages
use FloodControl\Budget; use FloodControl\Report; Report::error('Stripe webhook signature verification failed', ['gateway' => 'stripe']); Report::error('Feed fetch failed', ['feed' => $id], previous: $e); Report::error('Payment circuit breaker opened', budget: Budget::perDay(2));
Reports the message as a FloodControl\OperationalError, log line included, bucketed per call
site so one noisy caller cannot spend another's budget. previous is chained (the sink keeps its
trace) and does not change the bucket. budget sets that call site's pace; without it, the budget
for OperationalError applies (the classes entry that matches it, a catch-all included, or the
default). Subclass OperationalError to give a subsystem its own name in the sink and report it with
Report::exception(): it is then bucketed per class.
Log lines
use FloodControl\Budget; use FloodControl\LogThrottle; LogThrottle::once('origin-denied', 60)->warning('Origin not allowed', ['origin' => $origin]); LogThrottle::once('feed-stale', 900, ['single', 'slack'])->warning('Feed is stale'); LogThrottle::times('shadow-source-failed', Budget::of(3, 300))->warning('Shadow source failed');
once(string $key, ?int $seconds, LoggerInterface|UnitEnum|array|string|null $channel = null): LoggerInterface times(string $key, Budget $budget, LoggerInterface|UnitEnum|array|string|null $channel = null): LoggerInterface
once()returns the real logger exactly once per window, even across workers (oneCache::add()).times()allows about N: workers racing its counter can let an extra one through. Preferonce()unless you want several samples.- Past the budget they return a logger that discards: every level,
withContext()and array messages work, and noMessageLoggedfires (nothing reaches Telescope or Sentry breadcrumbs). $channel: a channel or stack name, an array of channels (an on-demand stack), ornullfor the default.- A
nullwindow never throttles, a cache failure lets the line through, and noflood-controlconfig applies. - Keep keys code-owned: a key built from request data is unbounded cache cardinality.
Budget::of(3, 600), Budget::perMinute(3), Budget::perHour(), Budget::perDay(5),
Budget::unlimited().
Counting
A reportable that counts sees only survivors. Count in front of the gate instead:
- Pulse, when installed: the package binds Pulse's exceptions recorder to one that counts in front
of the gate and delegates recording, so Pulse's settings, off switches and
Pulse::report()work as before.php artisan aboutshows which way it counts. - Your counters: invokable classes in
counters, resolved from the container and called with every reported exception, throttled or not.
'counters' => [\App\Reporting\CountExceptions::class],
class CountExceptions { public function __invoke(Throwable $e): void { Metrics::increment('exceptions', $e::class); } }
A counter's throws are swallowed, a report made inside a counter is not counted again, and an unknown
or non-invokable class throws at boot. Counting runs on dontReportWhen(), ahead of the throttle
chain, so an $exceptions->throttle() of your own that returns a Limit replaces the gate without
hiding anything from counters.
Sinks
Sentry, Flare, Bugsnag and Rollbar register reportables, so they already sit behind the gate. A
sink's log channel in LOG_STACK also takes every plain log line at its level (debug up
without a level), and wired with the reportable the sink gets each exception twice. Prefer the
reportable; to keep the channel for plain log lines, give it a level and stop it handling exceptions:
'sentry' => [ 'driver' => 'sentry', 'level' => env('SENTRY_CHANNEL_LEVEL', 'error'), 'report_exceptions' => false, ],
report_exceptions => false drops any record with an exception key, so Log::error($msg, ['exception' => $e]) stops reaching the sink: use Report::exception() or Report::error().
Testing
The gate also runs under Exceptions::fake(): a test that reports one class more than its budget
sees only the first N. Set flood-control.enabled to false in that test.
A bare Log::spy() works with LogThrottle:
Log::spy(); LogThrottle::once('origin-denied', 60)->warning('Origin not allowed'); Log::shouldHaveReceived('warning')->with('Origin not allowed')->once();
Upgrading
From 2.1
Laravel 12.21 is the floor, for dontReportWhen(); 2.1 already failed below it. Nothing else changes.
Licence
MIT.