ForgeNotification

The communication capability your app uses to reach people — email, SMS, and push — through a choice of providers, with a fluent sending API and optional async queueing.

Overview

ForgeNotification gives your app one consistent way to send outbound messages, no matter the channel. Email, SMS, and push are modeled as channels; each channel routes to a concrete provider that actually delivers the message.

Two layers keep things separate and swappable:

  • Channels are the semantic type — "an email", "a text". They keep a clean, fluent builder API regardless of who delivers.
  • Providers are the concrete integrations — SMTP, Twilio, and so on. They implement one small interface, so swapping the delivery backend is a config change.

You can send right away (synchronously) or hand the message to the event system to send asynchronously — the same builder describes both, so switching is natural.

Framing: this is a component of your app, not a framework mailer. You configure a provider or two, describe your messages with the fluent API, and decide sync or async — the delivery details stay out of your way.

Installation

php forge.php package:install-module --module=forge-notification

The capability relies on the event system to run its async queue, so that capability should be present too. There's no database work required on install — message history is left to your own tables.

Configuration

You pick a default channel and, for each channel, a default provider plus that provider's credentials. Everything is driven by a small set of environment variables:

Variable Default Effect
NOTIFICATION_DEFAULT_CHANNEL email Which channel is considered the default
NOTIFICATION_QUEUE_ENABLED / ..._QUEUE_NAME true / notifications Whether async sending is on, and the queue it uses
NOTIFICATION_QUEUE_PRIORITY / ..._MAX_RETRIES / ..._DELAY normal / 3 / 0s Queue priority, retry count, and delay for queued sends
NOTIFICATION_EMAIL_PROVIDER smtp Default email provider
SMTP_HOST / SMTP_PORT localhost / 1025 SMTP server and port (1025 suits local mail catchers like Mailpit)
SMTP_USERNAME / SMTP_PASSWORD Optional SMTP login (skipped when empty)
SMTP_ENCRYPTION none TLS (tls or ssl) or plain — most servers use tls
SMTP_FROM_ADDRESS / SMTP_FROM_NAME noreply@localhost / Forge Default sender when a message doesn't set its own
NOTIFICATION_SMS_PROVIDER twilio Default SMS provider
TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_FROM Twilio credentials and the sending number
NOTIFICATION_PUSH_PROVIDER firebase Default push provider

The provider list is a plain map keyed per channel. That same config also declares SendGrid and Mailgun (email), Vonage (SMS), and Firebase and OneSignal (push) — you can wire those up by swapping the default provider — but only SMTP and the Twilio SMS provider ship working implementations today, so those are the two you can rely on out of the box.

Channels

Each channel is an object you reach through the notification service (or directly through the channel manager). They all share the same shape: build it up with fluent calls, then send() or queue().

Email

The workhorse channel. Accepts recipients, subject, plain-text and HTML bodies, sender, CC/BCC, attachments, and reply-to. When you give HTML but no plain text, the plain text/body you supply is used for the text part; with both, the message is sent as a multi-part alternative.

SMS

Short text messages sent to one or more phone numbers through a provider like Twilio. Takes a recipient, the message, and an optional sender.

Push

Mobile/desktop push notifications. Takes recipient device tokens or user IDs, a title, a body, and optional presentation extras — badge, sound, icon, image, click action — plus a custom data payload.

Picking a provider: use via('provider') on a channel to route that one message through a specific provider, overriding the channel's default.

Sending Notifications

The most direct path is the fluent API. Resolve the notification service, pick a channel with email(), sms(), or push(), describe the message, and call send():

use Forge\Core\Contracts\NotificationInterface;

$notifications = $container->get(NotificationInterface::class);

$notifications->email()
    ->to('ada@example.com')
    ->subject('Welcome to Acme')
    ->html('<h1>Hello Ada</h1><p>Thanks for joining.</p>')
    ->text('Hello Ada, thanks for joining.')
    ->send();

Email also supports cc(), bcc(), replyTo(), from(), and attachments(). SMS takes message(), push takes title() plus the extras above:

$notifications->sms()
    ->to('+15551234567')
    ->message('Your code is 123456')
    ->send();

$notifications->push()
    ->to('device-token-abc')
    ->title('New message')
    ->body('Ada replied to your post')
    ->data(['post_id' => 42])
    ->send();

send() returns a boolean so you can react to success or failure directly in your code. Required fields are enforced — for example, an email without a recipient, or a push without a title or body, throws before anything is attempted.

Payloads

When you'd rather describe a message as data than with a chain of calls, build a typed payload and send it through the channel plus send(). This form is handy when your messages are generated from records or templates:

use Modules\ForgeNotification\Enums\NotificationChannel;
use Modules\ForgeNotification\Payload\EmailPayload;
use Modules\ForgeNotification\Payload\SmsPayload;
use Modules\ForgeNotification\Payload\PushPayload;

$email = new EmailPayload(
    to: 'ada@example.com',
    subject: 'Your receipt',
    text: 'Thanks for your order.',
    from: 'billing@acme.com',
    via: 'smtp',
);

$notifications->send(NotificationChannel::email, $email);

Three payload classes mirror the channels: EmailPayload (to, subject, html, text, from, cc, bcc, replyTo, attachments, via), SmsPayload (to, message, from, via), and PushPayload (to, title, body, data, via). The via field, when set, selects the provider just like the fluent via() call.

A global sendNotification($channel, $payload) helper wraps this resolution for one-liners, and a SendsNotifications trait drops the same method onto your own classes. Any failure is caught and routed to the error handler rather than thrown.

Queued Notifications

For work that shouldn't block a request — welcome mail, receipt texts, digests — hand the message to the event queue instead of sending immediately. Call queue() on a built-up channel:

$notifications->email()
    ->to('ada@example.com')
    ->subject('Welcome to Acme')
    ->html('<h1>Hello Ada</h1>')
    ->queue();

Or pass a typed DTO to the queueing methods on the service, queueEmail(), queueSms(), or queuePush().

Queued messages become events on the notifications queue. A listener picks each one up and routes it to its channel for delivery. Through the queue capabability you control retries (default 3), priority (default normal), and any initial delay — so a busy provider or a momentary failure doesn't lose a message.

One builder, two behaviors: the exact same description becomes a synchronous call with send() or an async job with queue(). Your message logic stays unchanged when you switch a message to run in the background.

From Your Code

The notification service registers itself under the NotificationInterface contract, so your code can depend on the interface and stay free of module internals. Resolve it and send:

use Forge\Core\Contracts\NotificationInterface;

public function __construct(
    private readonly NotificationInterface $notifications,
) {}

public function sendWelcome(string $email): void
{
    $this->notifications->email()
        ->to($email)
        ->subject('Welcome!')
        ->html('<p>Thanks for signing up.</p>')
        ->queue();
}

For custom delivery logic you can work with the channel manager and provider resolver directly, but for nearly all app code the interface plus the fluent API is all you need. Where you store sent messages for your own record — a notifications or outbox table — is entirely up to you.