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.1 || ^13.0
- monolog/monolog: ^3.0
- psr/log: ^1.0 || ^2.0 || ^3.0
Requires (Dev)
- 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-limit reported exceptions and repeated log lines without going blind — Laravel Pulse still counts every one.
Requires PHP 8.2 and Laravel 12.1 or newer.
Install
composer require ismailnakkar/laravel-flood-control
That is the whole install: no bootstrap/app.php edit, no change at the call site. Defaults to 10
reports per exception class per 5 minutes.
php artisan vendor:publish --tag=flood-control-config # to change them
Exceptions
// config/flood-control.php 'enabled' => env('FLOOD_CONTROL_ENABLED', true), // false stops exception throttling 'limit' => env('FLOOD_CONTROL_LIMIT', 10), 'window' => env('FLOOD_CONTROL_WINDOW', 300), 'classes' => [ \Illuminate\Database\QueryException::class => ['limit' => 1, 'window' => 3600], \Throwable::class => ['limit' => 20], // catch-all: last ],
- An entry covers everything under it, class or interface, and a subtype beats its supertype. Unrelated entries are tried in the order written, so put a catch-all last.
- An entry sets the budget, not the bucket. The bucket is always the concrete class, so a catch-all of 20 gives each subclass 20 of its own.
- A limit or window below 1 means no limit, not never report. To silence a class, use
$exceptions->dontReport(). - Budgets are arrays, not
Limitobjects —config:cachewrites config withvar_export(), andLimithas no__set_state(). Malformed entries throw at boot, naming the entry; the check is skipped once config is cached. - A
Budgetworks here too, if you prefer it to the array —QueryException::class => Budget::perHour(3). It survivesconfig:cache. Arrays stay the default because the top-levellimitandwindoware env-driven and read better that way.
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 goes through Context::scope(), so it reaches the log record and the Sentry event and is
restored afterwards. A per-call limit caps that one report without reserving the class bucket; it is
spent when the report reaches the throttle, and it survives $exceptions->map(). by() replaces the
bucket, which is how you throttle per tenant. With neither argument, use report($e).
A message that needs an issue
Not every alert starts life as a throwable:
Report::error('Stripe webhook signature verification failed', ['gateway' => 'stripe']);
It goes through the gate like any other report and writes the same log line report() would have
written. The bucket is the call site, not the exception type, so one noisy caller cannot spend
another's budget — and a previous is evidence, not identity, so it does not move the bucket either:
Report::error('Feed fetch failed', ['feed' => $id], previous: $e);
Chaining beats flattening the cause into context: the sink keeps its stack trace.
A line with its own pace — a circuit breaker you only want to hear from twice a day — takes a
Budget. It changes the pace, never the bucket, so a slow line still cannot be spent by a busy one:
Report::error('Payment circuit breaker opened', budget: Budget::perDay(2));
To budget them all together instead, name the type in classes:
\FloodControl\OperationalError::class => ['limit' => 3, 'window' => 600],
Subclass OperationalError when a subsystem deserves its own name and grouping in the sink, and
report that with Report::exception(). The classes entry above still covers it — entries match
subtypes — but the bucket becomes the class rather than the call site.
For narration that only ever belongs in the file, use LogThrottle::once() instead.
Log lines
For narration that is not an exception — a denied origin, a circuit opening, a feed going stale:
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');
once(string $key, ?int $seconds, LoggerInterface|UnitEnum|array|string|null $channel = null) times(string $key, Budget $budget, LoggerInterface|UnitEnum|array|string|null $channel = null)
Which of the two
| How it gates | What you get | |
|---|---|---|
once() |
one Cache::add() |
exactly one line per window, even when workers race for it |
times() |
a rate-limiter counter | about N — a concurrent burst can let an extra one through |
Cache::add() is a single store operation, so it cannot be raced. times() has to read a counter
and then increment it, and two workers can both read N-1 — the same trade the exception gate already
makes. So once() is the default: reach for times() only when you want several samples because
each carries different context, and an approximate count is fine.
use FloodControl\Budget; LogThrottle::times('shadow-source-failed', Budget::of(3, 300))->warning('Shadow source failed'); LogThrottle::times('feed-stale', Budget::perHour(2))->warning('Feed is stale');
Budget::of(3, 600) Budget::perMinute(3) Budget::perHour(1) Budget::perDay(5) Budget::unlimited()
Either way
The first calls in the window return the real logger and the rest return one that discards. Every PSR
level, withContext() and array messages work on both — only listen() differs, which throws on the
discarding one. A discarded line fires no MessageLogged, so it stays out of Telescope and Sentry
breadcrumbs too.
$channel takes anything the log manager does: a channel or stack name, an array for an on-demand
stack, an enum, a logger you already hold, or null for the default.
Both gate on the cache.limiter store, so the window holds across workers rather than per process. A
null or sub-1 window never throttles, and a cache failure lets the line through — an outage is
exactly when the line matters. This half reads no flood-control config: the window is the one you
pass, and FLOOD_CONTROL_ENABLED does not reach it.
The key is yours to pick — a log line has no class to key on. Keep it a literal or a code-owned value; a key built from client-supplied text is unbounded cache cardinality.
Keeping a rate signal
The gate runs inside shouldntReport(), ahead of every reportable. Anything counting exceptions from
a reportable therefore counts what survived the throttle, not what happened.
Pulse is handled for you. The package binds Pulse's Exceptions recorder to one that counts in
front of the gate instead of behind it. Recording is still Pulse's own recorder, so the card, your
ignore, sample_rate and location settings, and Pulse::report() are unchanged. No Pulse config
is written and Pulse's own off switches still win. php artisan about shows the swap, and
FLOOD_CONTROL_PULSE=false turns it off. With laravel/pulse absent nothing is registered at all.
Your own counters go in config:
// config/flood-control.php 'counters' => [\App\Reporting\CountExceptions::class],
class CountExceptions { public function __invoke(Throwable $e): void { Metrics::increment('exceptions', $e::class); } }
Each is resolved from the container per exception and sees everything, throttled or not. Throws are swallowed, a counter that reports is skipped rather than recursing, and a typo'd or non-invokable class throws at boot.
Counting hangs off dontReportWhen(), which shouldntReport() runs ahead of the whole throttle chain
— so your own $exceptions->throttle() can return a Limit and replace the gate outright without
costing you the rate signal.
The rule: a counter wants the true rate, so it goes here. A sink wants fewer events, so it
stays a reportable and gets the throttled stream, which needs no configuration at all.
Wiring a sink
Sentry, Flare, Bugsnag and Rollbar are reportables, so they already sit behind the gate and need
nothing from this package. Their log channel is a different hookup, and the difference matters:
| Hookup | Catches | Behind the gate? |
|---|---|---|
Reportable — Integration::handles($exceptions) |
reported throwables | yes |
Log channel in LOG_STACK |
reported throwables and every log line at that channel's level | no |
Wire both and every exception arrives twice: a reportable returning void does not stop the chain —
the handler short-circuits only on === false — so report() still falls through to the log write.
Prefer the reportable. The log channel looks like the wider net, and that is exactly the trap: the
log lines it carries never pass through report(), so the gate never sees them. One Log::info() on
a hot path becomes one issue per request. Worse, if the channel declares no level of its own,
sentry-laravel's handler defaults to DEBUG and takes everything you log.
To keep both — exceptions through the reportable, plain log lines through the channel — give the channel a level and stop it handling exceptions:
'sentry' => [ 'driver' => 'sentry', 'level' => env('SENTRY_CHANNEL_LEVEL', 'error'), 'report_exceptions' => false, ],
Note what that second key does: it drops the whole record when it carries an exception key, so
Log::error($msg, ['exception' => $e]) silently stops reaching the sink. Route those through
Report::exception() or Report::error() instead — both go through the gate.
Testing
composer test # phpunit composer check # pint --test, then phpunit
A bare Log::spy() is enough — the throttle resolves the log manager, not the driver behind it, so
there is nothing to stub:
Log::spy(); LogThrottle::once('origin-denied', 60)->warning('Origin not allowed'); Log::shouldHaveReceived('warning')->with('Origin not allowed')->once();
Upgrading to 2.0
ThrottleConfig::for() returns a Budget instead of array{limit: int, window: int}. Nothing else
changed: config stays arrays, every option keeps its name, and both throttles behave as before.
['limit' => $limit, 'window' => $window] = $config->for($e); // 1.x $budget = $config->for($e); // 2.0 — $budget->times, ->seconds
Budget is where "below 1 means no limit, not never" now lives, instead of being spelled out
separately in each throttle. Budget::toLimit($key) builds the Limit the handler consumes.