hmones/laravel-digest
Laravel Digest batches your app's notifications into scheduled email digests—daily, weekly, or custom intervals—so users receive one tidy summary instead of constant alerts. Configure channels, frequency, and templates with simple config and a fluent API.
Install and scaffold
composer require hmones/laravel-digest
php artisan vendor:publish # pick the digest config/migrations tags interactively
php artisan migrate
The package is small enough to read end-to-end in one sitting — do that before wiring it in. Order of reading:
config/digest.php — the published config; this is where frequency definitions, mail settings, and table names live.Digest model (src/Models/Digest.php or equivalent) — the frequency, starts_at, ends_at, next_run_at columns define the whole scheduling contract.php artisan list | grep -i digest to get the exact command signature for your installed version rather than trusting docs.User/notifiable model (DigestNotifiable or similar) — add it to App\Models\User and confirm the relationship name (digests()).First use case
The canonical win is replacing per-event notification mail with one scheduled email. Start with the lowest-risk scenario: a daily digest of "someone commented on your post" notifications for a single notification class. Get that working end-to-end (create digest → notification lands in window → command sends → next_run_at advances) before generalising to multiple notification types or multiple frequencies.
Sanity check before building anything
In tinker:
$user = User::first();
$user->digests()->create(['frequency' => 'daily']); // confirm the actual required columns
Digest::first()->toArray();
Then run the send command manually with a digest whose window has already passed. If the email arrives and next_run_at moved, the rest is configuration.
Notification side: one class per digest section
Keep digestible notifications thin — they exist to render a summary, not to re-implement the original notification. A typical implementation exposes something like toDigest($notifiable) returning an array/mailable, and the package's base notification class handles wrapping and addressing. Reuse the original notification's data-fetching logic by extracting it into a query/action class shared by both the immediate and digest paths.
Aggregation from the database channel
If you're already writing notifications to the notifications table, the digest query is usually "notifications for this user, this type, created between starts_at and ends_at". This keeps digest content and the bell icon in sync. If you're not using the database channel, you'll need an intermediate table or the package's own storage — decide early, because retrofitting is painful.
Scheduling
Register the send command in routes/console.php (Laravel 11+/12) or Kernel::schedule():
Schedule::command('digest:send')
->everyFiveMinutes()
->withoutOverlapping()
->onOneServer();
The command is a poller, not a cron-per-frequency. It finds digests where next_run_at <= now() and sends them. That means the schedule interval is your delivery granularity — a five-minute poll gives you "hourly" digests that fire within five minutes of the boundary. Don't try to express frequency in cron; express it in the frequency column.
Creating digests at the right moment
Two patterns, pick one and be consistent:
NotificationSent/observer hook the first time a digestible notification is queued.Eager is easier to reason about and makes starts_at predictable; lazy avoids empty digests for inactive users.
Testing
This package is time-sensitive, so tests hinge on time control:
$this->travelTo(now()->setTime(9, 0));
Notification::fake();
Mail::fake();
// trigger digestible notification
$this->artisan('digest:send')->assertExitCode(0);
Mail::assertSent(DigestMail::class);
Assert on the window boundaries — that a notification created one second after ends_at lands in the next digest — not just that an email was sent. That's where regressions live.
Customising output
Publish the package views and keep your own copy under resources/views/vendor/.... Don't edit vendor/; the next minor bump will silently discard it and your composer.lock bump will look innocent.
v2.4.0 is a compatibility release — treat the upgrade as low-risk but verify constraints. The only changes are Laravel 12 and wider PHP support. There are no API or schema changes, so application code shouldn't need touching. The real risk is composer: after composer update hmones/laravel-digest, run composer why-not laravel/framework 12.* if resolution fails, and check whether a transitive constraint held you back rather than the package itself. If you were previously unable to install on L12, that constraint is now lifted — but re-run your full suite rather than assuming green, since you'll also be pulling framework changes at the same time.
Re-publishing migrations after an upgrade is a trap. vendor:publish --force will overwrite an already-run migration file, and Laravel won't re-run it. If a future release adds columns, diff the package's database/migrations against your published copy and write a new migration by hand.
Empty digests. Guard against sending a blank email when nothing happened in the window. Either skip the send (and still advance next_run_at, or you'll re-poll forever) or suppress at the mailable level. Skipping without advancing is the single most common way this package ends up hammering your queue.
Timezone drift. Frequency boundaries are computed from starts_at/next_run_at timestamps, which are stored in UTC. If users expect "9am my time", you need either a per-user timezone column consulted when creating/advancing the digest, or you accept UTC-aligned delivery. Decide before launch — it's a schema change afterwards.
Failed sends and next_run_at. Confirm the behaviour when the mail transport throws: if the exception propagates before next_run_at is persisted, the digest retries on the next poll (good). If the timestamp advances first, a transport blip silently loses a day of notifications. Read the command's ordering and test it with a throwing mailer.
Overlap on multi-server deployments. The poller is not inherently locked. withoutOverlapping() only guards within a single host's cache; combine with onOneServer() and a shared cache driver (Redis/database, not array or file).
Local development. Mail is the slow feedback loop. Use MAIL_MAILER=log plus Mail::fake() in tests, and drive the command manually with a digest whose next_run_at you set into the past in tinker — far faster than waiting for a real window boundary.
Extension points worth knowing. The mailable class, the Blade views, and the Digest model are the intended seams. Bind your own subclass via the container or config rather than fighting the package; if you find yourself overriding the command itself, check whether a config toggle already exists — the config file is deliberately broad.
Watch the repo, not just Packagist. This is a low-traffic package (single maintainer, occasional contributions). A composer outdated that shows a jump from 2.3 to 2.4 with a one-line changelog is normal here — read the compare diff on GitHub rather than relying on release notes, and pin to ^2.4 rather than * so an unannounced breaking 3.0 doesn't land in CI.
How can I help you explore Laravel packages today?