NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

Require the package, publish its migrations and options-for-laravel’s (preferences are stored through it), and migrate. The service provider is auto-discovered together with the providers of the Roundly packages it builds on — crypto, enums, options and package-toolkit:

composer require roundly-consulting/notifications-for-laravel
php artisan vendor:publish --tag="notifications-migrations"
php artisan vendor:publish --tag="options-migrations"     # preferences are stored through options-for-laravel
php artisan migrate

The migrations are publish-only — the provider loads none of them. Five tables are created, in this order: notification_reminders, notification_inbox_items, notification_digest_runs, notification_deliveries and notification_receipts. Every instant column is a dateTime and every value is UTC, so there is no MySQL 2038 limit and no DST-ambiguous wall-clock value. Laravel’s own notifications table is never touched.

Or let the install command do the publishing:

# Publishes the config and the migrations (refusing when a configured table already exists)
# and prints the schedule lines and trait snippets
php artisan notifications:install

1. Schedule the sweep

Held mail, trailing mail, retries, digests, reminders and escalations all go out from one command. Schedule it every minute — nothing is auto-scheduled by the package:

// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('notifications:sweep')->everyMinute()->withoutOverlapping(10)->onOneServer();
Schedule::command('notifications:prune')->daily();

withoutOverlapping(10) matters: the default mutex lives 24 hours, so a killed sweep would otherwise stall held mail, digests and reminders for a day. Each sweep step claims its rows by compare-and-set, so two schedulers never send one row twice; onOneServer() just saves work. Run a queue worker too — mail, digests and large fan-outs are queued unless notifications.queue.enabled is off.

2. Settle the key types before migrating

With bigint ids everywhere the defaults fit. With UUID or ULID keys, set them before php artisan migrate:

# Before php artisan migrate — the values are frozen once the tables exist
NOTIFICATIONS_KEY_TYPE=uuid            # recipients and actors — must equal options.key_type
NOTIFICATIONS_SUBJECT_KEY_TYPE=        # blank = key_type
NOTIFICATIONS_CONTEXT_KEY_TYPE=        # blank = key_type
NOTIFICATIONS_PRIMARY_KEY_TYPE=bigint  # the package tables' own ids (bigint, uuid or ulid)

notifications.key_type must equal your user key type and options.key_type. Before any write each morph value is checked against its key type, and a mismatch throws InvalidNotificationsConfigurationException naming the config key and the model basename — never a raw SQL error.

3. Make your User a recipient

A recipient is an Eloquent model that can be mailed (Laravel’s Notifiable or RoutesNotifications, or notifications.mail.address_resolver), is Authenticatable when a type relies on the default Gate access check, and uses ReceivesNotifications:

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use RoundlyConsulting\Notifications\Traits\ReceivesNotifications;

class User extends Authenticatable
{
    use Notifiable;              // mail routing (Laravel)
    use ReceivesNotifications;   // inbox(), notificationPreferences(), unreadNotificationCount()
}

ReceivesNotifications is named so it composes with Notifiable: no notifications(), notify() or unreadNotifications() clash. Without a mail address a delivery becomes undeliverable — never a fatal error at delivery.

4. Describe a type and send it

A type needs a view policy on its subject (here PurchaseRequest), a group that lists its members (Company implementing ProvidesNotificationRecipients) and a recipient as above:

use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Notifications\NotificationPayload;
use RoundlyConsulting\Notifications\NotificationType;

// app/Notifications/Types/RequestAwaitingApproval.php — listed in config('notifications.types')
final class RequestAwaitingApproval extends NotificationType
{
    public function key(): string { return 'request.awaiting_approval'; }
    public function category(): string { return 'requests'; }
    public function subject(): ?string { return PurchaseRequest::class; }   // access check: Gate "view" on it
    public function window(): ?string { return 'PT5M'; }                    // 10 events in 5 min → 1 item, 1 mail

    public function title(NotificationPayload $p): string
    {
        return __('app.notifications.awaiting', ['ref' => $p->data['reference'], 'count' => $p->count]);
    }

    public function url(NotificationPayload $p): ?string { return route('requests.show', $p->subjectKey); }
    public function context(?Model $subject): ?Model { return $subject?->company; }   // "Acme s.r.o." label
}

Then send it from a feature — inbox, mail, preferences, de-dup, quiet hours and access are applied for you — and read the user’s inbox:

use RoundlyConsulting\Notifications\Facades\Notifications;
use RoundlyConsulting\Notifications\Recipients;

Notifications::send(
    'request.awaiting_approval',
    $request,
    Recipients::role($request->company, 'approver'),
    ['reference' => $request->reference],
    actor: $request->author,
);

$items = Notifications::inbox($user)->unread()->paginate();   // InboxItemResource::collection($items)
Notifications::inbox($user)->unreadCount();                   // 3
Notifications::inbox($user)->markAllRead();

Publish tags

TagPublishes
notifications-migrationsFive migrations — reminders, inbox items, digest runs, deliveries, receipts — timestamped on publish. Required: the provider loads none of them.
options-migrationsoptions-for-laravel’s table — preferences are stored through it. Required.
notifications-configconfig/notifications.php
notifications-translationslang/vendor/notifications/{en,sk}/{mail,digest,enums,exceptions,preferences}.php
notifications-viewsThe digest mail’s markdown view notifications::mail.digest.
notifications-routesroutes/notifications.php — once published it loads instead of the package’s file, still behind notifications.routes.enabled.
php artisan vendor:publish --tag="notifications-config"         # config/notifications.php
php artisan vendor:publish --tag="notifications-translations"   # lang/vendor/notifications/{en,sk}/…
php artisan vendor:publish --tag="notifications-views"          # the digest mail view
php artisan vendor:publish --tag="notifications-routes"         # routes/notifications.php — replaces the package's file

Tables

TableHolds
notification_inbox_itemsRecipient, subject, context and actor morphs, count, data, a title and url snapshot, a unique open_key, collapse_until, reminder_id, read and archived timestamps.
notification_deliveriesStatus, a unique open_key, window_ends_at, send_after, events_count, digest frequency / period / run, the encrypted interop payload (hidden), notification_id, source.
notification_remindersKind, type, rule, subject morph, anchor + offset + anchor_value, due_at, level, parent_id, stored recipients and data, a unique active_key, acknowledgement + acknowledger morph.
notification_digest_runsUnique per recipient, frequency and period.
notification_receiptsUnique per key and recipient; no soft deletes — a ledger.

Translations and labels

notifications::mail, notifications::digest, notifications::enums, notifications::exceptions and notifications::preferences ship in English and Slovak. The labels of your types and categories live in your own translations:

// lang/en/notifications.php — your type and category labels (and lang/sk/notifications.php)
return [
    'types' => [
        'request.awaiting_approval' => [
            'label' => 'Request awaiting approval',
            'description' => 'When a purchase request needs your approval.',
        ],
    ],
    'categories' => [
        'requests' => 'Requests',
    ],
];

Check the setup

php artisan notifications:doctor
php artisan about --only=notifications

The doctor reports key-type drift, a sync queue, a non-shared preference lock store, invalid default quiet hours, types without a policy or ability, interop map problems and a user model that cannot be mailed (see Commands). The about section shows the type count, the queue (connection and queue only as SET or DEFAULT), routes, key types, the digest default and the interop counts — never a secret.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.