The Strategy Pattern is one of those patterns that makes sense when the problem naturally arises.
The idea is simple: you have the same business intention, but several ways of executing it.
As long as this remains small, it is usually not an issue. The problem starts when this variation grows, becomes mixed into a central class, and every new case forces you to keep adding more decisions in the same place.
In Symfony projects, this happens quite often: an operation that changes depending on the channel, provider, format, or a specific business rule. This is where Strategy can be a useful way to separate behaviours without making the design unnecessarily complicated.
The key is not to use the pattern simply because it exists, but to recognise when it genuinely helps to distribute responsibilities and keep the codebase in a maintainable state.
The real problem is not the if statement
An if statement is not a problem.
Two are not either.
The problem starts when the same business intention can be executed in several different ways and all this variation ends up concentrated in a single class:
- depending on the channel;
- depending on the provider;
- depending on the type of customer;
- depending on the country;
- depending on the format;
- depending on the operation;
- depending on the execution context.
This is where the code starts to deteriorate in a fairly predictable way:
- the class grows;
- the constructor starts filling up with dependencies;
- adding a new case requires changes to existing code;
- testing a specific variant becomes inconvenient;
- and the main service turns into a place where decisions accumulate.
This is the scenario where Strategy usually starts to make sense.
What is the Strategy Pattern
The idea is fairly simple: if you have several behaviours for the same intention, do not put all the logic into one central class.
Define a common interface, create one implementation for each variant, and delegate execution to the one that is appropriate.
In other words:
- one single intention;
- several possible implementations;
- selection at runtime depending on the context.
The value of the pattern does not lie in its theoretical definition, but in what it helps to avoid: classes with too many responsibilities, conditionals that continue to grow indefinitely, and logic that is difficult to extend or test.
Not every variable behaviour needs a Strategy
This is an important point to consider, because it is exactly where it is easiest to over-engineer.
Seeing a variation in behaviour and immediately jumping to Strategy is often a mistake. In some cases, the best solution is not a resolver with multiple strategies, but simply polymorphism.
If the behaviour naturally belongs to the object itself, it will probably make more sense for it to live there.
For example, a shipping cost calculation based on the carrier is not the best case for justifying a Strategy. If each carrier has its own logic and this is a natural part of its responsibility, it would probably make more sense to model it like this:
interface Carrier
{
public function calculateShippingCost(float $weight): float;
}
final class SeurCarrier implements Carrier
{
public function calculateShippingCost(float $weight): float
{
return 5 + ($weight * 0.80);
}
}
final class DhlCarrier implements Carrier
{
public function calculateShippingCost(float $weight): float
{
return 7 + ($weight * 0.65);
}
}
And the usage would be direct:
$cost = $carrier->calculateShippingCost($weight);
Here, there is no need for an additional layer to iterate through strategies or a supports() method to decide which one applies. The behaviour is already where it should be.
This is an important distinction: it is not about choosing between an if statement and Strategy, but about choosing the right abstraction.
And sometimes the right abstraction is not Strategy, but simply a better model.
When Strategy is actually worth using
Strategy is a better fit when the selection of behaviour does not naturally belong to a specific entity, but is instead determined externally, based on context, application rules, or dynamic composition.
For example:
- when a business policy changes depending on external conditions;
- when several implementations share the same intention but are not part of the same domain model;
- when you are decoupling integrations with third parties;
- when the decision is made within an application service;
- when you want to add new variants without reopening a central class.
In these types of scenarios, using Strategy is usually worthwhile.
Why it fits so well with Symfony
Symfony makes it relatively easy to implement this pattern in a clean way because the service container is already designed to work with multiple implementations.
You can:
- have several classes implementing the same interface;
- register them automatically;
- tag them with tags;
- inject them as an iterable;
- resolve the appropriate implementation without coupling yourself to concrete classes.
For example:
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
final class DiscountResolver
{
public function __construct(
#[TaggedIterator('app.discount_strategy')]
private iterable $strategies
) {
}
}
And in services.yaml:
services:
App\Discount\Strategy\:
resource: '../src/Discount/Strategy/*'
tags: ['app.discount_strategy']
In Symfony, this comes quite naturally. There is no need to build unusual infrastructure or create your own mechanisms for discovering implementations.
This good alignment with the framework is one of the reasons why the pattern is particularly convenient in this ecosystem.
Real-world use cases of the Strategy Pattern in Symfony
Notifications by channel
This is one of the clearest examples.
You have the same business intention: sending a notification. However, the way it is done changes depending on the channel:
- email;
- SMS;
- push;
- Slack;
- webhook.
Here, the implementation changes significantly, the dependencies are usually different, and the decision is normally made externally rather than by the object itself.
You are not modelling the natural behaviour of an entity, but rather resolving a sending policy.
This is where Strategy fits well.
interface NotificationStrategyInterface
{
public function supports(string $channel): bool;
public function send(Notification $notification): void;
}
final class EmailNotificationStrategy implements NotificationStrategyInterface
{
public function __construct(
private MailerInterface $mailer
) {
}
public function supports(string $channel): bool
{
return $channel === 'email';
}
public function send(Notification $notification): void
{
// envío por email
}
}
final class SmsNotificationStrategy implements NotificationStrategyInterface
{
public function __construct(
private SmsClient $smsClient
) {
}
public function supports(string $channel): bool
{
return $channel === 'sms';
}
public function send(Notification $notification): void
{
// envío por SMS
}
}
final class NotificationSender
{
/**
* @param iterable<NotificationStrategyInterface> $strategies
*/
public function __construct(
private iterable $strategies
) {
}
public function send(string $channel, Notification $notification): void
{
foreach ($this->strategies as $strategy) {
if ($strategy->supports($channel)) {
$strategy->send($notification);
return;
}
}
throw new \RuntimeException(sprintf('Channel "%s" not supported', $channel));
}
}
Integrations with external providers
Another very common case.
From a functional perspective, you want to do “the same thing”, but the provider changes:
- payment gateways;
- external carriers;
- CRMs;
- signing systems;
- verification services;
- identity providers.
In these types of scenarios, trying to centralise all the logic usually leads to a class full of branches, HTTP clients, payload adaptations, and provider-specific error handling.
Separating these responsibilities into individual strategies is usually a much more reasonable approach.
interface PaymentStrategyInterface
{
public function supports(string $provider): bool;
public function charge(PaymentRequest $request): PaymentResult;
}
Each provider encapsulates its own behaviour, dependencies, and mapping logic. The use case or application service only needs to resolve which one it requires.
Exporting in different formats
Very common in management and back-office applications.
The intention is the same: exporting data. However, exporting to the following formats is not the same:
- CSV;
- Excel;
- JSON;
- XML;
- PDF.
Here, the choice of behaviour usually comes from the application context rather than from a natural responsibility of a domain entity. Each format can require its own libraries, structure, and processing logic.
interface ExportStrategyInterface
{
public function supports(string $format): bool;
public function export(array $data): string;
}
Each format has its own class, each implementation is isolated, and the main service remains much cleaner.
Business policies depending on context
This is one of the most interesting use cases, although it is less visually obvious.
For example:
- applying discounts based on business rules;
- deciding a processing policy based on the origin;
- executing different validations depending on the transition;
- selecting an action depending on the type of operation;
- resolving a different rule depending on the channel or environment.
In these cases, the important point is that the variation in behaviour does not clearly belong to a specific entity, but rather to a policy that the application needs to resolve.
This is where Strategy is often a good way to encapsulate the logic without putting everything into the same service.
Example with discounts
interface DiscountStrategyInterface
{
public function supports(Order $order): bool;
public function apply(Order $order): int;
}
final class PremiumDiscountStrategy implements DiscountStrategyInterface
{
public function supports(Order $order): bool
{
return $order->customer()->isPremium();
}
public function apply(Order $order): int
{
return (int) round($order->subtotal() * 0.10);
}
}
final class CampaignDiscountStrategy implements DiscountStrategyInterface
{
public function supports(Order $order): bool
{
return $order->hasActiveCampaign();
}
public function apply(Order $order): int
{
return 500;
}
}
final class DiscountResolver
{
/**
* @param iterable<DiscountStrategyInterface> $strategies
*/
public function __construct(
private iterable $strategies
) {
}
public function resolve(Order $order): int
{
foreach ($this->strategies as $strategy) {
if ($strategy->supports($order)) {
return $strategy->apply($order);
}
}
return 0;
}
}
When not to use the Strategy Pattern
This point is just as important as the rest.
Not everything needs a Strategy. In some teams, there is a tendency to introduce patterns before the problem that would justify using them actually exists.
If you have two simple, stable cases and the logic is minimal, a match expression or even an if statement will probably solve the problem better.
return match ($type) {
'fixed' => 100,
'percent' => 200,
default => 0,
};
This does not need an interface, three implementations, and a resolver. It needs you not to make something more complicated than it currently is.
Strategy starts to be worthwhile when several of these conditions apply:
· the logic for each case starts to grow;
· each variant requires its own dependencies;
· the number of cases may continue to increase;
· testing each behaviour separately provides value;
· changing one case always requires reopening the same class;
· the selection of behaviour is made from an application service or an external policy.
Signs that Strategy might be useful in a Symfony project
There are some fairly clear symptoms.
The main service starts making too many decisions
If your CheckoutService, NotificationService, ExportService, or similar service accumulates too many branches “depending on the type”, there is usually an abstraction waiting to be introduced.
The constructor starts filling up with dependencies that are not always used
When a single class requires too many collaborators because it supports too many different variants, there is almost always a better way to distribute responsibilities.
Adding a new case always means modifying the same sensitive class
If every new variant requires changes to a central service that already mixes too many decisions, you are creating an unnecessary point of friction.
Testing a specific variant becomes increasingly difficult
When testing a specific branch requires setting up half of the application context because the logic is embedded in a large class, that logic is probably in the wrong place.
Real advantages of the Strategy Pattern
The advantages are not just theoretical. In real projects, they become quite noticeable when the pattern is applied correctly.
The first advantage is maintainability. Each behaviour lives in its own class and no longer causes a central service to grow unnecessarily.
The second advantage is extensibility. Adding a new variant usually means creating a new implementation rather than reopening a particularly sensitive component.
The third advantage is testability. Testing isolated behaviours is much more convenient than testing a service with multiple mixed branches.
The fourth advantage is clarity. When someone joins the project, understanding the system through its different variants is usually easier if each one is encapsulated.
And the fifth advantage, particularly in Symfony, is that it integrates very well with the service container and the service system.
Common mistakes when applying it
It is also important not to idealise it.
Over-designing too early
This is the most common mistake. Applying Strategy to something that is still too simple only adds unnecessary complexity.
Not clearly defining the selection criteria
If several strategies can return true for the same case and there is no clear policy, you introduce ambiguity.
In that situation, you need to decide whether there should be a single valid strategy, whether priorities should exist, or whether you actually need to execute several strategies.
Splitting things up without genuinely improving the design
Separating responsibilities is good. However, if following a simple flow requires jumping between too many small classes that add little value, the cognitive overhead increases and the design becomes worse.
Using it to hide a poor model
Sometimes the problem is not the absence of a pattern. It is that the domain model is poorly designed.
And creating more services does not solve that.
Testing: where Strategy usually pays off most
One of the areas where this pattern is most noticeable is testing.
A specific strategy is usually easy to test in isolation:
public function test_premium_discount_is_applied(): void
{
$order = $this->createPremiumOrderWithSubtotal(10000);
$strategy = new PremiumDiscountStrategy();
self::assertTrue($strategy->supports($order));
self::assertSame(1000, $strategy->apply($order));
}
The resolver can also be tested in a straightforward way:
public function test_it_uses_the_first_supported_strategy(): void
{
$strategy = $this->createMock(DiscountStrategyInterface::class);
$strategy->method('supports')->willReturn(true);
$strategy->method('apply')->willReturn(500);
$resolver = new DiscountResolver([$strategy]);
self::assertSame(500, $resolver->resolve($order));
}
This is usually much more convenient than testing a central class with multiple branches and too many mixed dependencies.
Conclusion
The Strategy Pattern provides value when it prevents a single class from concentrating too many behavioural variants and too many business decisions.
In Symfony, it fits particularly well because the framework makes it easy to work with multiple implementations, tags, and strategy injection.
However, precisely because of this, it should be used carefully.
Not every variation in behaviour requires a Strategy. If the behaviour belongs to the object itself, it will probably make more sense to start with polymorphism. If the problem is still simple, a match expression may be enough.
Strategy is worthwhile when the selection of behaviour is decided externally, depends on context, represents an application policy, or needs to grow without turning a central class into a maintenance bottleneck.
In these cases, it works very well. And in WATA Factory, this is exactly the type of scenario where we use this pattern: when we need to decouple behaviours, support the evolution of the system, and keep the code prepared to grow without losing clarity.
Because, in the end, the key is not applying patterns. It is choosing solutions that continue to make sense as the project evolves.



