syropian/laravel-notification-channel-throttling
A Laravel package that adds throttling to notification channels, letting you limit how often notifications are sent per channel or notifiable. Configure caps to prevent spam and duplicate alerts while keeping existing notification workflows intact.
syropian/laravel-notification-channel-throttling is a small, single-purpose package: it lets you wrap any Laravel notification channel so that deliveries to a given notifiable are rate limited (e.g. "at most 1 email per 10 minutes per user"). It is not a queue middleware or a mail transport — it slots into the via() method of a notification, which makes it easy to adopt on one noisy notification without touching the rest of your app.
Minimal path to first use:
composer require syropian/laravel-notification-channel-throttling — the service provider is auto-discovered, so there is nothing to register.php artisan vendor:publish --tag=notification-channel-throttling) and skim it; the defaults are usually the first thing you want to change (limit + decay window).src/ThrottledChannel.php before writing any code. The whole surface area is one class with a constructor and a send() that delegates to the real channel behind a RateLimiter check — five minutes of reading removes all ambiguity about argument order and units.use Syropian\NotificationChannelThrottling\ThrottledChannel;
public function via($notifiable): array
{
return [
// Confirm exact signature in the README; the idea is
// (channel, maxAttempts, decaySeconds).
(new ThrottledChannel('mail'))->withLimit(1, 600),
'database', // leave the inbox unthrottled
];
}
Upgrading from v1.0.2 is a plain composer update; v1.0.3 only widens the framework constraint (illuminate/* for Laravel 13). There are no API changes, so the same code runs on Laravel 11, 12 and 13.
Wrap only the channels that need it. via() returns an array, so throttling is opt-in per channel. A very common shape is: throttle mail and sms, never throttle database (the in-app inbox should still show every event — throttling it just loses data). Decide this explicitly per notification rather than globally.
Centralise the numbers. Hard-coding withLimit(1, 600) in twelve notifications is a maintenance trap. Either put limits in a config file keyed by notification class + channel, or (cleaner) expose a throttleKey() / throttleLimit() method on a base notification class so each notification declares its own policy in one place.
Ordering with queues. The rate limiter is consulted at the point the channel actually sends. If your notifications are queued with delays or retries, the "window" is measured from send time, not dispatch time — a burst of dispatches during a traffic spike can still all land inside one window if the workers drain them slowly. For genuinely bursty sources, pair this with ShouldBeUnique or a RateLimited job middleware so you drop work early instead of at the channel boundary.
Keying. The limiter key is derived from the notifiable's morph identity plus the channel, which is the behaviour you want for "per user, per channel". Two things to verify against your own code: (a) on-demand / anonymous notifiables (Notification::route('mail', …)) key off the route rather than a model, and (b) if the same user should share a budget across several notifications, you need a custom key — sharing a limiter name across notifications is the extension point to look for.
Testing. Freeze time or set a very short decay, because throttling is time-based and flaky tests here are almost always cache state leaking between tests:
protected function setUp(): void
{
parent::setUp();
Notification::fake();
RateLimiter::clear('/* your key */');
}
Assert on the second send being a no-op rather than on a thrown exception — see the gotcha below.
Integration tip. Because it is just a channel, it composes with everything else: Notification::fake() assertions still behave normally for the first (allowed) send, and it works with on-demand notifications, Notifiable traits and multi-channel fan-out without any changes to your notifiable models.
src/ for an event class before assuming there is one.array works in tests but is per-process; if you run multiple queue workers or web nodes, the store must be shared (redis, database, memcached). A misconfigured per-node store makes throttling silently ineffective in exactly the multi-worker setup where you need it most.RateLimiter::attempt takes decaySeconds. Some throttle packages take minutes. Read the constructor once, write a test that asserts a send is allowed again after travel(), and stop thinking about it.illuminate/support constraints pinning you to 12, not this one. Run composer why-not laravel/framework ^13.0 if the resolver fights you.RateLimiter::clear($key) — does it start working? (3) is the notifiable identity the same object/key you expect (morph map aliases change the key!)? (4) is the wrapped channel name string 'mail' vs the FQCN MailChannel::class — the two produce different limiter keys and different results.How can I help you explore Laravel packages today?