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

The package works with zero configuration. Publish config/notifications.php only to override:

php artisan vendor:publish --tag="notifications-config"

Every value is read through a strict reader: a typo throws naming the key, and a blank env value (KEY=) means “not set” — the default applies.

  • Durations are ISO-8601 (PT5M, P1D); times are local HH:MM.
  • Switches accept true/false, 1/0, on/off and yes/no.
  • Integers must sit in the range each row gives; a value outside it throws rather than being clamped.
  • Class keys (resolvers, resources) must name a class of the right kind; anything else throws, naming the key.

The config file

use RoundlyConsulting\Notifications\Http\Resources\NotificationActorResource;
use RoundlyConsulting\Notifications\Http\Resources\NotificationContextResource;
use RoundlyConsulting\Notifications\Http\Resources\NotificationSubjectResource;
use RoundlyConsulting\Notifications\Models\DigestRun;
use RoundlyConsulting\Notifications\Models\InboxItem;
use RoundlyConsulting\Notifications\Models\NotificationDelivery;
use RoundlyConsulting\Notifications\Models\NotificationReceipt;
use RoundlyConsulting\Notifications\Models\NotificationReminder;
use RoundlyConsulting\Notifications\Recipients\ContractGroupResolver;
use RoundlyConsulting\Notifications\Support\DefaultTimezoneResolver;

return [
    'types' => [],

    'key_type' => env('NOTIFICATIONS_KEY_TYPE', 'bigint'),
    'subject_key_type' => env('NOTIFICATIONS_SUBJECT_KEY_TYPE'),
    'context_key_type' => env('NOTIFICATIONS_CONTEXT_KEY_TYPE'),
    'primary_key_type' => env('NOTIFICATIONS_PRIMARY_KEY_TYPE', 'bigint'),

    'models' => [
        'inbox_item' => InboxItem::class,
        'delivery' => NotificationDelivery::class,
        'reminder' => NotificationReminder::class,
        'digest_run' => DigestRun::class,
        'receipt' => NotificationReceipt::class,
    ],

    'tables' => [
        'inbox_items' => 'notification_inbox_items',
        'deliveries' => 'notification_deliveries',
        'reminders' => 'notification_reminders',
        'digest_runs' => 'notification_digest_runs',
        'receipts' => 'notification_receipts',
    ],

    'queue' => [
        'enabled' => env('NOTIFICATIONS_QUEUE_ENABLED', true),
        'connection' => env('NOTIFICATIONS_QUEUE_CONNECTION'),
        'queue' => env('NOTIFICATIONS_QUEUE_NAME'),
    ],

    'fan_out' => [
        'queue_above' => 50,
        'chunk' => 100,
    ],

    'access' => [
        'ability' => 'view',
    ],

    'recipients' => [
        'group_resolver' => ContractGroupResolver::class,
    ],

    'idempotency' => [
        'ttl' => 'P30D',
    ],

    'inbox' => [
        'per_page' => 20,
        'max_per_page' => 100,
        'max_data_bytes' => 16384,
        'recheck_on_read' => env('NOTIFICATIONS_INBOX_RECHECK', false),
        'on_subject_deleted' => 'keep',
    ],

    'preferences' => [
        'inbox_mutable' => false,
        'lock_store' => env('NOTIFICATIONS_LOCK_STORE'),
        'lock_seconds' => 10,
    ],

    'mail' => [
        'strategy' => 'leading',
        'minimal_for_sensitive' => true,
        'address_resolver' => null,
        'preferences_url' => env('NOTIFICATIONS_PREFERENCES_URL'),
        'max_attempts' => 3,
        'backoff' => [60, 300, 900],
        'lease' => 'PT10M',
    ],

    'quiet_hours' => [
        'enabled' => true,
        'default' => null,
    ],

    'timezone' => [
        'resolver' => DefaultTimezoneResolver::class,
        'attribute' => 'timezone',
    ],

    'digest' => [
        'default' => 'off',
        'time' => '08:00',
        'weekly_day' => 'monday',
        'max_items' => 50,
        'lease' => 'PT15M',
    ],

    'reminders' => [
        'max_lateness' => 'P1D',
        'batch' => 500,
        'lease' => 'PT10M',
    ],

    'interop' => [
        'map' => [],
        'suppress' => [],
        'database' => 'redirect',
        'hold' => true,
        'notifiables' => [],
    ],

    'routes' => [
        'enabled' => env('NOTIFICATIONS_ROUTES_ENABLED', 'off'),
        'prefix' => 'notifications',
        'middleware' => ['api'],
        'auth_middleware' => ['auth'],
    ],

    'resources' => [
        'actor' => NotificationActorResource::class,
        'subject' => NotificationSubjectResource::class,
        'context' => NotificationContextResource::class,
    ],

    'prune' => [
        'inbox_after' => 'P180D',
        'deliveries_after' => 'P30D',
        'digest_runs_after' => 'P90D',
        'reminders_after' => 'P90D',
    ],
];

Types and key types

KeyEnvDefaultPurpose
types—[]Your notification types: class-strings extending NotificationType, resolved through the container on first use. Anything else throws, naming the key. Fluent types are added with Notifications::define().
key_typeNOTIFICATIONS_KEY_TYPEbigintThe key of recipients and actors — bigint, uuid or ulid. Must equal your user key type and options.key_type, because preferences are stored per user through options-for-laravel.
subject_key_typeNOTIFICATIONS_SUBJECT_KEY_TYPEblank = key_typeThe key of every subject model.
context_key_typeNOTIFICATIONS_CONTEXT_KEY_TYPEblank = key_typeThe key of every context model.
primary_key_typeNOTIFICATIONS_PRIMARY_KEY_TYPEbigintThe package tables’ own ids and the foreign keys between them. Read by the migrations — set it before migrating.

Models and tables

KeyEnvDefaultPurpose
models.inbox_item—InboxItem::classSwap a model by extending the packaged class and naming the subclass here.
models.delivery—NotificationDelivery::classOne mail row per recipient, type, subject and context per window.
models.reminder—NotificationReminder::classReminder and escalation rows.
models.digest_run—DigestRun::classOne row per recipient, frequency and period — the digest ledger.
models.receipt—NotificationReceipt::classIdempotency receipts.
tables.inbox_items—notification_inbox_itemsLowercase letters, digits and underscores, at most 30 characters — MySQL limits index names to 64.
tables.deliveries—notification_deliveriesAt most 30 characters.
tables.reminders—notification_remindersAt most 24 characters.
tables.digest_runs—notification_digest_runsAt most 30 characters.
tables.receipts—notification_receiptsAt most 30 characters.

Queue and fan-out

KeyEnvDefaultPurpose
queue.enabledNOTIFICATIONS_QUEUE_ENABLEDtrueRun mail, digest and fan-out jobs on the queue. Off: they run inline after the commit.
queue.connectionNOTIFICATIONS_QUEUE_CONNECTIONblank = defaultThe queue connection of those jobs.
queue.queueNOTIFICATIONS_QUEUE_NAMEblank = defaultThe queue name of those jobs.
fan_out.queue_above—50More resolved recipients than this are delivered by queued jobs (at least 1).
fan_out.chunk—100Recipients per fan-out job, 1–10000; each job re-checks access.

Access, recipients and idempotency

KeyEnvDefaultPurpose
access.ability—viewThe Gate ability the default access check inspects on the subject.
recipients.group_resolver—ContractGroupResolver::classA ResolvesGroupMembers class behind Recipients::role() and Recipients::members().
idempotency.ttl—P30DHow long an idempotency key (->once(), reminder and escalation fires) is remembered; receipts are pruned after it.

Inbox

KeyEnvDefaultPurpose
inbox.per_page—20The default page size, 1 up to inbox.max_per_page.
inbox.max_per_page—100The largest page and the cap of get(), 1–1000.
inbox.max_data_bytes—16384The JSON size cap of a send’s data, 256–1048576 bytes.
inbox.recheck_on_readNOTIFICATIONS_INBOX_RECHECKfalseHide items whose subject the reader can no longer access, per page (counts stay raw).
inbox.on_subject_deleted—keepWhat happens to inbox items when their subject is deleted: keep, archive or delete. Runs for subjects using HasNotificationReminders.

Preferences

KeyEnvDefaultPurpose
preferences.inbox_mutable—falseOff: muting stops email only — the inbox always keeps the item.
preferences.lock_storeNOTIFICATIONS_LOCK_STOREblank = default cacheThe cache store of the preference write lock. Must be shared across servers and support atomic locks; a store without locks throws, naming the key.
preferences.lock_seconds—10How long the lock is held, 1–300 seconds.

Mail

KeyEnvDefaultPurpose
mail.strategy—leadingleading: the first event mails, later ones in the window fold into it. trailing: one mail after the window with the final count.
mail.minimal_for_sensitive—trueA type’s sensitive() subjects get a title + link mail only.
mail.address_resolver—nullA ResolvesMailAddress class deciding the address of every mail — typed mail and digests. null routes through the recipient.
mail.preferences_urlNOTIFICATIONS_PREFERENCES_URLnullA URL or a route name for the “manage notifications” footer link.
mail.max_attempts—3Transport tries of a mail and of a digest run, 1–20.
mail.backoff—[60, 300, 900]Seconds between tries — a non-empty list of positive integers.
mail.lease—PT10MA mail claimed longer ago than this is failed, never re-sent (at most once).

Quiet hours and timezone

KeyEnvDefaultPurpose
quiet_hours.enabled—trueThe global switch.
quiet_hours.default—nullQuiet hours of users without their own, e.g. ['start' => '22:00', 'end' => '07:00']; equal times throw.
timezone.resolver—DefaultTimezoneResolver::classA ResolvesTimezone class.
timezone.attribute—timezoneThe recipient attribute holding an IANA timezone; blank disables it.

Digests

KeyEnvDefaultPurpose
digest.default—offoff, daily or weekly — the frequency of users without a preference.
digest.time—08:00The local HH:MM slot.
digest.weekly_day—mondayThe day of a weekly digest, monday–sunday.
digest.max_items—50Lines per digest mail, 1–1000; the rest is “And :count more items in your inbox”.
digest.lease—PT15MA run still sending after this fails with its entries.

Reminders

KeyEnvDefaultPurpose
reminders.max_lateness—P1DA reminder overdue by more than this (after downtime) whose anchor has passed is skipped.
reminders.batch—500Due rows fired per sweep, 1–10000.
reminders.lease—PT10MA row still firing after this goes back to pending; its re-fire sends nothing twice.

Interop

KeyEnvDefaultPurpose
interop.map—[]Notification class => one of your type keys.
interop.suppress—[]A list of classes (every channel), or class => list of channels.
interop.database—redirectThe mapped database channel: redirect (into the inbox), keep or both.
interop.hold—trueHold mapped mail during quiet hours.
interop.notifiables—[]Extra recipient classes; models using ReceivesNotifications always count.

JSON API and resources

KeyEnvDefaultPurpose
routes.enabledNOTIFICATIONS_ROUTES_ENABLEDoffServe the JSON API — off unless you switch it on.
routes.prefix—notificationsURI prefix; surrounding slashes are trimmed. Route names start with notifications.
routes.middleware—['api']Middleware of every endpoint.
routes.auth_middleware—['auth']Authentication middleware, e.g. ['auth:sanctum']. An emptied list is honoured — the controllers still answer 401 without a user.
resources.actor—NotificationActorResource::classThe JsonResource an item’s actor renders through; receives the actor model.
resources.subject—NotificationSubjectResource::classReceives the subject model.
resources.context—NotificationContextResource::classReceives a ContextReference (key, label, morph type and id). Blank or null = the default; anything that is not an instantiable JsonResource subclass throws, naming the key.

Pruning

KeyEnvDefaultPurpose
prune.inbox_after—P180DRead and archived (or deleted) inbox items older than this.
prune.deliveries_after—P30DTerminal deliveries.
prune.digest_runs_after—P90DFinished digest runs with no live entry.
prune.reminders_after—P90DTerminal reminders with no pending or firing escalation. Every prune duration is at least one hour.

Environment

NOTIFICATIONS_KEY_TYPE=bigint
NOTIFICATIONS_SUBJECT_KEY_TYPE=
NOTIFICATIONS_CONTEXT_KEY_TYPE=
NOTIFICATIONS_PRIMARY_KEY_TYPE=bigint
NOTIFICATIONS_QUEUE_ENABLED=true
NOTIFICATIONS_QUEUE_CONNECTION=
NOTIFICATIONS_QUEUE_NAME=
NOTIFICATIONS_INBOX_RECHECK=false
NOTIFICATIONS_LOCK_STORE=redis
NOTIFICATIONS_PREFERENCES_URL=settings.notifications
NOTIFICATIONS_ROUTES_ENABLED=on

In phpunit.xml write on/off (or 1/0): PHPUnit turns false into an empty string, which means “not set”.

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.