Notification Channels & Templates
This page covers the notification system's channel and template internals: email provider configuration, multi-channel selection, writing custom channels, and the template system.
Email Notification Channel
The email channel comes from the glueful/email-notification extension (Symfony Mailer).
Email Providers
Mail transports are configured under mail in config/services.php (there is no
config/mail.php). MAIL_MAILER picks the default mailer:
// config/services.php (excerpt)
'mail' => [
'default' => env('MAIL_MAILER', 'smtp'),
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'host' => env('MAIL_HOST'),
'port' => env('MAIL_PORT', 2525),
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => 30,
],
'sendgrid' => [
'transport' => 'sendgrid+api',
'key' => env('SENDGRID_API_KEY'),
'dsn' => env('SENDGRID_DSN'),
],
'mailgun' => [
'transport' => 'mailgun+api',
'domain' => env('MAILGUN_DOMAIN'),
'key' => env('MAILGUN_SECRET'),
'region' => env('MAILGUN_REGION', 'us'),
'dsn' => env('MAILGUN_DSN'),
],
// Also: brevo, ses, postmark, log, null, array
],
'from' => [
'address' => env('MAIL_FROM'),
'name' => env('MAIL_FROM_NAME', 'Glueful'),
],
'failover' => [
'mailers' => explode(',', env('MAIL_FAILOVER_MAILERS', '')),
],
],
API providers need their Symfony bridge (composer require symfony/sendgrid-mailer,
symfony/mailgun-mailer, symfony/amazon-mailer, symfony/postmark-mailer, symfony/brevo-mailer).
Each mailer accepts a *_DSN override. Set MAIL_FAILOVER_MAILERS=sendgrid,smtp to fall through to
the next mailer when one is unavailable.
Transport settings saved through the extension's admin API (PUT /email/settings) take precedence
over services.mail per send; the SMTP password is stored encrypted.
Sending Email Notifications
Email is delivered through the standard notification flow — resolve NotificationService and call
send() with 'channels' => ['email']. The email channel reads template_name (and the template's
placeholders), cc/bcc and attachments from the notification data. Attachment paths must
resolve inside emailnotification.security.attachment_allowed_paths (default: the app's storage
directory). See Features → Notifications for the canonical send()
signature and a Notifiable example.
The recipient address comes from the notifiable's routeNotificationFor('email'); an empty route
fails the send with no_recipient (not retried).
Framework memory alerts (MemoryAlertingService) configured with an email channel are delivered
through this channel too; without a registered email channel they log one warning pointing to
glueful/email-notification and skip the email.
Multi-Channel Support
Channel Selection
The channels for a send are resolved in this order:
sync_channels/async_channelsinoptions(orsendSplit())channelsinoptionsnotifications.default_channels(['database']by default)
$notificationService->send(
'order_shipped',
$user,
'Your order has shipped',
['template_name' => 'default', 'message' => 'Tracking number: ABC123'],
[
'channels' => ['email', 'database'],
'priority' => 'high',
]
);
The recipient's shouldReceiveNotification($type, $channel) can still opt out of a channel.
Scheduled notifications store the channels
resolved this way (plus channel_failure_policy / critical_channels) and the
scheduled_notifications job delivers on them when they fall due — sync and async channels alike,
in the scheduler's run. The user recipient core rebuilds for scheduled sends and retries
(Glueful\Notifications\UserNotifiable) routes email to the user's current address.
Channel Configuration
Channels are registered in code (see below), not in config. Core's optional
config/notifications.php accepts only these keys:
// config/notifications.php
return [
'default_channels' => ['database'],
'async_queue' => 'notifications',
'channel_failure_policy' => 'any_success',
'critical_channels' => [],
'idempotency_enabled' => true,
'idempotency_ttl_seconds' => 300,
'max_retry_attempts' => 3,
'retry_delay_seconds' => 60,
];
Custom Channels
Create custom notification channels. Channels are built by the container, so inject what they need:
use Glueful\Http\Client;
use Glueful\Notifications\Contracts\NotificationChannel;
use Glueful\Notifications\Contracts\Notifiable;
class SlackChannel implements NotificationChannel
{
public function __construct(
private readonly Client $http,
private readonly ?string $webhookUrl,
) {
}
public function getChannelName(): string { return 'slack'; }
public function isAvailable(): bool { return $this->webhookUrl !== null && $this->webhookUrl !== ''; }
public function getConfig(): array { return ['webhook_url' => $this->webhookUrl]; }
public function format(array $data, Notifiable $notifiable): array
{
return [
'text' => $data['subject'] ?? '',
'attachments' => [
[
'color' => 'good',
'fields' => [
[
'title' => 'Message',
'value' => $data['message'] ?? '',
'short' => false
]
]
]
]
];
}
public function send(Notifiable $notifiable, array $data): bool
{
$response = $this->http->post((string) $this->webhookUrl, ['json' => $this->format($data, $notifiable)]);
return $response->getStatusCode() === 200;
}
}
Registering a Custom Channel
Register the channel from your extension's ServiceProvider::boot() using the framework helper. This resolves the shared ChannelManager and is the supported wiring path — since the framework no longer hardcodes channel providers, a channel that isn't registered here won't be reachable by the async dispatcher used for queued/retried notifications:
use Glueful\Bootstrap\ApplicationContext;
use Glueful\Extensions\ServiceProvider;
use Glueful\Http\Client;
final class SlackServiceProvider extends ServiceProvider
{
public function boot(ApplicationContext $context): void
{
// Idempotent for the same class; throws ChannelAlreadyRegisteredException
// if a different class tries to claim an already-registered name.
$this->registerNotificationChannel(new SlackChannel(
$this->app->get(Client::class),
config($context, 'services.slack.webhook_url'), // your own config key
));
}
}
Structured Results (RichNotificationChannel)
NotificationChannel::send() returns a bare bool. To surface richer per-send telemetry — a provider message id, error code/message, retryability, and latency — implement Glueful\Notifications\Contracts\RichNotificationChannel and return a NotificationResult from sendNotification(). The dispatcher prefers sendNotification() when a channel implements it and falls back to adapting send(): bool otherwise, so this is purely additive:
use Glueful\Notifications\Contracts\Notifiable;
use Glueful\Notifications\Contracts\RichNotificationChannel;
use Glueful\Notifications\Results\NotificationResult;
class SlackChannel implements RichNotificationChannel
{
// ... constructor, getChannelName(), isAvailable(), getConfig(), format() as above ...
public function send(Notifiable $notifiable, array $data): bool
{
// Keep the legacy contract by delegating to the structured path.
return $this->sendNotification($notifiable, $data)->success;
}
public function sendNotification(Notifiable $notifiable, array $data): NotificationResult
{
$start = microtime(true);
$response = $this->http->post((string) $this->webhookUrl, ['json' => $this->format($data, $notifiable)]);
$latencyMs = (int) round((microtime(true) - $start) * 1000);
if ($response->getStatusCode() === 200) {
return NotificationResult::success(latencyMs: $latencyMs);
}
return NotificationResult::failure(
errorCode: 'http_' . $response->getStatusCode(),
errorMessage: 'Slack webhook rejected the message.',
retryable: $response->getStatusCode() >= 500,
latencyMs: $latencyMs,
);
}
}
Template System
Email Templates
Email templates are registered definitions, not files you drop in a folder. The
email-notification extension registers the built-ins (default, welcome, alert,
password-reset, verification, two-factor-pin); other extensions and apps register their own
through Glueful\Extensions\Contracts\Email\EmailTemplateRegistry. A send picks a definition with
template_name; an unknown key fails loudly.
Registering a Template
Register during boot(), soft-resolving the registry so your package works without the email
extension:
use Glueful\Extensions\Contracts\Email\EmailTemplateDefinition;
use Glueful\Extensions\Contracts\Email\EmailTemplatePlaceholder;
use Glueful\Extensions\Contracts\Email\EmailTemplateRegistry;
public function boot(ApplicationContext $context): void
{
if (!$this->app->has(EmailTemplateRegistry::class)) {
return;
}
$this->app->get(EmailTemplateRegistry::class)->register(new EmailTemplateDefinition(
key: 'acme.order-shipped',
label: 'Order shipped',
description: 'Sent when an order leaves the warehouse.',
defaultSubject: 'Your {{app_name}} order has shipped',
defaultBody: '<p>Hi {{name|there}},</p><p>Tracking number: {{tracking_number}}</p>',
placeholders: [
new EmailTemplatePlaceholder('name', 'Customer first name.', 'Ada'),
new EmailTemplatePlaceholder('tracking_number', 'Carrier tracking number.', 'ABC123'),
],
owner: 'acme/shop',
));
}
$notificationService->send('order.shipped', $notifiable, 'Order shipped', [
'template_name' => 'acme.order-shipped',
'name' => 'Ada',
'tracking_number' => 'ABC123',
], ['channels' => ['email']]);
Template Syntax
{{variable}}, defaults{{variable|fallback}}, nested{{user.profile.name}}- Conditionals:
{{#if show_discount}} … {{/if}} - Every
{{variable}}is HTML-escaped;{{{variable}}}is raw — use it only for values you control - Global variables such as
{{app_name}},{{logo_url}}and{{current_year}}are always available - A plain-text part is generated from the HTML
Overriding Template Content
Operators override a registered template per site, through the extension's admin API (all routes
require auth plus the email.templates.manage permission):
| Method | Path | Purpose |
|---|---|---|
GET | /email/templates | List definitions and overrides |
PUT | /email/templates/{key} | Save a subject and body override |
DELETE | /email/templates/{key} | Reset to the registered default |
POST | /email/templates/{key}/test | Send a test email |
GET / PUT | /email/settings | Read / save transport settings |
POST | /email/settings/test | Test the transport |
Overrides are stored in the email_templates table; deleting the row restores the definition's
defaults.