PHPackages                             ismailnakkar/laravel-flood-control - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Logging &amp; Monitoring](/categories/logging)
4. /
5. ismailnakkar/laravel-flood-control

ActiveLibrary[Logging &amp; Monitoring](/categories/logging)

ismailnakkar/laravel-flood-control
==================================

Rate-limit reported exceptions and repeated log lines in Laravel.

v1.0.0(today)00MITPHPPHP ^8.2CI passing

Since Aug 24Pushed todayCompare

[ Source](https://github.com/ismailnakkar/laravel-flood-control)[ Packagist](https://packagist.org/packages/ismailnakkar/laravel-flood-control)[ Docs](https://github.com/ismailnakkar/laravel-flood-control)[ RSS](/packages/ismailnakkar-laravel-flood-control/feed)WikiDiscussions main Synced today

READMEChangelogDependencies (7)Versions (2)Used By (0)

laravel-flood-control
=====================

[](#laravel-flood-control)

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
-------

[](#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
----------

[](#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 `Limit` objects — `config:cache` writes config with `var_export()`. Malformed entries throw at boot, naming the entry; the check is skipped once config is cached.

### Per call

[](#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

[](#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. To budget these together, 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
---------

[](#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)
```

The first call in the window returns the real logger, the rest return one that discards. Both are an `Illuminate\Log\Logger`, so every PSR level, `withContext()` and array messages work either way — 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.

The gate is `Cache::add()` on the `cache.limiter` store, so it holds across workers rather than per process. A `null` or sub-1 window never throttles, and a cache failure lets the line through. 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
---------------------

[](#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
-------------

[](#wiring-a-sink)

Sentry, Flare, Bugsnag and Rollbar are `reportable`s, so they already sit behind the gate and need nothing from this package. Their **log channel** is a different hookup, and the difference matters:

HookupCatchesBehind the gate?Reportable — `Integration::handles($exceptions)`reported throwablesyesLog channel in `LOG_STACK`reported throwables **and every log line at that channel's level**noWire 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
-------

[](#testing)

```
composer test     # phpunit
composer check    # pint --test, then phpunit
```

`LogThrottle::once()` resolves through `Log::driver()`, which a bare `Log::spy()` stubs to `null`:

```
Log::spy()->shouldReceive('driver')->andReturnSelf();
```

###  Health Score

39

—

LowBetter than 84% of packages

Maintenance100

Actively maintained with recent releases

Popularity0

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity45

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

0d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/46094663?v=4)[Ismail Nakkar](/maintainers/ismailnakkar)[@ismailnakkar](https://github.com/ismailnakkar)

---

Top Contributors

[![ismailnakkar](https://avatars.githubusercontent.com/u/46094663?v=4)](https://github.com/ismailnakkar "ismailnakkar (6 commits)")

---

Tags

laravelloggingexceptionssentryrate limitthrottlepulse

###  Code Quality

TestsPHPUnit

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/ismailnakkar-laravel-flood-control/health.svg)

```
[![Health](https://phpackages.com/badges/ismailnakkar-laravel-flood-control/health.svg)](https://phpackages.com/packages/ismailnakkar-laravel-flood-control)
```

###  Alternatives

[laravel/framework

The Laravel Framework.

34.9k556.2M21.6k](/packages/laravel-framework)[matomo/matomo

Matomo is the leading Free/Libre open analytics platform

21.7k39.6k](/packages/matomo-matomo)[laravel/nightwatch

The official Laravel Nightwatch package.

37111.6M46](/packages/laravel-nightwatch)[tempest/framework

The PHP framework that gets out of your way.

2.3k37.6k21](/packages/tempest-framework)[illuminate/log

The Illuminate Log package.

6225.7M698](/packages/illuminate-log)[flow-php/flow

PHP ETL - Extract Transform Load - Data processing framework

86337.5k](/packages/flow-php-flow)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
