Originally published on phpdesignpatterns.dev — part of the PHP design patterns course. View this lesson on the original site.
A notification dispatcher, end to end
A complete, self-contained example showing the full arc — problem to solution — in one file.
The starting point
A notification dispatcher. One method, three channels.
<?php
final class Notifier
{
public function send(string $channel, string $to, string $message): void
{
if ($channel === 'email') {
mail($to, 'Notification', $message);
return;
}
if ($channel === 'sms') {
$url = 'https://api.smsprovider.test/send';
file_get_contents($url . '?to=' . urlencode($to) . '&body=' . urlencode($message));
return;
}
if ($channel === 'log') {
file_put_contents('/var/log/notify.log', "{$to}: {$message}\n", FILE_APPEND);
return;
}
throw new RuntimeException("Unknown channel: {$channel}");
}
}
What's wrong
The picker (which channel) and the doing (mail / HTTP / file write) are tangled. Each channel has different effects, dependencies, and failure modes — but they share one method body. Adding a slack channel means another if.
The interface
<?php
interface NotificationChannel
{
public function send(string $to, string $message): void;
}
One method, named after the question. Two parameters, both required. Void return because the operation is a side effect.
The strategies
<?php
final class EmailChannel implements NotificationChannel
{
public function send(string $to, string $message): void
{
mail($to, 'Notification', $message);
}
}
final class SmsChannel implements NotificationChannel
{
public function __construct(private SmsClient $client) {}
public function send(string $to, string $message): void
{
$this->client->send($to, $message);
}
}
final class LogChannel implements NotificationChannel
{
public function __construct(private string $logFile) {}
public function send(string $to, string $message): void
{
file_put_contents($this->logFile, "{$to}: {$message}\n", FILE_APPEND);
}
}
Notice each strategy carries its own dependencies (SmsClient, $logFile) instead of fishing them out of globals. That's part of what made the original code awkward.
The picker
<?php
final class Notifier
{
/** @param array<string, NotificationChannel> $channels */
public function __construct(private array $channels) {}
public function send(string $channel, string $to, string $message): void
{
if (! isset($this->channels[$channel])) {
throw new InvalidArgumentException("Unknown channel: {$channel}");
}
$this->channels[$channel]->send($to, $message);
}
}
The wiring
<?php
$notifier = new Notifier([
'email' => new EmailChannel(),
'sms' => new SmsChannel(new SmsClient(/* config */)),
'log' => new LogChannel('/var/log/notify.log'),
]);
$notifier->send('email', '[email protected]', 'Order shipped');
What we now have
- Each channel is testable in isolation, with a fake client where needed.
- Adding a
SlackChannelis a new file and one new entry in the wiring map. - The
Notifierno longer knows anything about how to send — it knows how to dispatch. Two responsibilities, two places.
That's the whole arc. Problem → example → why → how → restraint, in fourteen lessons. The next lesson is your turn.
From PHP design patterns — Strategy: replacing branching with swappable behaviour