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 PHPUnit without mocking dependencies.

Strategy pattern’s execution
flow

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:

  1. A context class
  2. A single class for each strategy
  3. 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:

  1. Business rule
  2. API Platform operations
  3. 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.

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.


  1. dependency injection ↩︎

  2. “Client code” is the code responsible for using the implementation that you are currently creating. Even if the entire codebase is in the same repository, there are pieces of code that work as a base for others to consume. ↩︎