fomvasss / laravel-notify-templates
Notification templates management for Laravel: DB-based templates, role/user subscriptions, channel resolution.
Package info
github.com/fomvasss/laravel-notify-templates
pkg:composer/fomvasss/laravel-notify-templates
Requires
- php: ^8.2
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/notifications: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpunit/phpunit: ^10.0|^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
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).
composer require fomvasss/laravel-notify-templates
Publish and run migrations:
php artisan vendor:publish --tag=notify-templates-migrations php artisan migrate
Publish config (optional):
php artisan vendor:publish --tag=notify-templates-config
Configuration
config/notify-templates.php:
return [ 'tables' => [ 'notify_templates' => 'notify_templates', 'notify_role_subscriptions' => 'notify_role_subscriptions', ], // All delivery channels available in the project. // Used as UI listing and as fallback when typeDefinition()['channels'] is empty. // Include only channels actually wired up in the app. 'channels' => ['mail', 'telegram', 'sms', 'database', 'broadcast'], // Fallback channels when subscription has no channels configured, // or when via() resolves to nothing entirely 'default_channels' => ['mail'], // Tenant ID: null (single-tenant), or a callable that returns the tenant ID string. // Used automatically by NotifyTemplatesManager (resolveTemplate/resolveChannels/resolveDelay, // and therefore BaseNotify) whenever no explicit $tenantId is passed — set $this->tenantId in // a concrete Notify class to override it per-instance. 'tenant_id' => null, // 'tenant_id' => fn() => app('domain')->getId(), // Directories to scan for BaseNotify subclasses on boot (auto-discovery) 'discover' => [ app_path('Notifications'), ], // Optional: pre-register notify types via config 'types' => [], // Override models in the project (e.g. to add translatable support) 'models' => [ 'notify_template' => \Fomvasss\NotifyTemplates\Models\NotifyTemplate::class, ], ];
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:
'discover' => [ app_path('Notifications'), ],
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:
NotifyTemplates::discoverIn(app_path('Notifications'));
Manual registration
For dynamic types (e.g. generated from DB records), register in AppServiceProvider::boot():
NotifyTemplates::registerTypes([ [ 'key' => 'UserCreated', 'name' => 'Користувач створено', 'group' => 'user', 'weight' => 10, 'settings' => ['delay'], 'tokens' => [ ['key' => '[user:name]', 'name' => 'Ім\'я користувача'], ['key' => '[user:email]', 'name' => 'Email'], ], 'defaults' => [ 'mail' => ['subject' => 'Новий користувач', 'body' => 'Користувача [user:name] створено.'], 'messenger' => ['body' => 'Новий користувач: [user:name]'], ], ], ]); // or from DB foreach (Order::statusesList() as $status) { NotifyTemplates::registerType([ 'key' => 'OrderStatus' . ucfirst($status['key']), 'name' => 'Статус: ' . $status['name'], 'group' => 'order', ]); }
Or statically via config:
'types' => [ ['key' => 'UserCreated', 'name' => 'Користувач створено', 'group' => 'user'], ],
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:
public static function typeDefinition(): array { return [ 'key' => 'UserOtp', // ... 'user_configurable' => false, // Only these roles can have a subscription/template for this type. An OTP code always goes to the // user who is logging in, so a template for any other role would never be used 'allowed_roles' => ['client'], // Sent directly ($user->notify()) in response to the user's own action, not through a role resolver. // Combined with user_configurable = false, a disabled subscription falls back to default_channels // and the mail goes out anyway, so the admin UI shows the "active" toggle locked instead 'always_sent' => true, ]; }
// Admin matrix: hide cells for roles the type doesn't allow $allowed = NotifyTemplates::getType($key)['allowed_roles'] ?? null; $roleAllowed = $allowed === null || in_array($role, $allowed, true); // Subscription toggle: refuse to disable what can't be disabled abort_if(NotifyTemplates::getType($key)['always_sent'] ?? false, 422, 'This type is always sent');
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:
'settings' => ['delay'] // notify_role_subscriptions.options = {"delay": 5} // NotifyTemplates::resolveDelay() returns 5 * 60 = 300 seconds
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:
$settings = NotifyTemplates::getType($notifyKey)['settings'] ?? []; if ($settings) { $sub = NotifyRoleSubscription::firstOrNew( ['notify_key' => $notifyKey, 'role_key' => $roleKey, 'tenant_id' => null], ['is_active' => false, 'personal_only' => false, 'channels' => []], ); $incoming = collect($settings) ->mapWithKeys(fn($key) => [$key => $request->input($key)]) ->toArray(); $sub->options = array_merge($sub->options ?? [], $incoming); $sub->save(); }
Retrieve registered types:
NotifyTemplates::getTypes(); // all types NotifyTemplates::getTypes('order'); // filtered by group NotifyTemplates::getType('OrderOrdered');
User model — HasNotifySettings
Add the trait to your User model with a notify_channels column (cast to array):
use Fomvasss\NotifyTemplates\Traits\HasNotifySettings; class User extends Authenticatable { use HasNotifySettings; protected $casts = [ 'notify_channels' => 'array', ]; }
Override getNotifyChannels() if your column has a different name:
public function getNotifyChannels(): array { return $this->channels ?? []; }
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():if (!$this->manager()->isNotifyEnabled($this->getNotifyKey(), $notifiable)) { return []; }
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:$override = $this->manager()->resolveNotifyUserChannels($this->getNotifyKey(), $notifiable); // null = no override
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:
NotifyUserSetting::updateOrCreate( ['notifiable_type' => $user->getMorphClass(), 'notifiable_id' => $user->getKey(), 'notify_key' => 'OrderOrdered'], ['is_enabled' => true, 'channels' => ['telegram']], );
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():
public static function typeDefinition(): array { return [ 'key' => 'UserOtp', 'name' => 'Login code', 'group' => 'user', 'user_configurable' => false, // default true ]; }
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:
use Fomvasss\NotifyTemplates\Contracts\NotifyRoleResolverInterface; use Fomvasss\NotifyTemplates\Models\NotifyRoleSubscription; class AppNotifyRoleResolver implements NotifyRoleResolverInterface { // Roles you actually trust to receive a broadcast — typically your internal staff role(s). // Whitelist, not blacklist: any role not listed here (your "customer"/"tenant" role, and any // future role you add and forget to review) defaults to personal-only delivery. Without this, // a subscription row created with its default personal_only=false — e.g. the first time // someone opens the admin UI for a brand-new notify type, via a lazy firstOrNew() — silently // broadcasts to *every* holder of that role the first time the event fires. For a role with // many unrelated accounts (customers, tenants) that means leaking one person's event (an // invite link, a generated password, ...) to everyone else on the platform. private const BROADCAST_SAFE_ROLES = ['admin']; public function resolveUsersForNotify(string $notifyKey, mixed $context = null): array { $tenantId = config('notify-templates.tenant_id'); $tenantId = is_callable($tenantId) ? $tenantId() : $tenantId; $subscriptions = NotifyRoleSubscription::query() ->active() ->forNotify($notifyKey) ->forTenant($tenantId) ->get(); $result = []; foreach ($subscriptions as $sub) { $forcePersonal = !in_array($sub->role_key, self::BROADCAST_SAFE_ROLES, true); if (($sub->personal_only || $forcePersonal) && $context?->user) { $result[$sub->role_key] = collect([$context->user]); } else { $result[$sub->role_key] = User::role($sub->role_key) ->where('status', User::STATUS_ACTIVE) ->get(); } } return $result; } }
// AppServiceProvider::register() $this->app->bind( \Fomvasss\NotifyTemplates\Contracts\NotifyRoleResolverInterface::class, \App\Services\AppNotifyRoleResolver::class, );
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".
flowchart TD
A["foreach $sub — active NotifyRoleSubscription rows<br/>for this notifyKey"] --> B{"sub.role_key in<br/>BROADCAST_SAFE_ROLES?"}
B -- "no (customer/tenant/... role)" --> D["force personal"]
B -- "yes (trusted staff role)" --> C{"sub.personal_only<br/>checked?"}
C -- "no (default)" --> E["broadcast:<br/>all active users with role_key"]
C -- "yes" --> D
D --> F{"$context available?<br/>(context instanceof User)"}
F -- "yes" --> G["send only to $context's user<br/>— regardless of role_key"]
F -- "no" --> E
Loading
Artisan commands
php artisan notify:make OrderOrdered
# → app/Notifications/OrderOrderedNotify.php
The Notify suffix is added automatically. Nested namespaces are supported:
php artisan notify:make Shop/OrderOrdered
# → app/Notifications/Shop/OrderOrderedNotify.php
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:
cp vendor/fomvasss/laravel-notify-templates/src/Console/stubs/notify.stub stubs/notify.stub
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.
use Fomvasss\NotifyTemplates\Notifications\BaseNotify; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; final class OrderOrderedNotify extends BaseNotify implements ShouldQueue { use Queueable; public function __construct(protected Order $order, protected string $roleKey) { // $this->tenantId = $order->domain_id; // override per-instance; otherwise falls back to config('notify-templates.tenant_id') } public static function typeDefinition(): array { return [ 'key' => 'OrderOrdered', 'name' => 'Замовлення оформлено', 'group' => 'order', 'weight' => 20, 'desc' => 'Відправляється в момент оформлення замовлення', 'settings' => ['delay'], 'tokens' => [ ['key' => '[order:number]', 'name' => 'Номер замовлення'], ['key' => '[user:name]', 'name' => 'Ім\'я клієнта'], ], 'defaults' => [ 'mail' => ['subject' => 'Замовлення оформлено', 'body' => 'Ваше замовлення [order:number] прийнято.'], 'messenger' => ['body' => 'Нове замовлення [order:number]'], ], ]; } }
For types sent directly without an event/listener (e.g. OTP):
$user->notify(new UserOtpNotify(roleKey: 'client', code: $code));
Use only() or except() to override channels at call site:
// send only via mail, regardless of subscription settings $user->notify((new UserOtpNotify(roleKey: 'client', code: $code))->only(['mail'])); // send via all resolved channels except sms $user->notify((new OrderOrderedNotify(roleKey: 'client'))->except(['sms']));
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:
namespace App\Notifications; use Fomvasss\NotifyTemplates\Notifications\BaseNotify; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Str; use NotificationChannels\Telegram\TelegramMessage; abstract class BaseNotification extends BaseNotify { // 1. Custom channels: map a subscription channel slug to a channel name / class-string, // or null to skip (no route). This is the ONLY method to touch — the opt-out gate, // user channel preferences, subscription resolution, the user_configurable fallback // and only()/except() all keep applying to these channels automatically. protected function mapChannel(string $channel, mixed $notifiable): ?string { return match ($channel) { 'telegram' => $notifiable->routeNotificationForTelegram() ? 'telegram' : null, 'sms' => $notifiable->phone ? TurboSmsChannel::class : null, default => parent::mapChannel($channel, $notifiable), }; } // 2. A to{Channel}() per added channel. getMessengerBody() resolves the 'messenger' // template slot (falling back to 'mail') and runs prepareText() on it. // Messengers have hard message limits — trim and strip HTML per channel. public function toTelegram(mixed $notifiable): TelegramMessage { return TelegramMessage::create() ->options(['parse_mode' => 'HTML']) ->line($this->getMessengerBody($notifiable)); } public function toTurboSms(mixed $notifiable): string { return Str::limit(strip_tags($this->getMessengerBody($notifiable)), 660); } // 3. Token replacement — applied to subject/body of every channel // (example uses fomvasss/laravel-str-tokens; any templating works) protected function prepareText(string $text, mixed $notifiable): string { return \StrToken::setEntity($notifiable)->setText($text)->replace(); } // 4. Optional: custom mail view instead of the default ->line() public function toMail(mixed $notifiable): MailMessage { $template = $this->resolveTemplate('mail'); return (new MailMessage()) ->subject($this->prepareText($template?->subject ?: $this->getSubjectDefault(), $notifiable)) ->view('mails.plain', ['body' => $this->prepareText($template?->body ?: $this->getBodyDefault(), $notifiable)]); } }
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:
'buttons' => [ ['text' => 'Pay', 'url' => '[order:payUrl]'], ], 'buttons_by_role' => [ 'admin' => [['text' => 'Order in admin', 'url' => '[order:adminUrl]']], ], 'buttons_columns' => 1,
// notify_templates.options of a messenger template — wins over typeDefinition() {"buttons": [{"text": "Pay now", "url": "[order:payUrl]"}], "buttons_columns": 2}
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():
public function toTelegram(mixed $notifiable): TelegramMessage { $message = TelegramMessage::create() ->options(['parse_mode' => 'HTML']) ->line($this->getMessengerBody($notifiable)); $columns = $this->getMessengerButtonsColumns(); foreach ($this->getMessengerButtons($notifiable) as $button) { $message->button($button['text'], $button['url'], $columns); } return $message; }
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:
'buttons' => [ ['text' => ['uk' => 'Оплатити', 'en' => 'Pay'], 'url' => '[order:payUrl]'], ],
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:
sequenceDiagram
participant App as App code
participant Listener
participant Resolver as NotifyRoleResolverInterface
participant Notif as Notification::send()
participant Notify as YourNotify (BaseNotify)
App->>Listener: event(new OrderOrdered($order))
Listener->>Resolver: resolveUsersForNotify('OrderOrdered', $order)
Resolver-->>Listener: ['role_key' => Collection<User>]
loop for each role_key
Listener->>Notif: send($users, new YourNotify($order, $roleKey))
Notif->>Notify: toMail() / toTelegram() / ...
Notify->>Notify: resolveTemplate() — 8-level fallback<br/>(DB → typeDefinition defaults)
Notify->>Notify: via() — resolveChannels() ∩ user channels<br/>∩ physical route (email set, telegram_id set, ...)
Notify-->>App: delivered per resolved channel
end
Loading
use Fomvasss\NotifyTemplates\Contracts\NotifyRoleResolverInterface; use Fomvasss\NotifyTemplates\Facades\NotifyTemplates; use Illuminate\Support\Facades\Notification; class OrderOrderedListener { public function __construct(protected NotifyRoleResolverInterface $resolver) {} public function handle(OrderOrdered $event): void { $order = $event->order->fresh(); foreach ($this->resolver->resolveUsersForNotify('OrderOrdered', $order) as $roleKey => $users) { $delay = NotifyTemplates::resolveDelay('OrderOrdered', $roleKey, $order->domain_id); Notification::send( $users, (new OrderOrderedNotify($order, $roleKey))->delay($delay), ); } } }
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
// Type registry NotifyTemplates::discoverIn(string $path): void NotifyTemplates::registerType(array $type): void NotifyTemplates::registerTypes(array $types): void NotifyTemplates::getTypes(?string $group = null): array NotifyTemplates::getType(string $key): ?array // Channels supported by a notify type (from typeDefinition or config fallback) NotifyTemplates::getTypeChannels(string $notifyKey): array // Template resolution (8-level fallback chain) NotifyTemplates::resolveTemplate(string $notifyKey, string $channel, ?string $roleKey, ?string $tenantId): ?NotifyTemplate // Delivery channels: subscription channels intersected with user preferences (user can opt out, not add) NotifyTemplates::resolveChannels(string $notifyKey, string $roleKey, ?string $tenantId, array $userChannels = []): array // Messenger buttons, raw: template options.buttons → buttons_by_role[role] → buttons NotifyTemplates::resolveButtons(string $notifyKey, ?string $roleKey, ?string $tenantId, string $channel = 'messenger', ?array $type = null): array NotifyTemplates::resolveButtonsColumns(string $notifyKey, ?string $roleKey, ?string $tenantId, string $channel = 'messenger', ?array $type = null): int // Button text for a locale: plain string as is, locale map → locale → fallback_locale → first filled NotifyTemplates::localizeButtonText(string|array $text, ?string $locale = null): string // Delay in seconds (options.delay in DB is stored in minutes) NotifyTemplates::resolveDelay(string $notifyKey, string $roleKey, ?string $tenantId): int // Delivery report from a provider; status only moves forward NotifyTemplates::updateDelivery(string $channel, string $externalId, string $status, array $payload = []): bool
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).
0. isNotifyEnabled(notifyKey, notifiable)
has the notifiable opted out of this whole type? (notify_user_settings.is_enabled)
'user_configurable' => false in typeDefinition() → always true, the row (if any) is ignored
false → via() returns [] immediately, nothing below runs
↓
1. getNotifyChannels() (on the notifiable — optional method)
the notifiable's own global channel preference
method absent → treated as "no restriction", not "no channels"
↓
2. resolveNotifyUserChannels(notifyKey, notifiable)
per-type override (notify_user_settings.channels) — narrows step 1 further, only for this one type
'user_configurable' => false → always null, the row (if any) is ignored
null → no narrowing beyond step 1
[] (or no overlap with step 1) → explicit opt-out of every channel, via() returns []
↓
3. notify_role_subscriptions.channels (set in admin UI)
which channels are enabled for this role+notify pair
empty → falls back to config('notify-templates.default_channels')
intersected with the combined result of steps 1+2 (a notifiable can only opt out, never add channels the role doesn't allow)
↓
4. mapChannel() — routeNotificationFor*() / property checks (e.g. mail needs ->email)
physical check: does the notifiable actually have an email / telegram id / etc.?
channel dropped silently if the route/property is empty
host apps add their channels by overriding this hook (see "Extending in the host app")
if nothing survives → [] ("don't send"); only 'user_configurable' => false types
fall back to config('notify-templates.default_channels') here (guaranteed delivery for OTP and the like)
↓
5. only() / except() (call-site override in code)
applied last, always wins
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.
// config/notify-templates.php 'log' => [ 'enabled' => true, 'retention_days' => 90, 'external_id_resolvers' => [ 'mail' => \Fomvasss\NotifyTemplates\Resolvers\MailMessageIdResolver::class, 'telegram' => \Fomvasss\NotifyTemplates\Resolvers\TelegramMessageIdResolver::class, ], 'content_resolvers' => [ 'mail' => \Fomvasss\NotifyTemplates\Resolvers\MailContentResolver::class, 'telegram' => \Fomvasss\NotifyTemplates\Resolvers\TelegramContentResolver::class, ], 'store_body' => true, ],
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:
NotifyTemplates::updateDelivery($channel, $externalId, 'delivered', $rawPayload);
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:
use Fomvasss\NotifyTemplates\Contracts\ExternalIdResolverInterface; class TurboSmsIdResolver implements ExternalIdResolverInterface { public function resolve(mixed $response): ?string { return $response['response_result'][0]['message_id'] ?? null; } }
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:
php artisan vendor:publish --tag=notify-templates-lang
Pruning
NotifyLog is MassPrunable. Rows older than retention_days are removed by model:prune, which you need to schedule:
Schedule::command('model:prune', ['--model' => [\Fomvasss\NotifyTemplates\Models\NotifyLog::class]])->daily();
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:
// config/octane.php 'warm' => [ \Fomvasss\NotifyTemplates\NotifyTemplatesManager::class, ],
Multilingual templates (astrotomic/laravel-translatable)
Override the NotifyTemplate model via config to add translation support without touching the package.
1. Migration in your project:
Schema::create('notify_template_translations', function (Blueprint $table) { $table->id(); $table->foreignId('notify_template_id')->constrained('notify_templates')->cascadeOnDelete(); $table->string('locale', 10); $table->text('subject')->nullable(); $table->longText('body')->nullable(); $table->unique(['notify_template_id', 'locale']); });
2. Extend the model:
// app/Models/NotifyTemplate.php namespace App\Models; use Astrotomic\Translatable\Contracts\Translatable as TranslatableContract; use Astrotomic\Translatable\Translatable; use Fomvasss\NotifyTemplates\Models\NotifyTemplate as BaseNotifyTemplate; class NotifyTemplate extends BaseNotifyTemplate implements TranslatableContract { use Translatable; public array $translatable = ['subject', 'body']; }
3. Point config to your model:
'models' => [ 'notify_template' => \App\Models\NotifyTemplate::class, ],
$template->subject now returns the current locale's translation — BaseNotify::toMail() and getMessengerBody() require no changes.
Locale in queues — implement HasLocalePreference on User:
use Illuminate\Contracts\Translation\HasLocalePreference; class User extends Authenticatable implements HasLocalePreference { public function preferredLocale(): string { return $this->locale ?? config('app.locale'); } }
Laravel reads this automatically and sets the locale before toMail() / toTelegram() — even in queued jobs.