Good day to all! Surprisingly, the mention of the "Specification" pattern in the php context is extremely rare. But with its help you can not only avoid the combinatorial explosion of repository methods , but also improve code reuse . I, in turn, would like to dwell on one more opportunity provided by this pattern. It can help solve a problem that occurs in almost every web application. And personally, I really missed this knowledge a couple of years ago.
What do we do
Let's assume we are developing a task tracker. The main page will display a list of tasks. We also need to view a separate task.
<?php declare(strict_types=1); namespace App\Controller; use App\Entity\Task; use App\Repository\TaskRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; #[Route('/task')] final class TaskController extends AbstractController { #[Route('/', name: 'task_index', methods: ['GET'])] public function index(TaskRepository $taskRepository): Response { return $this->render('task/index.html.twig', [ 'tasks' => $taskRepository->findAll(), ]); } #[Route('/{id}', name: 'task_show', methods: ['GET'])] public function show(Task $task): Response { return $this->render('task/show.html.twig', [ 'task' => $task, ]); } }
Further, suppose we have 3 types of users:
- Admin - can work with all tasks.
- Manager - can only work with the tasks of his project.
- Developer - can work only with tasks assigned to him.
Therefore, it is necessary to create a system of rights so that each type of user has access only to the tasks intended for him. It will look something like this:
namespace App\Controller; use App\Entity\Task; +use App\Entity\User; use App\Repository\TaskRepository; +use App\Security\CurrentUserProvider; +use Doctrine\ORM\QueryBuilder; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; #[Route('/task')] final class TaskController extends AbstractController { + public function __construct(private AuthorizationCheckerInterface $authorizationChecker, private CurrentUserProvider $currentUserProvider) + { + } + #[Route('/', name: 'task_index', methods: ['GET'])] public function index(TaskRepository $taskRepository): Response { + $queryBuilder = $taskRepository->createQueryBuilder('t'); + $this->filter($queryBuilder); + return $this->render('task/index.html.twig', [ - 'tasks' => $taskRepository->findAll(), + 'tasks' => $queryBuilder->getQuery() + ->getResult(), ]); } + private function filter(QueryBuilder $queryBuilder): void + { + if ($this->authorizationChecker->isGranted(User::ROLE_ADMIN)) { + return; + } + + $user = $this->currentUserProvider->getUser(); + + if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) { + $queryBuilder->andWhere('t.project in(:projects)') + ->setParameter('projects', $user->getProjects()); + + return; + } + + $queryBuilder->andWhere('t.performedBy = :performedBy') + ->setParameter('performedBy', $user); + } + #[Route('/{id}', name: 'task_show', methods: ['GET'])] public function show(Task $task): Response { + if (!$this->isViewable($task)) { + throw new AccessDeniedHttpException(); + } + return $this->render('task/show.html.twig', [ 'task' => $task, ]); } + + private function isViewable(Task $task): bool + { + if ($this->authorizationChecker->isGranted(User::ROLE_ADMIN)) { + return true; + } + + $user = $this->currentUserProvider->getUser(); + + if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) { + return $user->getProjects() + ->contains($task->getProject()); + } + + return $task->getPerformedBy() === $user; + } }
Of course, writing a lot of code in the controller is not good. One way or another, you can scatter it across services, use the standard symfony voters. But the main problem with this code is that our business rules are completely repeated in both the filter method and the isViewable method. And the correction of this fact no longer looks so obvious. What can you do about it? We need a business rule abstraction that works both for a list of items and for a single entity. This is what the Specification template provides.
Writing a Specification
2 , php. Happyr/Doctrine-Specification K-Phoen/rulerz. , symfony 5 . , , .
, . . , , , .
<?php declare(strict_types=1); namespace App\Specification; use Doctrine\ORM\QueryBuilder; use Symfony\Component\PropertyAccess\PropertyAccess; abstract class Specification { abstract public function isSatisfiedBy(object $entity): bool; abstract public function generateDql(string $alias): ?string; abstract public function getParameters(): array; public function modifyQuery(QueryBuilder $queryBuilder): void { } public function filter(QueryBuilder $queryBuilder): void { $this->modifyQuery($queryBuilder); $alias = $queryBuilder->getRootAliases()[0]; $dql = $this->generateDql($alias); if (null === $dql) { return; } $queryBuilder->where($dql); foreach ($this->getParameters() as $field => $value) { $queryBuilder->setParameter($field, $value); } } protected function getFieldValue(object $entity, string $field): mixed { return PropertyAccess::createPropertyAccessorBuilder() ->enableExceptionOnInvalidIndex() ->getPropertyAccessor() ->getValue($entity, $field); } }
. filter query builder. getFieldValue
.
, -, . CompositeSpecification.
<?php declare(strict_types=1); namespace App\Specification; use Doctrine\ORM\QueryBuilder; abstract class CompositeSpecification extends Specification { abstract public function getSpecification(): Specification; public function isSatisfiedBy(object $entity): bool { return $this->getSpecification() ->isSatisfiedBy($entity); } public function generateDql(string $alias): ?string { return $this->getSpecification() ->generateDql($alias); } public function getParameters(): array { return $this->getSpecification() ->getParameters(); } public function modifyQuery(QueryBuilder $queryBuilder): void { $this->getSpecification() ->modifyQuery($queryBuilder); } }
, .
<?php declare(strict_types=1); namespace App\Specification; final class AlwaysSpecified extends Specification { public function isSatisfiedBy(object $entity): bool { return true; } public function generateDql(string $alias): ?string { return null; } public function getParameters(): array { return []; } }
<?php declare(strict_types=1); namespace App\Specification; final class Equals extends Specification { public function __construct(private string $field, private mixed $value) { } public function isSatisfiedBy(object $entity): bool { return $this->value === $this->getFieldValue($entity, $this->field); } public function generateDql(string $alias): ?string { return sprintf('%s.%s = :%2$s', $alias, $this->field); } public function getParameters(): array { return [ $this->field => $this->value, ]; } }
<?php declare(strict_types=1); namespace App\Specification; final class MemberOf extends Specification { public function __construct(private string $field, private object $value) { } public function isSatisfiedBy(object $entity): bool { return $this->getFieldValue($entity, $this->field) ->contains($this->value); } public function generateDql(string $alias): ?string { return sprintf(':%2$s member of %1$s.%2$s', $alias, $this->field); } public function getParameters(): array { return [ $this->field => $this->value, ]; } }
<?php declare(strict_types=1); namespace App\Specification; final class Not extends Specification { public function __construct(private Specification $specification) { } public function isSatisfiedBy(object $entity): bool { return !$this->specification ->isSatisfiedBy($entity); } public function generateDql(string $alias): ?string { return sprintf( 'not (%s)', $this->specification->generateDql($alias) ); } public function getParameters(): array { return $this->specification ->getParameters(); } }
. . .
<?php declare(strict_types=1); namespace App\Specification; use Doctrine\ORM\QueryBuilder; final class Join extends Specification { public function __construct(private string $rootAlias, private string $field, private Specification $specification) { } public function isSatisfiedBy(object $entity): bool { return $this->specification ->isSatisfiedBy($this->getFieldValue($entity, $this->field)); } public function generateDql(string $alias): ?string { return $this->specification ->generateDql($this->field); } public function getParameters(): array { return $this->specification ->getParameters(); } public function modifyQuery(QueryBuilder $queryBuilder): void { $queryBuilder->join(sprintf('%s.%s', $this->rootAlias, $this->field), $this->field); $this->specification ->modifyQuery($queryBuilder); } }
-
, , - . .
<?php declare(strict_types=1); namespace App\Specification\Task; use App\Entity\User; use App\Security\CurrentUserProvider; use App\Specification\AlwaysSpecified; use App\Specification\CompositeSpecification; use App\Specification\Equals; use App\Specification\Join; use App\Specification\MemberOf; use App\Specification\Specification; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; final class IsViewable extends CompositeSpecification { public function __construct(private AuthorizationCheckerInterface $authorizationChecker, private CurrentUserProvider $currentUserProvider) { } public function getSpecification(): Specification { if ($this->authorizationChecker->isGranted(User::ROLE_ADMIN)) { return new AlwaysSpecified(); } $user = $this->currentUserProvider->getUser(); if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) { $isProjectMember = new MemberOf('members', $user); return new Join('task', 'project', $isProjectMember); } return new Equals('performedBy', $user); } }
.
namespace App\Controller; use App\Entity\Task; -use App\Entity\User; use App\Repository\TaskRepository; -use App\Security\CurrentUserProvider; -use Doctrine\ORM\QueryBuilder; +use App\Specification\Task\IsViewable; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Routing\Annotation\Route; -use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; #[Route('/task')] final class TaskController extends AbstractController { - public function __construct(private AuthorizationCheckerInterface $authorizationChecker, private CurrentUserProvider $currentUserProvider) + public function __construct(private IsViewable $isViewable) { } @@ -26,7 +23,7 @@ final class TaskController extends AbstractController public function index(TaskRepository $taskRepository): Response { $queryBuilder = $taskRepository->createQueryBuilder('t'); - $this->filter($queryBuilder); + $this->isViewable->filter($queryBuilder); return $this->render('task/index.html.twig', [ 'tasks' => $queryBuilder->getQuery() @@ -34,29 +31,10 @@ final class TaskController extends AbstractController ]); } - private function filter(QueryBuilder $queryBuilder): void - { - if ($this->authorizationChecker->isGranted(User::ROLE_ADMIN)) { - return; - } - - $user = $this->currentUserProvider->getUser(); - - if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) { - $queryBuilder->andWhere('t.project in(:projects)') - ->setParameter('projects', $user->getProjects()); - - return; - } - - $queryBuilder->andWhere('t.performedBy = :performedBy') - ->setParameter('performedBy', $user); - } - #[Route('/{id}', name: 'task_show', methods: ['GET'])] public function show(Task $task): Response { - if (!$this->isViewable($task)) { + if (!$this->isViewable->isSatisfiedBy($task)) { throw new AccessDeniedHttpException(); } @@ -64,20 +42,4 @@ final class TaskController extends AbstractController 'task' => $task, ]); } - - private function isViewable(Task $task): bool - { - if ($this->authorizationChecker->isGranted(User::ROLE_ADMIN)) { - return true; - } - - $user = $this->currentUserProvider->getUser(); - - if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) { - return $user->getProjects() - ->contains($task->getProject()); - } - - return $task->getPerformedBy() === $user; - } }
! . ?
, , "archived".
use App\Entity\User; use App\Security\CurrentUserProvider; use App\Specification\AlwaysSpecified; +use App\Specification\AndX; use App\Specification\CompositeSpecification; use App\Specification\Equals; use App\Specification\Join; use App\Specification\MemberOf; +use App\Specification\Not; +use App\Specification\Project\IsArchived; use App\Specification\Specification; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; @@ -26,14 +29,23 @@ final class IsViewable extends CompositeSpecification return new AlwaysSpecified(); } + $isNotArchived = new Not(new IsArchived()); $user = $this->currentUserProvider->getUser(); if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) { $isProjectMember = new MemberOf('members', $user); - return new Join('task', 'project', $isProjectMember); + return $this->getProjectSpecification(new AndX($isNotArchived, $isProjectMember)); } - return new Equals('performedBy', $user); + return new AndX( + new Equals('performedBy', $user), + $this->getProjectSpecification($isNotArchived) + ); + } + + private function getProjectSpecification(Specification $specification): Join + { + return new Join('task', 'project', $specification); } }
. , . . . . . — - , . . , - .
, ? php? , ?
A complete example from the article can be found on github .