Browse the documentation
Building modules
Notifications
Send notifications from a module and register your own notification channels through the channel registry.
When a job is completed, someone usually needs to know — the property manager, the customer, the dispatcher. The application handles this through a channel-based notification system: the core knows only the concept of a “channel”; the concrete delivery paths (email, Telegram, SMS …) are provided by modules. A new notification path can therefore be added without touching any core file.
How a notification is triggered
The trigger is the JobCompleted event. The SendJobCompletedNotification listener picks it up and distributes the message through all active channels in the NotificationChannelRegistry:
JobCompleted event
→ SendJobCompletedNotification listener
→ FilterRegistry applies 'schneespur.job.notification.recipients'
→ NotificationChannelRegistry::dispatch()
→ FilterRegistry applies 'schneespur.job.notification.channels'
→ Each enabled channel's send() method is called
→ Results logged to notification_logs table
The two filter points in this flow are important: before anything is sent, modules may adjust the recipient list (schneespur.job.notification.recipients) and the selection of active channels (schneespur.job.notification.channels). A module can therefore influence the dispatch without replacing the listener itself. More on this mechanism under Filter & hooks and Events.
The core channel: email
Email is built in. The core registers EmailNotificationChannel, which sends via Laravel’s Mail system using the JobCompletedMail mailable. It respects per-customer notification settings (auto_notify_email, notification_email). A custom channel is only needed when you want to add a delivery path other than email.
Building a custom notification channel
Using a Telegram channel as an example — the pattern is always the same: implement the interface, register in the registry, provide settings.
1. Implement the interface
A channel implements NotificationChannelInterface. The central method is send(): it receives the completed Job, the notification type, and a context array, and is responsible for actually delivering the message. The name(), slug(), and isEnabled() methods describe the channel and report whether it is ready to use.
namespace Schneespur\Module\Telegram\Notification;
use App\Models\Job;
use App\Models\Setting;
use App\Services\Notification\NotificationChannelInterface;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TelegramChannel implements NotificationChannelInterface
{
public function send(Job $job, string $type, array $context): void
{
$botToken = Setting::get('telegram.bot_token');
$chatId = Setting::get('telegram.chat_id');
$customer = $job->customer;
$object = $job->customerObject;
$driver = $job->user;
$text = sprintf(
"✅ *Einsatz abgeschlossen*\n" .
"Typ: %s\n" .
"Kunde: %s\n" .
"Objekt: %s\n" .
"Fahrer: %s\n" .
"Dauer: %s",
$job->type->label(),
$customer?->name ?? '—',
$object?->name ?? '—',
$driver?->displayName() ?? '—',
$job->durationFormatted()
);
$response = Http::post("https://api.telegram.org/bot{$botToken}/sendMessage", [
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'Markdown',
]);
if (!$response->successful()) {
throw new \RuntimeException('Telegram API error: ' . $response->body());
}
}
public function name(): string
{
return 'Telegram';
}
public function slug(): string
{
return 'telegram';
}
public function isEnabled(): bool
{
return !empty(Setting::get('telegram.bot_token'))
&& !empty(Setting::get('telegram.chat_id'));
}
}
isEnabled() is more than a formality: the channel decides for itself whether it participates in dispatch. As long as the bot token and chat ID are absent, the channel stays silent rather than failing with a half-configured connection. Exceptions thrown in send() are caught and logged by the system (see below), so a failure in one channel does not bring down the entire dispatch.
2. Register in the ServiceProvider
Register the channel during the boot() phase of your module’s ServiceProvider, through the NotificationChannelRegistry. The first parameter is the slug, the second is the class:
protected function registerNotificationChannels(): void
{
$registry = app(NotificationChannelRegistry::class);
$registry->register('telegram', TelegramChannel::class);
}
Where this call fits in the module lifecycle is described under The ServiceProvider pattern.
3. Register default settings
To make the bot token and chat ID configurable, register them as module settings. They are stored in the settings table with the module slug as a prefix:
app(ModuleManager::class)->registerSettings('telegram', [
'bot_token' => '',
'chat_id' => '',
]);
4. Build a settings page
Finally, you need an admin controller and a Blade view where the token and chat ID can be entered. The recommended pattern here is “test before save”: validate the submitted value against the real API before persisting it. This way a wrong credential is caught immediately rather than at the next job completion.
The notification context
The $context array passed to send() typically contains the recipients and, depending on notification type, additional data:
[
'recipients' => [
['email' => 'customer@example.com', 'name' => 'Customer Name'],
],
// Additional context depending on notification type
]
Filter: modify recipients
The schneespur.job.notification.recipients filter lets a module change the recipient list before dispatch — for example to add a standing copy to the dispatcher. The third parameter (100) is the priority, which determines the order when multiple filters are registered:
$filters = app(FilterRegistry::class);
$filters->register('schneespur.job.notification.recipients', function (array $recipients, $job): array {
// Add a CC recipient for all jobs
$recipients[] = [
'email' => Setting::get('my-module.cc_email'),
'name' => 'Dispatcher',
];
return $recipients;
}, 100);
Filter: control which channels fire
The schneespur.job.notification.channels filter decides which channels fire for a specific job. This allows a channel to be switched on or off selectively for certain job types:
$filters->register('schneespur.job.notification.channels', function (array $channels, $job): array {
// Only use Telegram for urgent job types
if ($job->type === JobType::Kontrolle) {
unset($channels['telegram']);
}
return $channels;
}, 100);
Logging notifications
Every dispatch is recorded — which is why a failed delivery does not disappear without trace. The NotificationLog model stores, per message, which channel was used, to whom, and with what result:
// Fields:
notifiable_type // e.g. 'App\Models\Job' or 'App\Models\Customer'
notifiable_id // the model ID
channel // 'email', 'telegram', etc.
type // notification type
recipient // where it was sent
status // 'sent', 'failed'
error_message // failure reason (if any)
metadata // JSON additional data
A channel should create its own log entry for each delivery — this gives a traceable record of who was notified and when:
use App\Models\NotificationLog;
NotificationLog::create([
'notifiable_type' => Job::class,
'notifiable_id' => $job->id,
'channel' => 'telegram',
'type' => 'job_completed',
'recipient' => $chatId,
'status' => 'sent',
'metadata' => ['message_id' => $response->json('result.message_id')],
]);
Other channel ideas
The pattern shown here works for any transport. The following channels can all be built using the same four steps — they differ only in transport and required configuration:
| Channel | Transport | Key config |
|---|---|---|
| Telegram | HTTP POST to Bot API | Bot token, chat ID |
| SMS (Twilio) | Twilio REST API | Account SID, auth token, from number |
| SMS (Vonage) | Vonage REST API | API key, secret, from number |
| Slack | Incoming Webhook | Webhook URL |
| Push (Firebase) | FCM HTTP v1 API | Service account key |
| Microsoft Teams | Incoming Webhook | Webhook URL |
| Twilio/Meta API | Account credentials |
Each follows the same four steps: implement NotificationChannelInterface, register in the NotificationChannelRegistry, register default settings, and provide a settings page.