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 SlackChannel is a new file and one new entry in the wiring map.
  • The Notifier no 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