Cookbook

Notification Channels & Templates

Channel internals — email providers, multi-channel selection, custom channels, and the template system.

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:

  1. sync_channels / async_channels in options (or sendSplit())
  2. channels in options
  3. notifications.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):

MethodPathPurpose
GET/email/templatesList 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}/testSend a test email
GET / PUT/email/settingsRead / save transport settings
POST/email/settings/testTest the transport

Overrides are stored in the email_templates table; deleting the row restores the definition's defaults.