laravel-notify-templates maintained by fomvasss
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).
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 |
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
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.
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
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
// 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.