An SMS provider outage makes a Laravel 13 app report thousands of identical exceptions a minute; how do you throttle that reporting with withExceptions()?
answer
- $exceptions->throttle(closure)
- Lottery::odds(1, 1000) samples
- Limit::perMinute(300)->by(...)
- null or Limit::none() means unthrottled
- decided before callbacks, counted in the cache
basics
~10 sRegister $exceptions->throttle() in bootstrap/app.php and return Limit::perMinute(n), optionally ->by() a key, for the SMS exception, or Lottery::odds(1, 1000) to sample. Returning null leaves other exceptions unthrottled.
solid answer
~40 sIn `bootstrap/app.php`, `$exceptions->throttle(function (Throwable $e) { ... })` returns, per exception, a `Lottery`, a `Limit` or `null`. A `Lottery::odds(1, 1000)` reports each occurrence with a one-in-a-thousand chance. A `Limit::perMinute(30)` lets 30 reports through per minute and drops the rest; it counts in Laravel's `RateLimiter`, which lives in the cache, under a hashed key built from the exception class unless you call `->by($key)`. `null` or `Limit::none()` means unthrottled. Throttling is decided before the exception's own `report()`, the report callbacks and the logger, so an error tracker fed from those sees the reduced stream too. Keep keys bounded, because `->by($e->getMessage())` with ids in the message gives every failure its own budget, and remember that throttling hides volume: fixing the outage, with timeouts or a queue, is separate work.
code
php · 20 lines<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use App\Exceptions\DeliveryReceiptParseException;
use App\Exceptions\SmsGatewayException;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Lottery;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(web: __DIR__.'/../routes/web.php', health: '/up')
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->throttle(function (Throwable $e) {
return match (true) {
$e instanceof SmsGatewayException => Limit::perMinute(30)->by('sms:'.$e->provider),
$e instanceof DeliveryReceiptParseException => Lottery::odds(1, 100),
default => null, // everything else unthrottled
};
});
})->create();go deeper
Know that bootstrap/app.php can throttle how many exceptions of a type get logged during an outage.
Explain the three return values of the throttle closure, Lottery, Limit and null, and how the default Limit key is built.
Reason about the cache-backed counter, shared or per-machine budgets, failing open when the cache is down, bounded keys, and the lost failure count.
Weigh sampling against rate limits across services, and make sure throttled reporting is paired with metrics and with fixing the dependency.
## The problem: a flood of identical reports When an external dependency such as an SMS gateway goes down, every request or queued job that calls it throws the same exception. Each one is reported: written to the log and forwarded to any error tracker hooked into the handler. Thousands of identical entries a minute fill disks, cost money on paid tracking plans and bury the one different error you need to see. Laravel's exception handler can **throttle reporting** without touching how the failure is handled or rendered. ## Registering a throttle callback In `bootstrap/app.php`, `$exceptions->throttle(callable $throttleUsing)` registers a closure. Like report callbacks, it is matched by its **first-parameter type-hint**, and it returns one of three things for the exception it receives: | Return value | Effect | |---|---| | `Lottery::odds(1, 1000)` | Random sampling: each occurrence is reported with a 1-in-1000 chance | | `Limit::perMinute(300)` | Rate limit: up to 300 reports per window, the rest dropped | | `Limit::perMinute(300)->by($key)` | Same, counted under your own key | | `null` | No opinion: the next throttle callback is asked, and if none answers, the exception is unthrottled | | `Limit::none()` | Explicitly unthrottled | `Lottery` is `Illuminate\Support\Lottery`; `Limit` is `Illuminate\Cache\RateLimiting\Limit`, the same class route rate limiters use, so `perSecond()`, `perHour()` and `perDay()` work too. ## How a Limit is counted For a `Limit`, the handler calls `RateLimiter::attempt()` with: 1. a key: the value of `->by()` if set, otherwise `illuminate:foundation:exceptions:` plus the exception's class name, then hashed with `xxh128`; 2. the limit's maximum attempts and its decay window in seconds. When the window's budget is used up, `attempt()` returns `false` and the exception is treated as "should not report". The `RateLimiter` stores its counters in the **cache**: the default store, or the one named by the `limiter` key in `config/cache.php`. That has consequences: - With a shared store such as Redis or the database, the budget is shared by every web server and queue worker. - With a per-machine store such as `file`, each machine has its own budget. - The whole throttle decision runs inside `rescue()` with reporting disabled, and a failure there means "report". If the cache store throws, for example because the database behind the skeleton's `database` cache store is the thing that is down, the handler **fails open** and reports unthrottled. ## Where throttling sits in the pipeline Throttling is the last of the "should this be reported?" checks, after deduplication, `ShouldntReport`, the `dontReport` lists and `dontReportWhen`. A dropped exception therefore never reaches: - its own `report()` method, - any report callback registered with `$exceptions->report()`, - the default logger. Anything that listens on those paths, including error-tracking integrations, sees only what passed the throttle. Nothing records how many were dropped, so if you need the true failure rate, count it somewhere the throttle does not sit in front of, such as a metric incremented where the SMS call fails. ## Choosing keys and numbers - Keep keys **bounded**. `->by($e->getMessage())` is fine for a fixed message, but a message that embeds a phone number or message id gives every failure its own budget and throttles nothing. - Key by the dimension you want visibility on, such as the provider name, so an outage of one gateway does not use up the budget of another. - Prefer `Limit` for outage floods, since you are guaranteed the first reports of each window, and `Lottery` for steady high-volume noise, where a sample is enough. - Leave everything else unthrottled by returning `null` for types you do not recognise. - Remember the window: a `Limit` starts counting at the first report and, once the budget is spent, drops everything until the decay window expires; then a fresh budget begins. A `perMinute(30)` therefore gives you a burst of 30 at the top of each window, not one report every two seconds. ## What throttling does not do Throttling changes what is **recorded**, not what **happens**. The user still gets the same error response, the job still fails or retries, and the gateway is still being hammered. Pair it with handling the outage itself: short HTTP timeouts, moving SMS sending into queued jobs with backoff, and a fallback provider. Lowering the exception's level with `level()` so it does not page anyone is another complementary step.
- Where is a throttle Limit counted, and what happens if that store is unavailable?In Laravel's `RateLimiter`, which keeps counters in the cache: the default store or the one `cache.limiter` names. The throttle decision is wrapped in `rescue()`, and a failure is treated as 'report', so if the cache store throws, exceptions are reported unthrottled.
- Why is Limit::perMinute(30)->by($e->getMessage()) risky for SMS failures?Each distinct key gets its own budget. If the message embeds a phone number or message id, every failure has a unique key and the limit never triggers. Key by something bounded, such as the provider or the exception class.
- Does a throttle reduce what an error-tracking integration receives?Yes, when the integration hooks into the handler's report path, as report callbacks and log channels do. Throttling is decided before the exception's `report()` method, the callbacks and the logger, so a dropped exception reaches none of them.
saying these in an interview costs you the question
- throttle() limits how often the exception is thrown or handled
- Returning null from the throttle closure drops the exception
- Lottery::odds(1, 1000) reports exactly every thousandth occurrence
- Limit counters live in PHP memory for the current request
- Report callbacks still receive every throttled exception
- Keying the limit by a message that contains ids is harmless