- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open the OrderService and there it is. A TwilioClient injected next to the order repository. An SmtpMailer two lines below. A SlackWebhook a bit further down. The method that confirms an order also decides that confirmations go over SMS, formats the phone number, and swallows the Twilio exception so a texting outage doesn't roll back the order.
Then a test fails. Not because the order logic broke, but because CI tried to send a real SMS and the sandbox credit ran out. Someone comments out the notify line. Six weeks later nobody remembers why welcome texts stopped going out.
The notification concern leaked into the domain. Symfony Notifier can pull it back to the edge, and a small port keeps it there.
What Notifier gives you
Symfony Notifier is the component for sending a message across channels: email, SMS, chat (Slack, Telegram, Teams), push, and desktop. You configure transports once, then send a Notification to a Recipient and the component picks channels.
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\Recipient\Recipient;
$notification = (new Notification('Order shipped'))
->content('Your order #1042 is on the way.')
->importance(Notification::IMPORTANCE_HIGH);
$recipient = new Recipient(
'ana@example.com',
'+15555550142',
);
$notifier->send($notification, $recipient);
Transports live in config/packages/notifier.yaml:
framework:
notifier:
texter_transports:
twilio: '%env(TWILIO_DSN)%'
chatter_transports:
slack: '%env(SLACK_DSN)%'
channel_policy:
urgent: ['sms', 'chat/slack', 'email']
high: ['chat/slack', 'email']
medium: ['email']
low: ['email']
The channel_policy maps an importance level to an ordered channel list. IMPORTANCE_URGENT fans out to SMS, Slack, and email. IMPORTANCE_LOW stays on email. That routing table is infrastructure, and it belongs in config, not in a service method.
This already beats hand-wired SDK calls. But NotifierInterface, Notification, and Recipient are Symfony types. Import them into your domain and you have swapped three vendor couplings for one framework coupling. Better, still not where you want the line.
The domain port
Your domain has an intent: tell this customer their order shipped. It does not care that "high importance" means Slack plus email today. Model the intent with your own types.
<?php
// src/Domain/Notification/Notifier.php
namespace App\Domain\Notification;
interface Notifier
{
public function send(
Recipient $to,
Notification $notification,
): void;
}
<?php
// src/Domain/Notification/Notification.php
namespace App\Domain\Notification;
final readonly class Notification
{
public function __construct(
public string $subject,
public string $body,
public Urgency $urgency = Urgency::Normal,
) {}
}
<?php
// src/Domain/Notification/Urgency.php
namespace App\Domain\Notification;
enum Urgency
{
case Low;
case Normal;
case High;
case Urgent;
}
<?php
// src/Domain/Notification/Recipient.php
namespace App\Domain\Notification;
final readonly class Recipient
{
public function __construct(
public ?string $email = null,
public ?string $phone = null,
) {}
}
Nothing here mentions Symfony, Twilio, or Slack. The domain sends a Notification to a Recipient and moves on. OrderService now depends on App\Domain\Notification\Notifier only.
public function ship(Order $order): void
{
$order->markShipped();
$this->orders->save($order);
$this->notifier->send(
$order->customerRecipient(),
new Notification(
subject: 'Order shipped',
body: "Order #{$order->id()} is on the way.",
urgency: Urgency::High,
),
);
}
The Symfony adapter
The adapter is the one class allowed to know both worlds. It translates the domain Notification into Symfony's, maps Urgency to importance, and delegates to NotifierInterface.
<?php
// src/Infrastructure/Notification/SymfonyNotifier.php
namespace App\Infrastructure\Notification;
use App\Domain\Notification\Notifier;
use App\Domain\Notification\Notification as Domain;
use App\Domain\Notification\Recipient as DomainTo;
use App\Domain\Notification\Urgency;
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\Recipient\Recipient;
final readonly class SymfonyNotifier implements Notifier
{
public function __construct(
private NotifierInterface $notifier,
) {}
public function send(DomainTo $to, Domain $n): void
{
$notification = (new Notification($n->subject))
->content($n->body)
->importance($this->map($n->urgency));
$this->notifier->send(
$notification,
new Recipient(
$to->email ?? '',
$to->phone ?? '',
),
);
}
private function map(Urgency $u): string
{
return match ($u) {
Urgency::Low => Notification::IMPORTANCE_LOW,
Urgency::Normal => Notification::IMPORTANCE_MEDIUM,
Urgency::High => Notification::IMPORTANCE_HIGH,
Urgency::Urgent => Notification::IMPORTANCE_URGENT,
};
}
}
Bind the port to the adapter in services.yaml:
services:
App\Domain\Notification\Notifier:
alias: App\Infrastructure\Notification\SymfonyNotifier
The channel decision now lives in three places, each at the right altitude. The domain sets urgency. notifier.yaml maps urgency to channels. Symfony's transports own the wire protocol. Add Telegram next quarter and the domain does not change a line.
One adapter per channel when you need it
The channel policy covers routing by importance. Sometimes you want a channel the standard transports do not model well, or you want per-channel logic the policy cannot express: strip HTML for SMS, attach a Slack block, respect a customer's opt-out per channel.
For that, keep the port and put a small strategy behind it. Each channel gets its own class implementing a narrow interface, and a router picks one.
<?php
// src/Infrastructure/Notification/Channel.php
namespace App\Infrastructure\Notification;
use App\Domain\Notification\Notification;
use App\Domain\Notification\Recipient;
interface Channel
{
public function supports(Recipient $to): bool;
public function deliver(
Recipient $to,
Notification $n,
): void;
}
An SMS channel implements supports() as "the recipient has a phone number" and deliver() as the Twilio texter call. A chat channel checks for a Slack ID. The router iterates channels in priority order and delivers through the first that supports the recipient, which gives you fallback across kinds of contact, not just across SMS providers.
This is the tag-and-collect pattern: tag every Channel, inject the set into the router, add a channel by writing a class. The domain still sees one Notifier.
Failover so an outage does not eat the message
Two failure modes, two answers.
Provider-level: Twilio is down but Vonage is up. Symfony solves this in the transport config with the || failover operator. The second transport is tried only when the first throws.
# config/packages/notifier.yaml
framework:
notifier:
texter_transports:
sms: '%env(TWILIO_DSN)% || %env(VONAGE_DSN)%'
Use && instead when you want round-robin load spreading across providers and failover only on error.
Channel-level: SMS is unreachable for this customer, so try email. That is a domain decision about what "notified" means, so it belongs in your router, not a DSN. Walk the channels, keep the last error, and only give up when every channel fails.
public function send(Recipient $to, Notification $n): void
{
$errors = [];
foreach ($this->channels as $channel) {
if (!$channel->supports($to)) {
continue;
}
try {
$channel->deliver($to, $n);
return;
} catch (\Throwable $e) {
$errors[] = $e;
}
}
throw new AllChannelsFailed($errors);
}
AllChannelsFailed is a small custom domain exception that carries the collected errors:
<?php
// src/Domain/Notification/AllChannelsFailed.php
namespace App\Domain\Notification;
final class AllChannelsFailed extends \RuntimeException
{
/** @param list<\Throwable> $errors */
public function __construct(public array $errors)
{
parent::__construct('Every channel failed.');
}
}
Provider failover is infrastructure and stays in config. Channel failover is policy and stays in code you own. Keeping them apart is the point.
Testing without sending anything
Because the domain depends on App\Domain\Notification\Notifier, tests inject a fake. No transports boot, no credits burn, no network.
<?php
// tests/Support/RecordingNotifier.php
namespace App\Tests\Support;
use App\Domain\Notification\Notifier;
use App\Domain\Notification\Notification;
use App\Domain\Notification\Recipient;
final class RecordingNotifier implements Notifier
{
/** @var list<array{Recipient, Notification}> */
public array $sent = [];
public function send(
Recipient $to,
Notification $n,
): void {
$this->sent[] = [$to, $n];
}
}
The test asserts intent, not delivery.
public function testShippingNotifiesCustomer(): void
{
$notifier = new RecordingNotifier();
$service = new OrderService($this->orders, $notifier);
$service->ship($order);
self::assertCount(1, $notifier->sent);
[$to, $n] = $notifier->sent[0];
self::assertSame(Urgency::High, $n->urgency);
self::assertSame($order->email(), $to->email);
}
If you want to exercise the Symfony wiring too, the framework ships a null transport (null://null) and the Notifier test constraints for functional tests. But the unit that matters, "does shipping an order produce a high-urgency notification," runs in microseconds against the fake and never touches a provider.
That is the payoff. The one thing that used to break CI and roll back orders is now a value object you assert on.
If this was useful
Notifications are a textbook edge concern: the domain has an intent, the outside world has SMS gateways and chat webhooks and rate limits. Put a port between them and the domain stays testable while the channel list, the failover rules, and the vendor SDKs all move at the edge where they belong. That boundary is the whole subject of Decoupled PHP: what the framework owns, what your domain owns, and the thin adapter that translates between them.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)