Delivery log
An opt-in journal of sent notifications: one notify_logs row per notification × channel × recipient, with the address, the subject and body actually sent, the provider’s message id and a delivery status. Only notifications extending BaseNotify are logged.
Enabling
Section titled “Enabling”'log' => [ 'enabled' => true, // ...],The notify_logs table comes from the create_notify_logs_table migration (see Installation). The setting is read when the application boots; with log.enabled = false no listener is registered at all.
What gets written
Section titled “What gets written”The package listens to Laravel’s notification events:
| Event | Effect |
|---|---|
NotificationSending | Creates the row as pending. A send that dies without another event (killed worker, timeout) stays pending as a trace |
NotificationSent | sent, plus external_id and subject/body from the channel’s response |
NotificationFailed | failed, with the error message and the event data in payload |
- A queue retry of the same notification (same notification id, channel and notifiable) reuses the row: status back to
pending, error cleared,attempts+ 1. - A channel that catches its own exception dispatches
NotificationFailedand returns normally, after which Laravel still firesNotificationSent. Thefailedstatus is kept. routeis the address the channel delivers to — email, chat id, phone — fromrouteNotificationFor(), cut to 255 characters.notifiable_type/notifiable_idarenullfor on-demand recipients (Notification::route()).tenant_idis the notification’s tenant, orconfig('notify-templates.tenant_id').
Subject and body
Section titled “Subject and body”subject and body hold what was sent, tokens already substituted. They are extracted from the channel’s response by a content resolver per channel:
| Resolver | Channel | Extracts |
|---|---|---|
MailContentResolver | mail | Subject, and the HTML body (text body when there is no HTML) |
TelegramContentResolver | telegram | Message text or caption; parts of a chunked message joined; url buttons appended as [text] url |
The subject is stored whenever the resolver returns one (cut to 255 characters). The body is skipped:
- for every type, with
log.store_body = false; - for one type, with
'log_body' => falseintypeDefinition()— use it for login codes, generated passwords and anything that must not be readable in the log.
A channel without a content resolver leaves both columns empty.
Delivery status
Section titled “Delivery status”pending → sent → delivered → read, plus failed.
sent means the provider accepted the message. delivered and read exist only where the provider reports them — WhatsApp, Viber, SMS gateways with delivery reports, ESP webhooks. For mail over plain SMTP or a Telegram bot, sent is final.
Feed provider reports (webhook or status poll) into:
NotifyTemplates::updateDelivery($channel, $externalId, 'delivered', $rawPayload);$channelis the channel asvia()returned it:'mail','telegram'or a channel class name.- The newest row (by
created_at) with this channel andexternal_idis updated:status,payload(nullwhen empty) andstatus_updated_at. - The status only moves forward, because reports arrive out of order (
deliveredafterread). Ranks:pending0,sent1,delivered2,failed2,read3. Sofailedis accepted oversentbut not overdelivered, andreadis accepted even overfailed. - Returns
falsewhen no row matches or the report is stale. - Throws
InvalidArgumentExceptionforpendingor a status that doesn’t exist.
External ids
Section titled “External ids”The id that reports are matched by comes from an external id resolver per channel:
| Resolver | Channel | Id |
|---|---|---|
MailMessageIdResolver | mail | Message-ID of the sent mail |
TelegramMessageIdResolver | telegram | Bot API message_id (the first part of a chunked message) |
The Telegram resolvers read the response of laravel-notification-channels/telegram. For another channel, implement the interface and bind it by the channel name via() returns:
use Fomvasss\NotifyTemplates\Contracts\ExternalIdResolverInterface;
class TurboSmsIdResolver implements ExternalIdResolverInterface{ public function resolve(mixed $response): ?string { return $response['response_result'][0]['message_id'] ?? null; }}'external_id_resolvers' => [ 'mail' => \Fomvasss\NotifyTemplates\Resolvers\MailMessageIdResolver::class, 'telegram' => \Fomvasss\NotifyTemplates\Resolvers\TelegramMessageIdResolver::class, \App\Channels\TurboSmsChannel::class => \App\Notifications\TurboSmsIdResolver::class,],$response is whatever the channel’s send() returned (NotificationSent::$response). Resolvers are created through the container. Content resolvers implement ContentResolverInterface and return ['subject' => ?string, 'body' => ?string] — see Contracts.
Status labels
Section titled “Status labels”NotifyLog::statusLabels(); // ['pending' => 'Pending', 'sent' => 'Sent', ...] in lifecycle order$log->getStatusLabel(); // label of this row's statusen and uk are included. To change the wording or add a locale, publish and edit lang/vendor/notify-templates/{locale}/log.php:
php artisan vendor:publish --tag=notify-templates-langPruning
Section titled “Pruning”NotifyLog is MassPrunable: rows with created_at older than log.retention_days are deleted by model:prune, which you schedule:
use Illuminate\Support\Facades\Schedule;
Schedule::command('model:prune', ['--model' => [\Fomvasss\NotifyTemplates\Models\NotifyLog::class]])->daily();Since the model lives in the package, model:prune without --model doesn’t find it.
Querying
Section titled “Querying”use Fomvasss\NotifyTemplates\Models\NotifyLog;
NotifyLog::query() ->where('notifiable_type', $user->getMorphClass()) ->where('notifiable_id', $user->getKey()) ->latest() ->paginate();notifiable() is a morph-to relation. Indexes exist on notification_id, (channel, external_id), (notifiable_type, notifiable_id) and created_at.