Symfony: como aplicar o padrão strategy
Quando uma aplicação precisa se comportar de maneiras diferentes os programadores têm o costume de colocar várias condicionais e um retorno para cada uma delas.
<?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;
}
Essa forma de programar é problemática porque dificulta a criação de testes e a complexidade da função aumenta conforme novas regras são criadas. Essa prática pode ser encontrada em diversos cenários e também pode ser feito com o uso do switch ou o match do 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,
};
}
O padrão Strategy veio para resolver esse problema, com ele você pode colocar inúmeras condições sem aumentar a complexidade do código e testar cada cenário individualmente. O melhor de tudo é que o código fica elegante e mais profissional na hora da entrega.
Para aqueles que não sabem, o Strategy pattern é um padrão de código onde o sistema pode fazer a mesma coisa de várias maneiras diferentes, você pode criar inúmeras estratégias sem aumentar a complexidade do código e o melhor de tudo é que cada estratégia pode ser testada individualmente com o
PHPUnitsem a necessidade de mock de dependências.

Portanto, se o sistema se comporta de várias maneiras e possui o mesmo tipo de retorno, considere usar o padrão de estratégias ao invés de várias condicionais. No Symfony 7 você consegue criar um código que segue esse padrão sem esforço nenhum e com mínima configuração possível.
Como aplicar
O padrão é composto por:
- Uma classe de contexto
- Uma classe por estratégia
- Classes auxiliares
A classe de contexto vai varrer todas as estratégias até encontrar alguma que
bate com o cenário atual e depois retornar o resultado da estratégia. A
estratégia implementa a condicional e o retorno em si (substituindo as
declarações if e os múltiplos retornos), e as classes auxiliares são classes
que representam estruturas de dados comuns que podem ser utilizados no contexto
atual.
Neste artigo, você vai encontrar três exemplos reais.
- Regra de negócio
- Operações da API Platform
- Busca inteligente no Doctrine
Exemplo 1: Regra de negócio
Seguindo o primeiro exemplo do início do artigo, nós vamos mapear todos os dados que vão ser utilizados no contexto com a seguinte classe.
<?php
namespace App\Service\Invoice\PercentageCalculation;
readonly class PercentageCalcRequest
{
public function __construct(
public int $paidValue,
public int $totalAmount,
public int $invoiceItemCents,
) {}
}
Agora vamos criar uma interface para dizer como a estratégia deve se comportar, a função matches() substitui o if e a função handle() substitui o retorno.
<?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;
}
Perceba que coloquei um atributo do Symfony em cima da interface, esse atributo
vai fazer com que o injetor de dependências classifique todos os serviços que
implementam essa estratégia com o texto app.invoice_item_percentage_calc. Esse
texto será referenciado mais tarde na classe de contexto.
<?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;
}
}
Por fim, a implementação da classe de contexto. Lembrando que você não precisa botar o nome Contexto na classe. O código cliente1 não precisa saber que você está implementando o Strategy pattern (veja o exemplo 2).
A classe de contexto recebe no construtor todas as estratégias graças ao
atributo AutowireIterator. O contexto, quando chamado, percorre todas as
estratégias e retorna o resultado da primeira que retornar verdadeiro. Quando
nenhuma estratégia bate, um valor padrão é retornado. Você pode lançar uma
exceção ou retornar um valor a mão.
Agora que temos as classes básicas criadas, podemos prosseguir com a criação das estratégias para calcular o valor da porcentagem.
<?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);
}
}
Pronto! Todas as estratégias estão prontas e agora serão injetadas automaticamente pelo injetor de dependências, nenhuma configuração a mais é necessária. Quando o código cliente chamar a classe de contexto, você vai obter o resultado conforme a estratégia interna selecionada.
Exemplo de código cliente:
<?php
public function getPercentagePaid(): int
{
return $this->percentageCalculator->handle(
new PercentageCalcRequest(
$this->invoiceListing->paidValue,
$this->invoiceListing->getTotalAmount(),
(int) $this->cents,
)
);
}
Exemplo de teste unitário:
<?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);
}
}
Exemplo 2: Operações da API Platform
Se você usa API-Platform você pode utilizar o padrão Strategy para implementar um provedor de estado compatível com várias operações.
Por exemplo, imagine que você vai implementar um paginador e uma função para carregar um determinado DTO. Ao invés de criar um provedor para cada operação, você pode criar um provedor para todas as operações envolvendo esse mesmo DTO. O objetivo é centralizar todas as operações em um único provedor e transformar cada operação em uma estratégia seguindo o Strategy pattern.
Comece criando a classe de contexto:
<?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;
}
}
Em seguida uma classe que vai guardar as informações necessárias para as
operações Get e GetCollection.
<?php
namespace App\Operation\InvoiceListing;
readonly class InvoiceListingOperationParams
{
public function __construct(
public int $page,
public int $itemsPerPage,
public int $id,
) {}
}
E por fim a interface e a implementação de uma das estratégias:
<?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()
);
}
}
Perceba, ao invés de nós criarmos uma condicional para cada operação, nós simplesmente criamos um grupo de estratégias onde cada estratégia corresponde a uma operação do API Platform.
Exemplo 3: Doctrine e busca inteligente
Vamos supor que você está criando a lógica por trás da pesquisa que um usuário está fazendo na sua aplicação, essa busca pode contemplar diversos campos da sua base de dados e se você não segue o padrão de código Strategy, você pode muito bem acabar fazendo uma consulta deste tipo:
<?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);
A fim de deixar a sua busca mais rápida e inteligente, você pode criar estratégias para identificar o que o usuário está buscando e aplicar o filtro correspondente.
<?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
->orWhere('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
->orWhere('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
->orWhere('person.cpf = :searchTerm')
->setParameter(':searchTerm', unmaskCpf($searchTerm));
}
}
<?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
->orWhere('person.name like :searchTerm')
->setParameter(':searchTerm', $searchTerm);
}
}
Como já trabalhei em bancos de dados gigantescos, esse tipo de otimização se torna necessária. Você também pode escolher a ordem na qual as estratégias são colocadas usando atributos da injeção de dependência do Symfony (AsTaggedItem). Ele diz ao DI2 que a estratégia deve ser a última da lista e isso ajuda a evitar conflitos entre uma estratégia e outra.
Considerações finais
Agora você sabe como implementar o padrão Strategy no Symfony 7. Nem sempre a classe de contexto precisa retornar algo — às vezes, você pode apenas criar um conjunto de validações que não retornam nada e jogam exceções.
É possível combinar o Strategy com outros padrões, como o Builder, e criar testes muito mais simples e isolados.
Sei que, à primeira vista, o padrão pode parecer uma complicação para algo simples. Mas acredite: em projetos de grande porte, quanto mais você separar responsabilidades, mais fácil será manter e evoluir seu código.