Switching behavior in Symfony with the Strategy pattern

Cover image for Switching behavior in Symfony with the Strategy pattern

Using the Strategy pattern in Symfony to switch behavior without spreading conditional logic.

Choosing between several behaviors often starts with an if/else or switch. As the number of cases grows, that branch becomes the place every new implementation must modify.

A route finder exposes one operation, but calculates different routes for a car, bicycle, or pedestrian. The Strategy pattern models those implementations behind one contract. This post applies the same idea to article publishers in Symfony.

How the strategy pattern works

The Strategy pattern puts interchangeable algorithms behind one interface. Client code calls the interface without knowing which implementation will run.

Select strategies with dependency injection

Symfony's dependency injection container can collect implementations and select one at runtime.

In this example, the application publishes an article as a JSON feed, RSS feed, or HTML. Each output format becomes a strategy.

Step 1. Define the contract

Start with the interface every publisher must implement.

PHP
interface PublisherInterface
{
    public function publish(Post $post): void;
}

Step 2. Create the implementations

JsonPublisher and RssPublisher implement the same interface. Each class handles one output format.

PHP
final readonly class JSONPublisher implements PublisherInterface
{
    public function __construct(private LoggerInterface $logger) {}

    #[\Override]
    public function publish(Post $post): void
    {
        $fqcn = $this::class;
        $this->logger->critical("Strategy {$fqcn}");
    }

    #[\Override]
    public function supports(string $channel): bool
    {
        return $channel === "json";
    }
}
PHP
final readonly class RSSPublisher implements PublisherInterface
{
    public function __construct(private LoggerInterface $logger) {}

    #[\Override]
    public function publish(Post $post): void
    {
        $fqcn = $this::class;
        $this->logger->critical("Strategy {$fqcn}");
    }

    #[\Override]
    public function supports(string $channel): bool
    {
        return $channel === "rss";
    }
}

The caller can replace one with the other without changing its own code.

Method 1. Inject an iterable

Symfony cannot choose between JsonPublisher and RssPublisher when a constructor asks for PublisherInterface.

One option is to inject every implementation as an iterable, then select the supported one at runtime.

First, I tag the interface so Symfony knows to collect its implementations:

PHP
#[AutoconfigureTag]
interface PublisherInterface
{
    public function publish(Post $post): void;
    public function supports(string $channel): bool;
}

Then, I create a central Publisher service that receives them all:

PHP
final readonly class Publisher
{
    /** @param iterable<PublisherInterface> */
    public function __construct(
        #[AutowireIterator(PublisherInterface::class)]
        private iterable $publishers,
    ) {}

    public function publish(Post $post, string $channel): void
    {
        foreach ($this->publishers as $publisher) {
            if ($publisher->supports($channel)) {
                $publisher->publish($post);
                return;
            }
        }
    }
}

The controller injects Publisher and delegates the operation. It does not know which concrete publisher handles the request.

PHP
final class PostController extends AbstractController
{
    public function __construct(
        private readonly ClockInterface $clock,
        private readonly Publisher $publisher, // Ideally, should use interface instead
    ) {}

    #[Route('/{id}/{channel}', name: 'app_post_show', requirements: [
        "channel" => "html|json|rss"
    ], methods: ['GET'])]
    public function show(Post $post, string $channel = "html"): Response
    {
        $this->publisher->publish($post, $channel);
        return $this->render('post/show.html.twig', ['post' => $post]);
    }
}

Because Publisher exposes the same operation, it can implement PublisherInterface too.

PHP
final readonly class Publisher implements PublisherInterface
{
    public function __construct(
        #[AutowireIterator(PublisherInterface::class, excludeSelf: true)]
        private iterable $publishers,
    ) {}

    #[\Override]
    public function publish(Post $post, string $channel): void
    {
        foreach ($this->publishers as $publisher) {
            if ($publisher->supports($channel)) {
                $publisher->publish($post);
                return;
            }
        }
    }

    #[\Override]
    public function supports(string $channel): bool
    {
        return true;
    }
}

The excludeSelf flag defaults to true, so Symfony does not inject Publisher into itself even when it carries the tag.

Method 2. Inject a service locator

The iterable approach instantiates every publisher before checking which one supports the requested format.

With a service locator, each strategy declares its channel key in advance. The application can retrieve one service by that key.

Split the contract into two interfaces, following the Interface Segregation Principle:

PHP
#[AutoconfigureTag]
interface StrategyPublisherInterface extends PublisherInterface
{
    public static function supports(): string;
}

Each publisher implements StrategyPublisherInterface, and supports() returns its channel.

PHP
interface PublisherInterface
{
    public function publish(Post $post, string $channel = "html"): void;
}

Wire the central Publisher with Symfony's ServiceLocator for an O(1) lookup.

PHP
#[AsAlias(PublisherInterface::class)]
final readonly class Publisher implements PublisherInterface
{
    public function __construct(
        #[AutowireLocator(StrategyPublisherInterface::class, defaultIndexMethod: "supports")]
        private ServiceLocator $publishers
    ) {}

    public function publish(Post $post, string $channel = "html"): void
    {
        if ($this->publishers->has($channel)) {
            $this->publishers->get($channel)->publish($post, $channel);
        }
    }
}

Symfony builds the mapping and instantiates only the selected strategy.

Controller usage becomes even simpler:

PHP
final class PostController extends AbstractController
{
    public function __construct(
        private readonly ClockInterface $clock,
        private readonly PublisherInterface $publisher,
    ) {}

    #[Route('/{id}/{channel}', name: 'app_post_show', requirements: [
        "channel" => "html|json|rss"
    ], methods: ['GET'])]
    public function show(Post $post, string $channel = "html"): Response
    {
        $this->publisher->publish($post, $channel);
        return $this->render('post/show.html.twig', ['post' => $post]);
    }
}

Choose the approach that fits

Both approaches implement the Strategy pattern, with a different cost.

  • The iterable is simple and suits a small set of cheap services.

  • The locator avoids constructing unused services and gives direct keyed lookup.

In both cases, adding a publisher means adding an implementation rather than editing a central conditional. Choose the locator when construction cost or the number of strategies makes eager iteration wasteful.

Related writing