Powerful Design Pattern Pairing: Strategy and Chain of Responsibility in Symfony
Hey there! Remember sitting through those design pattern lectures at school or nodding along when other developers dropped pattern names in meetings, all while secretly wondering when you’d actually use any of this in real life? You’re not alone!
🤫 Here’s a little secret: you’re probably already using at least a couple of design patterns every day without even realizing it.
That simple interface with different implementations? That’s the Strategy pattern. Those validation steps where you bail out early if something fails? That’s Chain of Responsibility in action.
Today, we’re going to demystify two of these patterns that Symfony leverages extensively: the Strategy Pattern and Chain of Responsibility Pattern. When combined, these patterns create elegant, maintainable solutions that will genuinely level up your PHP applications.
Plus, you’ll sound super smart 🤓 at developer meetups when you casually mention how you “leveraged the Strategy pattern combined with Chain of Responsibility to create a flexible processing pipeline.” 👊✋🎤🎶
Let’s explore how these patterns work individually, how they complement each other, and how Symfony’s modern DI features make implementing them a breeze.
The Strategy Pattern: Flexibility Through Interchangeable Algorithms
The Strategy Pattern is a powerful design tool that lets you define a family of interchangeable algorithms, encapsulate each one, and make them interchangeable at runtime.
Imagine you need to reach the airport. You have several transportation options: taking a bus, calling a cab, or riding your bicycle. Each of these represents a different transportation strategy. Your choice will depend on various factors like your available budget, time constraints, amount of luggage, or current weather conditions. This everyday scenario perfectly illustrates how we regularly select different strategies to accomplish the same goal (going to the airport) based on specific circumstances.
I’ll use a transport system example to demonstrate this pattern in action. This will clearly illustrate how the Strategy Pattern can simplify what would otherwise be complex conditional code:
interface TransportStrategyInterface {
public function travel(Traveler $traveler): int; // Returns travel time in minutes
}
class BusTransportStrategy implements TransportStrategyInterface {
public function travel(Traveler $traveler): int {
// Inexpensive but slower option.
$traveler->reduceMoney(3);
return 90 + Traffic::calculateDelay(20); // Base time + possible delay.
}
}
class TaxiTransportStrategy implements TransportStrategyInterface {
public function travel(Traveler $traveler): int {
// Expensive but faster option.
$traveler->reduceMoney(50);
return 30 + Traffic::calculateDelay(10); // Base time + possible delay.
}
}
class BicycleTransportStrategy implements TransportStrategyInterface {
public function travel(Traveler $traveler): int {
// Free but very slow and depends on traveler's fitness.
$traveler->reduceEnergy(80);
return 120 + (100 - $traveler->getFitness()); // Base time adjusted by fitness.
}
}
Finally use any of the TransportStrategy in your JourneyPlanner.
class JourneyPlanner {
public function travelToAirport(TransportStrategyInterface $transportStrategy, Traveler $traveler): int {
return $transportStrategy->travel($traveler);
}
}
$planner = new JourneyPlanner();
echo "Bus travel time: " . $planner->travelToAirport(new BusTransportStrategy(), $traveler) . " minutes";
echo "Taxi travel time: " . $planner->travelToAirport(new TaxiTransportStrategy(), $traveler) . " minutes";
echo "Bicycle travel time: " . $planner->travelToAirport(new BicycleTransportStrategy(), $traveler) . " minutes";
What makes the Strategy Pattern so powerful is its ability to decouple how something is done from where it’s used. In our example, the JourneyPlanner
doesn't need to know how a bus route works or how a taxi calculates its route—it just needs to call travelToAirport()
and let the specific strategy handle the details.
This separation brings several key benefits:
- Runtime Flexibility: Planner can change their transport strategy dynamically
- Clean Extensibility: Adding new transport types requires zero changes to existing code
- Simplified Testing: Each strategy can be tested in isolation
- Reduced Complexity: No bloated classes filled with conditional logic for different behaviors
While powerful on its own, the Strategy Pattern becomes even more versatile when combined with other patterns. By pairing it with the Chain of Responsibility pattern (which we’ll explore next), we can create systems that not only select the appropriate algorithm but also determine which algorithm applies in a given context.
The Chain of Responsibility Pattern: Sequential Decision Making
The Chain of Responsibility pattern creates a pipeline where requests flow through a series of handlers, with each handler performing a specific operation on the request. Rather than having a single monolithic process, this pattern breaks down complex operations into discrete, focused steps.
In its classic form, each handler decides whether to process the request and either handles it completely (stopping the chain) or passes it to the next handler. In the variation we’ll explore, each handler modifies the request and passes it along, creating a transformation pipeline (Middleware).
Let’s see this in action by creating a text processing system for a game’s chat feature. Our pipeline will filter inappropriate language, convert emoticons to emojis, and format text — all within a clean, modular structure that’s easy to extend with new processing steps when needed.
interface MessageHandlerInterface
{
public function handle(string &$message): void;
}
class ProfanityFilterHandler implements MessageHandlerInterface
{
private array $badWords = ['noob', 'troll', 'hack'];
public function handle(string &$message): void
{
// Modify the message by reference.
$message = str_replace($this->badWords, '***', strtolower($message));
}
}
class EmoteHandler implements MessageHandlerInterface
{
private array $emotes = [
':smile:' => '😊',
':sad:' => '😢',
':heart:' => '❤️'
];
public function handle(string &$message): void
{
// Modify the message directly by reference.
foreach ($this->emotes as $code => $emoji) {
$message = str_replace($code, $emoji, $message);
}
}
}
Finally, we set up the chain and use it.
// Set up the chain
$profanityFilter = new ProfanityFilterHandler();
$emoteHandler = new EmoteHandler();
$iterator = new Iterator([$profanityFilter, $emoteHandler]);
// Use the chain.
function processMessage(string $rawMessage): string
{
echo "Original message: " . $rawMessage . "\n";
foreach($iterator as $handler) {
$handler->handle($rawMessage);
}
echo "Processed message: " . $processed . "\n";
return $processed;
}
// Example usage
$message = "Hello *noob*, I'm _really_ :sad: about your approach.";
$processed = processMessage($message);
// Output: Hello <strong>***</strong>, I'm <em>really</em> 😢 about your approach.Modern Symfony Integration with Automatic Services
What we’ve implemented is a transformation pipeline — a variation of the Chain of Responsibility where each handler processes the request and passes it forward, ensuring the entire chain executes from start to finish. This approach is perfect for cumulative transformations where each step builds upon the previous one.
It’s important to distinguish between 2 common variations of this pattern:
- The Middleware: As shown in our example, every handler in the chain is executed in sequence, each modifying the request before passing it to the next handler. This is ideal for message filtering, data transformations, and middleware implementations.
- The Classic Chain of Responsibility: In this version, each handler decides whether to process the request and terminate the chain, or pass the request to the next handler. Only one handler (if any) completes the processing. This works well for approval workflows, event handling systems, or command processing where only one handler should respond.
Combining Strategy and Chain of Responsibility
The real power emerges when we combine these patterns. By using the Strategy Pattern to define algorithms and Chain of Responsibility to select the appropriate strategy, we create a system that’s both flexible and decoupled.
Symfony provides several attributes that make working with collections of services more efficient:
- AutoconfigureTag: Automatically tags services implementing a specific interface
- AsTaggedItem: Customizes how a service is identified within a tagged collection
- AutowireIterator: Autowires an iterable of services for a specific tag
Let’s refactor our first example using these attributes:
First, let’s define our strategy interface with the AutoconfigureTag
attribute:
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag]
interface TransportStrategyInterface {
// Determines if this transport option is viable.
public function supports(Traveler $traveler, JourneyContext $context): bool;
// Executes the journey and returns travel time in minutes.
public function travel(Traveler $traveler): int;
}
Next, implement our concrete strategies with the AsTaggedItem
attribute:
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;
#[AsTaggedItem('taxi')]
class TaxiTransportStrategy implements TransportStrategyInterface {
public function supports(Traveler $traveler, JourneyContext $context): bool {
// Only use taxi if traveler has enough money and time is limited.
return $traveler->getMoney() >= 50 && $context->getRemainingTimeToFlight() < 120;
}
public function travel(Traveler $traveler): int {
$traveler->reduceMoney(50);
return 30 + Traffic::calculateDelay(10); // Base time + possible delay
}
}
#[AsTaggedItem('bus')]
class BusTransportStrategy implements TransportStrategyInterface {
public function supports(Traveler $traveler, JourneyContext $context): bool {
// Use bus if traveler has some money and there's enough time.
return $traveler->getMoney() >= 3 && $context->getRemainingTimeToFlight() >= 120;
}
public function travel(Traveler $traveler): int {
$traveler->reduceMoney(3);
return 90 + Traffic::calculateDelay(20); // Base time + possible delay
}
}
// Ultimate fallback - can always walk if nothing else works.
#[AsTaggedItem('walking')]
class WalkingTransportStrategy implements TransportStrategyInterface {
public function supports(Traveler $traveler, JourneyContext $context): bool {
return true;
}
public function travel(Traveler $traveler): int {
$traveler->reduceEnergy(100);
return 180; // Three hours to walk.
}
}
Finally, refactor our context class to use AutowireIterator
:
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
class JourneyPlanner {
public function __construct(
#[AutowireIterator(TransportStrategyInterface::class)]
private readonly iterable $transportStrategies
) {
}
public function planJourney(Traveler $traveler, JourneyContext $context): int {
foreach ($this->transportStrategies as $transport) {
if ($transport->supports($traveler, $context)) {
$travelTime = $transport->travel($traveler);
echo "Using " . get_class($transport) . " with travel time: {$travelTime} minutes\n";
return $travelTime;
}
}
// This should never be reached with WalkingTransportStrategy as fallback
throw new \LogicException('No transport strategy found - check your chain configuration');
}
}
// In a controller or service where Symfony injects the JourneyPlanner.
public function planAirportTrip(Traveler $traveler, JourneyPlanner $journeyPlanner): Response
{
$context = new JourneyContext();
$context->setRemainingTimeToFlight(180); // 3 hours until flight.
$context->setLuggageWeight(15); // 15 kg of luggage.
$context->setWeatherCondition('sunny');
$travelTime = $journeyPlanner->planJourney($traveler, $context);
return $this->json([
'travel_method' => $usedTransport,
'travel_time_minutes' => $travelTime,
'arrival_time' => new \DateTime('+' . $travelTime . ' minutes')
]);
}
Now, the journey planner iterates through transport strategies, using the first one that supports
the traveler's constraints and journey context.
Adding a new transport strategy is as simple as creating a new class implementing the interface — no existing code needs modification.
With this implementation, Symfony automatically:
- Tags all services implementing
TransportStrategyInterface
- Identifies each strategy with the tag value provided in
AsTaggedItem
- Injects all tagged strategies into our
JourneyPlanner
class
This means we can add new strategies to our application without modifying any existing code — just create a new class implementing the interface, and Symfony handles the rest.
Conclusion
The marriage of the Strategy and Chain of Responsibility patterns is a game-changer for writing clean, modular code. By letting each strategy decide its applicability, Symfony components achieve remarkable extensibility and simplicity.
- Flexibility: Swap or add strategies without altering existing code (Open/Closed Principle).
- Decoupling: Strategies don’t need to know about each other.
- Maintainability: Clear separation of concerns makes debugging easier.
- Scalability: New behaviors are added by introducing new strategy classes.
Using Symfony’s modern attributes like AutoconfigureTag
, AsTaggedItem
, and AutowireIterator
makes implementing these patterns even more elegant, reducing boilerplate code and improving maintainability.
The next time you find yourself writing complex conditional logic or facing a system that needs to be extended with new behaviors frequently, consider using this powerful pattern pairing. Your future self (and teammates) will thank you for the clean, flexible, and maintainable code.
Remember, good design is not about showing off your knowledge of patterns — it’s about creating systems that are easy to understand, extend, and maintain. The Strategy and Chain of Responsibility patterns, when used appropriately, help achieve exactly that.
❤️ Happy coding!
Sources
Saman Esmaeil (2025) Why Strategy and Chain of Responsibility are everywhere in Symfony Components.
https://medium.com/@teal33t/why-strategy-and-chain-of-responsibility-are-everywhere-in-symfony-components-5c52f53a8b66
Java Design Patterns
https://java-design-patterns.com/patterns/#get-the-e-book
Cap Coding (2020) Chain of Responsibility.
https://github.com/Cap-Coding/chain_of_responsibility
Refactoring Guru (2025) Chain of Responsibility. https://refactoring.guru/design-patterns/chain-of-responsibility
Refactoring Guru (2025) Strategy.
https://refactoring.guru/design-patterns/strategy
SymfonyCasts (2025)
https://symfonycasts.com/screencast
Refactoring Guru (2025) Design Patterns.
https://refactoring.guru/design-patterns
Resources
Claude AI, https://claude.ai
Assisted with writing and refining my English.
DALL-E & Midjourney, https://openai.com & https://www.midjourney.com
Generated the very accurate article’s cover illustration.
This article has been first write for the Blog of Antistatique — Web Agency in Lausanne, Switzerland.
A place where I work as Senior Backend Developer. Feel free to read it here or check it out there: https://antistatique.net/en/blog
All images copyright of their respective owners.
Big thanks to @Antistatique for the review.