Download the PHP package fomvasss/laravel-notify-templates without Composer
On this page you can find all versions of the php package fomvasss/laravel-notify-templates. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.
Download fomvasss/laravel-notify-templates
More information about fomvasss/laravel-notify-templates
Files in fomvasss/laravel-notify-templates
Package laravel-notify-templates
Short Description Notification templates management for Laravel: DB-based templates, role/user subscriptions, channel resolution.
License MIT
Homepage https://github.com/fomvasss/laravel-notify-templates
Informations about the package laravel-notify-templates
laravel-notify-templates
DB-based notification templates for Laravel. Manages notification templates, role/user subscriptions, channel resolution, and delay — without coupling to any specific role package.
Українська: README.uk.md
Concepts
notify_templates— stores subject/body per notify type + channel slot + role + tenant, with fallback chainnotify_role_subscriptions— which notify types are active for which role (channels, delay, personal_only)notify_user_settings— per-notifiable opt-out of a specific notify type (polymorphic — works with any Eloquent model, not just aUser)BaseNotify— abstract base that resolves templates and channels; concrete classes live in the appNotifyTemplatesManager— type registry + resolve methods, available viaNotifyTemplatesfacade
Installation
Requires PHP 8.2+, Laravel 10–13, and PostgreSQL or MySQL 8.0.13+ (the migration uses functional unique indexes on COALESCE(...); MariaDB does not support them).
Publish and run migrations:
Publish config (optional):
Configuration
config/notify-templates.php:
Type Registry
Auto-discovery (recommended)
By default the package scans app/Notifications on every boot — no code needed in the app. Configured via discover in the config:
Scanning is recursive — subdirectories like Notifications/Order/, Notifications/User/ are included automatically. Set to [] to disable.
The package discovers all classes that extend BaseNotify and return a non-empty typeDefinition(). Safe in production — types are registered once on boot, the singleton is read-only during requests.
Manual call is also available:
Manual registration
For dynamic types (e.g. generated from DB records), register in AppServiceProvider::boot():
Or statically via config:
typeDefinition() fields
| Field | Type | Description |
|---|---|---|
key |
string | Unique identifier, e.g. 'OrderOrdered' |
name |
string | Human-readable label for UI |
group |
string | Grouping key for UI tables, e.g. 'order' |
weight |
int | Sort weight within the group; lower values appear first |
desc |
string | Optional description shown as tooltip in the UI table |
settings |
array | Option keys editable in the admin UI, stored in notify_role_subscriptions.options |
tokens |
array | Token hints for the template editor: [['key' => '[order:number]', 'name' => 'Номер']] |
channels |
array | Channels this notify type supports. Empty (default) — falls back to config('notify-templates.channels') |
defaults |
array | Default subject/body per channel slot, used as placeholder in the editor when no DB template exists |
user_configurable |
bool | false — the notifiable can't opt out of the type or restrict its channels, and an empty channel resolution falls back to default_channels. Default true. See Non-configurable types |
log_body |
bool | false — the delivery log stores the subject only, never the body (OTP codes, passwords). Default true. See Delivery log |
buttons |
array | Link buttons under a messenger message: [['text' => 'Pay', 'url' => '[order:payUrl]']], tokens allowed in both; text may be a locale map. See Messenger buttons |
buttons_by_role |
array | Buttons for a specific role, replacing buttons: ['admin' => [...]]; [] — no buttons for that role |
buttons_columns |
int | Buttons per row. Default 1 |
tokens and defaults are UI metadata — the package does not use them for sending. getBodyDefault() / getSubjectDefault() on BaseNotify read from defaults.mail automatically. Keep them in sync.
Custom keys. registerType() stores the whole array returned by typeDefinition() as-is — any key beyond the
table above survives untouched and comes back from NotifyTemplates::getType($notifyKey)['your_key']. Useful for
project-specific behavior without forking the package. The package itself ignores these keys: your admin
controller, UI or import enforces them. Two examples from a production app:
always_sent matters only for types sent without a role resolver. A type with user_configurable = false that
still goes through NotifyRoleResolverInterface is disabled for real by an inactive subscription: the resolver
returns no recipients for that role.
settings field
settings declares which option keys are shown in the admin UI. The only key the package reads natively is delay:
Any other keys are project-defined — read them via $subscription->getOption('key').
Recommended controller pattern — save all settings keys generically so adding a new option requires no controller changes:
Retrieve registered types:
User model — HasNotifySettings
Add the trait to your User model with a notify_channels column (cast to array):
Override getNotifyChannels() if your column has a different name:
getNotifyChannels() defines the user's preferred channels. The result is intersected with the channels configured in notify_role_subscriptions — the user can opt out of channels but cannot add new ones beyond what the role allows. If the user returns [] or the method is absent, all subscription channels are used.
Per-type opt-out & channel override (notify_user_settings)
Two independent, optional things a notifiable can record per notify type — both on the same row, both default to "not customized":
-
is_enabled— turn one specific notify type off entirely (e.g. "stop emailing me about X, but keep everything else"). Checked automatically inBaseNotify::via(): channels— restrict this one type to a subset of channels, e.g. "OrderOrdered only via telegram" while everything else still follows the notifiable's globalgetNotifyChannels(). Narrows, never widens — it's intersected with the global preference, so you can't route to a channel the notifiable hasn't connected:
Both work for any Eloquent model — no trait or interface required on the notifiable. Absence of a row = fully default (enabled, no channel override). A row only ever exists because something explicit was recorded — typically from a profile settings form:
To read the current state outside of a Notification (e.g. to render the toggle in a settings form), either query NotifyUserSetting directly, call the facade — NotifyTemplates::isNotifyEnabled($notifyKey, $user) / NotifyTemplates::resolveNotifyUserChannels($notifyKey, $user) — or add HasNotifySettings to the model (it already carries isNotifyEnabled() alongside getNotifyChannels()).
The notifiable_id column is a string, not an integer FK — so it works whether the host app's primary keys are auto-increment integers or UUIDs.
Non-configurable types (OTP, security codes)
Some notify types must never be opt-out-able or channel-restricted by the notifiable — e.g. an OTP/login code: if the user could turn that off in their profile, they'd lock themselves out. Opt a type out with typeDefinition():
With user_configurable: false, isNotifyEnabled() always returns true and resolveNotifyUserChannels() always returns null for that type — regardless of any notify_user_settings row that might exist for it (defense in depth, not just a UI-level hide). Use NotifyTemplates::isUserConfigurable($notifyKey) to filter such types out of a settings form's list of toggles.
NotifyRoleResolverInterface
Implement to resolve which users receive a given notify type. Bind in your ServiceProvider:
Note:
personal_only, wherever it comes from (the subscription's own flag, or the whitelist force above), redirects delivery to$context's user regardless of which role the subscription row belongs to — it does not mean "send to one specific person holding this role". Enabling it on aBROADCAST_SAFE_ROLESrow (e.g.admin) does not reach any actual staff member; it just re-sends the same message to$context->useragain, formatted with that role's template. There is no per-notification concept of "this one specific admin" — only "the person the event is about" vs "everyone holding a role".
Artisan commands
The Notify suffix is added automatically. Nested namespaces are supported:
The generated stub includes typeDefinition() with all fields pre-filled and a prepareText() hook ready to override. To customise the stub — copy it to stubs/notify.stub in your project root:
Concrete Notify classes
Extend BaseNotify. Generate with php artisan notify:make, fill typeDefinition(), and add constructor arguments for the models you need.
getBodyDefault() and getSubjectDefault() are derived automatically from typeDefinition()['defaults']['mail'] — no need to define them.
Hooks available for the host app to override:
mapChannel(string $channel, mixed $notifiable): ?string— add custom channels (telegram/sms/…); see Extending in the host appprepareText(string $text, mixed $notifiable): string— token replacement; returns$textas-is by defaulttoMail(mixed $notifiable): MailMessage— default renders subject + body via->line(); override for a custom view
manager() and resolveTemplate() are protected — accessible from a trait or base class mixed into concrete classes.
For types sent directly without an event/listener (e.g. OTP):
Use only() or except() to override channels at call site:
Extending in the host app
The typical setup is one abstract base class in the app that extends BaseNotify and adds project channels + token processing; every concrete Notify then extends it:
Do not copy
via()into the host app. OverridemapChannel()instead. A copiedvia()freezes the resolution chain at the moment of copying — every package fix to it (opt-out handling, fallback semantics, …) then silently doesn't apply until you manually sync the copy.
Messenger buttons
A long link in a messenger text reads badly; put it on a button under the message instead. Defaults live in
typeDefinition(), and a messenger template row can override them from your admin UI via options:
Resolution: template options.buttons → buttons_by_role[role] → buttons. An array wins even when empty, so
"buttons": [] in a template removes the type's default buttons. Leave the key out to keep the defaults.
Entries with an empty text or url are skipped.
The package doesn't render messages for host channels, so the buttons go into your toTelegram():
getMessengerButtons() runs prepareText() on text and url, then drops buttons whose url isn't an absolute
http(s) link on a public host: an unresolved token or a *.test / localhost url makes Telegram reject the
whole message, not just the button. Override isSendableButtonUrl() to allow e.g. a tunnel. Channels without
buttons (SMS, WhatsApp text) can append the links to the text instead.
With the delivery log on, TelegramContentResolver appends url buttons to the logged body as [text] url.
Multilingual text. text can be a locale map instead of a string, in typeDefinition() and in template
options alike. The url stays shared:
The text for the current locale is taken, then app.fallback_locale, then the first non-empty entry. Laravel
switches the locale per notifiable that implements HasLocalePreference (or via ->locale()), so every
recipient gets their language. NotifyTemplates::localizeButtonText($text, $locale) does the same for an admin UI.
options lives on notify_templates, not in astrotomic's translation table, so this map is the way to translate
buttons there.
Listeners
Overall flow, end to end:
DB data examples
notify_role_subscriptions — which roles receive which notify types:
| role_key | notify_key | tenant_id | is_active | personal_only | channels | options |
|---|---|---|---|---|---|---|
| client | OrderOrdered | null | 1 | 1 | ["mail","sms"] |
{"delay": 0} |
| manager | OrderOrdered | null | 1 | 0 | ["mail","telegram"] |
{"delay": 0} |
| client | OrderOrdered | shop-ua | 1 | 1 | ["mail","telegram"] |
{"delay": 2} |
personal_only=true — send only to the user from the event context (e.g. the client who placed the order).
notify_templates — subject/body per notify type, channel slot, role, tenant:
| notify_key | channel | role_key | tenant_id | subject | body |
|---|---|---|---|---|---|
| OrderOrdered | null | null | Замовлення оформлено | Ваше замовлення [order:number] прийнято... | |
| OrderOrdered | client | shop-ua | Дякуємо за замовлення | Привіт, [user:name]! Замовлення [order:number]… | |
| OrderOrdered | messenger | null | null | null | Замовлення [order:number] оформлено |
Channel slots in notify_templates:
mail— used bytoMail()(subject + body)messenger— generic fallback for non-mail channels; used bygetMessengerBody()sms— optional SMS-specific slot;toTurboSms()tries this first, falls back tomessenger- any other slot name is resolved via
resolveTemplate('slot')in the host app
Facade reference
Channel resolution flow
Every notification goes through a fixed resolution chain inside via(). Each step can only restrict channels — it cannot add ones that earlier steps excluded. typeDefinition()['channels'] / getTypeChannels() are not part of this chain — that's a separate, UI-only listing (see note at the bottom).
Practical examples:
| Scenario | Result |
|---|---|
| No subscriptions in DB, nothing configured | nothing sent (user_configurable => false types: mail from default_channels) |
Subscription active, channels [] in DB |
mail (from default_channels) |
Subscription active, user's notify_user_settings.channels = [] for this type |
nothing sent — the notifiable disabled the whole type |
Subscription channels ['mail','telegram'], user has no telegram id |
mail only |
Subscription channels ['mail','telegram'], user getNotifyChannels() returns ['mail'] |
mail only |
Subscription channels ['mail','telegram'], user prefers both, but has a notify_user_settings.channels = ['telegram'] override for this one type |
telegram only — for this type; other types are unaffected |
notify_user_settings.is_enabled = false for this type, but typeDefinition()['user_configurable'] = false |
still sent — the opt-out row is ignored |
->only(['telegram']) at call site |
telegram only, regardless of subscription |
config('notify-templates.channels') and typeDefinition()['channels'] (via getTypeChannels()) are the UI listing only — they drive the checkboxes on the admin edit form. Neither has a direct effect on the send path above.
Template fallback chain
resolveTemplate('OrderOrdered', 'mail', 'client', 'shop-ua') tries in order:
notify_key=OrderOrdered, channel=mail, role=client, tenant=shop-ua← most specificnotify_key=OrderOrdered, channel=mail, role=client, tenant=nullnotify_key=OrderOrdered, channel=mail, role=null, tenant=shop-uanotify_key=OrderOrdered, channel=mail, role=null, tenant=nullnotify_key=OrderOrdered, channel=null, role=client, tenant=shop-uanotify_key=OrderOrdered, channel=null, role=client, tenant=nullnotify_key=OrderOrdered, channel=null, role=null, tenant=shop-uanotify_key=OrderOrdered, channel=null, role=null, tenant=null← global fallback
Returns the first match, or null — BaseNotify then falls back to getBodyDefault() / getSubjectDefault().
Delivery log
Opt-in journal of every sent notification: one notify_logs row per notification × channel × recipient. Covers BaseNotify subclasses only.
mergeConfigFrom() merges only top-level keys, so a published config needs the whole log block, including the new keys after an upgrade.
Existing installs: php artisan vendor:publish --tag=notify-templates-migrations publishes only the migrations you don't have yet (create_notify_logs_table, add_content_to_notify_logs_table).
What gets written:
NotificationSendingcreates the row aspending. A send that dies without any further event (worker killed, timeout) stayspending, so the row is still a trace.NotificationSent→sent, plusexternal_id(the provider's message id) from the channel's resolver.NotificationFailed→failedwith the error. When a channel swallows its own exception (dispatchesNotificationFailedand returns), theNotificationSentthat Laravel fires right after does not overwrite the failure.- A queue retry of the same notification reuses the row and increments
attempts. subjectandbodyhold what was actually sent, tokens already substituted, taken from the channel's response bycontent_resolvers: the mail's subject and HTML, the Telegram message text. The subject is always stored when the channel has one. The body can be turned off globally withstore_body => false, or per type with'log_body' => falseintypeDefinition(). Use the latter for OTP codes, generated passwords and anything else that must not be readable in the log.routeholds the actual address: email, chat id, phone.notifiable_type/idarenullfor on-demand (Notification::route()) recipients.
Delivery status
pending → sent → delivered → read, plus failed. sent means the provider accepted the message. delivered/read exist only where the provider reports them: WhatsApp, Viber, SMS gateways with DLR, ESP webhooks. For mail over plain SMTP or Telegram bots, sent is final.
Feed provider reports (webhook or status poll) into:
The status only moves forward: reports arrive out of order (e.g. delivered after read), and a stale one is ignored. failed is accepted over sent but not over delivered/read. The method returns false when nothing matched or the report was ignored.
$channel is the channel name as via() returned it ('mail', 'telegram' or a channel class-string). Bind an id resolver for each channel whose reports you process:
Status labels
NotifyLog::statusLabels() returns translated labels keyed by status, and $log->getStatusLabel() returns the label for one row. The package ships en and uk. To change the wording or add a locale, publish the translations and edit lang/vendor/notify-templates/{locale}/log.php:
Pruning
NotifyLog is MassPrunable. Rows older than retention_days are removed by model:prune, which you need to schedule:
Queues & Octane
Queues — fully safe. Types are registered once in boot(), DB queries in resolveChannels / resolveDelay / resolveTemplate are fresh per call.
Octane — safe. The NotifyTemplatesManager singleton is intentionally long-lived: $types is populated once on boot and only read during requests — no request-scoped state is stored.
Caveat: call registerType() / registerTypes() / discoverIn() only in ServiceProvider::boot(), never during request handling — a mutation would persist across all Octane requests.
Optionally pre-resolve the singleton:
Multilingual templates (astrotomic/laravel-translatable)
Override the NotifyTemplate model via config to add translation support without touching the package.
1. Migration in your project:
2. Extend the model:
3. Point config to your model:
$template->subject now returns the current locale's translation — BaseNotify::toMail() and getMessengerBody() require no changes.
Locale in queues — implement HasLocalePreference on User:
Laravel reads this automatically and sets the locale before toMail() / toTelegram() — even in queued jobs.
All versions of laravel-notify-templates with dependencies
illuminate/support Version ^10.0|^11.0|^12.0|^13.0
illuminate/database Version ^10.0|^11.0|^12.0|^13.0
illuminate/notifications Version ^10.0|^11.0|^12.0|^13.0