Strategy pattern in Symfony 7
When an application needs to behave differently under certain conditions,
developers often write multiple if statements with separate return values.
<?php
function getPercentagePaid(): int
{
$totalAmountPaid = $this->invoiceListing->paidValue;
if ($totalAmountPaid === 0) {
return 0;
}
$invoiceTotal = $this->invoiceListing->getTotalAmount();
if ($invoiceTotal === 0) {
return 0;
}
$proportionalPaid = ((int) $this->cents / $invoiceTotal) * $totalAmountPaid;
$result = min($proportionalPaid / (int) $this->cents, 1) * 100;
return (int) $result;
}
This way of programming is problematic because it makes it harder to create
tests, and the function becomes increasingly complex as new rules are added.
It’s very common for developers to implement business rules like this using if
statements, switch, and even the new match in PHP 8.
<?php
function getStatus(): InvoiceStatus
{
$invoiceItemsTotalPay = $this->items->reduce(
fn (int $initial, InvoiceListingItem $a) =>
(int) $a->cents + $initial,
0
);
return match (true) {
$this->paidValue === 0 => InvoiceStatus::PENDENTE,
$invoiceItemsTotalPay !== 0
&& (int) $this->paidValue >= $invoiceItemsTotalPay => InvoiceStatus::PAGO,
default => InvoiceStatus::PARCIALMENTE_PAGO,
};
}
The Strategy pattern was designed to address this problem. When using it, you can place numerous conditions without increasing the code’s complexity, and you can test every little scenario individually. The best thing about it is that the code looks more elegant and professional.
For those of you who don’t know, the strategy pattern is a code pattern where the system can do the same thing in different ways, you can define numerous strategies without increasing the code’s complexity, and the best part is that every strategy can be unit tested by
PHPUnitwithout mocking dependencies.

Therefore, when the system behaves in different ways and has the same type of
return, you should consider using the strategy pattern instead of multiple
if statements. Luckily, you can do this in Symfony 7 without any hassle and
with minimal configuration.
How to implement
The pattern is composed of:
- A context class
- A single class for each strategy
- Auxiliary classes
The context class will iterate over all strategies until it finds one that
matches the current situation, and then return the strategy result. The strategy
implements the condition and the result itself (which replaces the if
statements and the multiple returns). Auxiliary classes can be used to represent
common data structures for the current scenario.
In this article you will find three real-life examples:
- Business rule
- API Platform operations
- Doctrine intelligent search
Example one: Business rule
By following the example in the article’s beginning, we are going to map all the data that is needed in the context with the following class.
<?php
namespace App\Service\Invoice\PercentageCalculation;
readonly class PercentageCalcRequest
{
public function __construct(
public int $paidValue,
public int $totalAmount,
public int $invoiceItemCents,
) {}
}
Now we’re going to make an interface that every strategy should apply, the
function matches() will replace the if statement and the function
handle() will replace the return.
<?php
namespace App\Service\Invoice\PercentageCalculation;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag('app.invoice_item_percentage_calc')]
interface PercentageCalcInterface
{
public function matches(PercentageCalcRequest $req): bool;
public function handle(PercentageCalcRequest $req): int;
}
Note that I’m using a Symfony attribute at the top of the interface, this
attribute will make the DI1 tag all services that implement this strategy
with the string app.invoice_item_percentage_calc. This string will be
referenced later in the context class.
<?php
namespace App\Service\Invoice\PercentageCalculation;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
readonly class PercentageCalculator
{
/**
* @param iterable<int,PercentageCalcInterface> $strategies
*/
public function __construct(
#[AutowireIterator('app.invoice_item_percentage_calc')]
private iterable $strategies
) {}
public function handle(PercentageCalcRequest $req): int
{
foreach ($this->strategies as $strategy) {
if ($strategy->matches($req)) {
return $strategy->handle($req);
}
};
// Fallback value
return 0;
}
}
And finally, the context class implementation. Remember, you don’t have to call
it Context or anything similar, the client code 2 doesn’t need to know that
you’re implementing the Strategy pattern.
The context class receives an array of strategies in its constructor thanks to
the AutowireIterator attribute. When the context is called, it searches across
all strategies until it finds one that matches, and then it returns its result.
When no strategy matches, a default value is returned. You can throw an
exception or return a value by hand.
Now that we have all classes at hand, we can create the strategies to calculate the percentage value.
<?php
namespace App\Service\Invoice\PercentageCalculation\Strategies;
use App\Service\Invoice\PercentageCalculation\PercentageCalcInterface;
use App\Service\Invoice\PercentageCalculation\PercentageCalcRequest;
class AmountPaidIsZero implements PercentageCalcInterface
{
public function matches(PercentageCalcRequest $req): bool
{
return $req->paidValue === 0;
}
public function handle(PercentageCalcRequest $_): int
{
return 0;
}
}
<?php
namespace App\Service\Invoice\PercentageCalculation\Strategies;
use App\Service\Invoice\PercentageCalculation\PercentageCalcInterface;
use App\Service\Invoice\PercentageCalculation\PercentageCalcRequest;
class TotalAmountIsZero implements PercentageCalcInterface
{
public function matches(PercentageCalcRequest $req): bool
{
return $req->totalAmount === 0;
}
public function handle(PercentageCalcRequest $req): int
{
return 0;
}
}
<?php
namespace App\Service\Invoice\PercentageCalculation\Strategies;
use App\Service\Invoice\PercentageCalculation\PercentageCalcInterface;
use App\Service\Invoice\PercentageCalculation\PercentageCalcRequest;
class ProportionalPayment implements PercentageCalcInterface
{
public function matches(PercentageCalcRequest $req): bool
{
return
$req->invoiceItemCents > 0
&& $req->paidValue > 0
&& $req->totalAmount > 0;
}
public function handle(PercentageCalcRequest $req): int
{
$proportionalPaid = ($req->invoiceItemCents / $req->totalAmount) * $req->paidValue;
return (int) (min($proportionalPaid / $req->invoiceItemCents, 1) * 100);
}
}
Done! All strategies are ready to be used, and they’re already injected by Symfony DI. No more configuration is needed. When the client code calls your context, you’ll get the result based on the chosen strategy.
Client code example:
<?php
public function getPercentagePaid(): int
{
return $this->percentageCalculator->handle(
new PercentageCalcRequest(
$this->invoiceListing->paidValue,
$this->invoiceListing->getTotalAmount(),
(int) $this->cents,
)
);
}
Unit test example:
<?php
namespace Tests\Unit\Service\Invoice\PercentageCalculation\Strategies;
use App\Service\Invoice\PercentageCalculation\PercentageCalcRequest;
use App\Service\Invoice\PercentageCalculation\Strategies\ProportionalPayment;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\TestCase;
class ProportionalPaymentTest extends TestCase
{
#[TestWith([1000, 500, 2000, true])]
#[TestWith([1, 1, 1, true])]
#[TestWith([100, 50, 200, true])]
#[TestWith([0, 500, 2000, false])]
#[TestWith([-100, 500, 2000, false])]
#[TestWith([1000, 0, 2000, false])]
#[TestWith([1000, -500, 2000, false])]
#[TestWith([1000, 500, 0, false])]
#[TestWith([1000, 500, -2000, false])]
#[TestWith([0, 0, 0, false])]
#[TestWith([-100, -500, -2000, false])]
public function testMatches(int $invoiceItemCents, int $paidValue, int $totalAmount, bool $expected): void
{
$strategy = new ProportionalPayment();
$request = new PercentageCalcRequest(
$paidValue,
$totalAmount,
$invoiceItemCents
);
$result = $strategy->matches($request);
$this->assertEquals($expected, $result);
}
}
Example 2: API Platform operations
If you use API-Platform you can utilize the Strategy pattern to create a state provider that is compatible with multiple operations.
For example, let’s say you have a paginator and a simple GET request for a certain DTO. Instead of creating a state provider for each operation, you can make a single provider for all operations that are related to this DTO. The goal is to centralize everything in a single provider and then implement every single operation as a strategy.
Let’s start with the context class:
<?php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Operation\InvoiceListing\InvoiceListingOperationInterface;
use App\Operation\InvoiceListing\InvoiceListingOperationParams;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
use App\ApiResource\InvoiceListing\InvoiceListing;
/**
* @implements ProviderInterface<InvoiceListing>
*/
final readonly class InvoiceListingProvider implements ProviderInterface
{
/**
* @param iterable<int,InvoiceListingOperationInterface> $operations
*/
public function __construct(
#[AutowireIterator('app.invoice_listing_operation')]
private iterable $operations
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
$context['filters']['page'] ??= 1;
$context['filters']['itemsPerPage'] ??= 0;
$uriVariables['id'] ??= 0;
foreach ($this->operations as $op) {
if ($op->matches($operation)) {
return $op->handle(
new InvoiceListingOperationParams(
(int) $context['filters']['page'],
(int) $context['filters']['itemsPerPage'],
(int) $uriVariables['id']
)
);
}
}
return null;
}
}
And then, a class that will hold all the necessary data for the Get and
GetCollection operations.
<?php
namespace App\Operation\InvoiceListing;
readonly class InvoiceListingOperationParams
{
public function __construct(
public int $page,
public int $itemsPerPage,
public int $id,
) {}
}
Lastly, the interface and a strategy example:
<?php
namespace App\Operation\InvoiceListing;
use ApiPlatform\State\Pagination\PaginatorInterface;
use App\ApiResource\InvoiceListing\InvoiceListing;
use App\ApiResource\InvoiceListing\InvoiceListingCollection;
use ApiPlatform\Metadata\Operation;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag('app.invoice_listing_operation')]
interface InvoiceListingOperationInterface
{
public function matches(Operation $operation): bool;
/**
* @return PaginatorInterface<InvoiceListing>
*/
public function handle(InvoiceListingOperationParams $params): null|InvoiceListingCollection|InvoiceListing|PaginatorInterface;
}
<?php
declare(strict_types=1);
namespace App\Operation\InvoiceListing\Impl;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\State\Pagination\PaginatorInterface;
use App\ApiResource\InvoiceListing\InvoiceListing;
use App\ApiResource\InvoiceListing\InvoiceListingCollectionPaginated;
use App\Operation\InvoiceListing\InvoiceListingOperationInterface;
use App\ApiResource\InvoiceListing\InvoiceListingCollection;
use App\Operation\InvoiceListing\InvoiceListingOperationParams;
use App\Repository\InvoiceRepository;
readonly class GetCollectionImpl implements InvoiceListingOperationInterface
{
public function __construct(
private InvoiceRepository $invoiceRepository
) {}
public function matches(Operation $operation): bool
{
return $operation instanceof GetCollection;
}
public function handle(InvoiceListingOperationParams $params): null|PaginatorInterface|InvoiceListingCollection|InvoiceListing
{
return new InvoiceListingCollectionPaginated(
$this->invoiceRepository->getInvoiceListingCollection(
$params->page,
$params->itemsPerPage
),
$params->page,
$params->itemsPerPage,
$this->invoiceRepository->count()
);
}
}
Please note, instead of creating multiple conditions (each one for a single operation), we’re simply creating a group of strategies where each strategy is tied to an API Platform operation.
Example 3: Doctrine intelligent search
Let’s say you are creating the logic behind a search page in your application, and this search utilizes many fields in your database to compare against the user input. If you don’t follow the Strategy pattern, you might end up doing a search query like this:
<?php
/** @var \Doctrine\ORM\QueryBuilder $qb */
$qb = $this->createQueryBuilder('person');
$qb
->where('person.name like :searchTerm')
->orWhere('person.email = :searchTerm')
->orWhere('person.cpf = :searchTerm')
->setParameter(':searchTerm', $searchTerm);
In order to make your query faster and smarter, you can create many strategies that try to identify the type of input and apply the corresponding database filter.
<?php
namespace App\Operation\PersonSearch\Impl;
use App\Operation\PersonSearch\SearchStrategyInterface;
use Doctrine\ORM\QueryBuilder;
use function filter_var;
class SearchByEmail implements SearchStrategyInterface
{
public function matches(string $searchTerm): bool
{
return false !== filter_var($searchTerm, FILTER_VALIDATE_EMAIL);
}
public function apply(QueryBuilder $builder, string $searchTerm): void
{
$builder
->andWhere('person.email = :searchTerm')
->setParameter(':searchTerm', $searchTerm);
}
}
<?php
namespace App\Operation\PersonSearch\Impl;
use App\Operation\PersonSearch\SearchStrategyInterface;
use Doctrine\ORM\QueryBuilder;
use function preg_match;
class SearchByUnmaskedCpf implements SearchStrategyInterface
{
public function matches(string $searchTerm): bool
{
return preg_match('/^\d{11}$/', $searchTerm) === 1;
}
public function apply(QueryBuilder $builder, string $searchTerm): void
{
$builder
->andWhere('person.cpf = :searchTerm')
->setParameter(':searchTerm', $searchTerm);
}
}
<?php
namespace App\Operation\PersonSearch\Impl;
use App\Operation\PersonSearch\SearchStrategyInterface;
use Doctrine\ORM\QueryBuilder;
use function preg_match;
use function Utils\Cpf\unmaskCpf;
class SearchByMaskedCpf implements SearchStrategyInterface
{
public function matches(string $searchTerm): bool
{
return preg_match('/^\d{3}\.\d{3}\.\d{3}-\d{2}$/', $searchTerm) === 1;
}
public function apply(QueryBuilder $builder, string $searchTerm): void
{
$builder
->andWhere('person.cpf = :searchTerm')
->setParameter(':searchTerm', unmaskCpf($searchTerm));
}
}
If you’re not from Brazil, you might not know what a CPF is. A CPF is an eleven-digit unique identifier used to identify Brazilian citizens.
<?php
namespace App\Operation\PersonSearch\Impl;
use App\Operation\PersonSearch\SearchStrategyInterface;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;
#[AsTaggedItem(priority: -1)]
class SearchByName implements SearchStrategyInterface
{
public function matches(string $searchTerm): bool
{
return true;
}
public function apply(QueryBuilder $builder, string $searchTerm): void
{
$builder
->andWhere('person.name like :searchTerm')
->setParameter(':searchTerm', $searchTerm);
}
}
Since I’ve worked with massive databases before, this kind of optimization becomes necessary. You can also control the order in which strategies are applied by using Symfony’s dependency injection attributes (AsTaggedItem). It tells the DI container that the strategy should be placed at the end of the list, which helps prevent conflicts between strategies.
Final thoughts
Now you know how to implement the Strategy pattern in Symfony 7. The context class doesn’t always need to return something — sometimes, you can make a group of validations that don’t return anything and throw exceptions.
It’s possible to combine the Strategy with other patterns (like Builder for example) in order to create simpler tests.
I know that at a first glance this pattern might seem like it’s overcomplicating something simple, but trust me when I say: in big projects, the more you separate responsibilities, the easier it is to maintain the code.