stand with ukraine
mandarinian.io
search

Doctrine best practices

Entities

Domain

Entities should represent domain.

Avoid setters as much as possible. because usually they do not represent domain logic.

Entities are not typed arrays, they should have behavior. Entities without behavior called anemic models, what is anti-pattern.

          // Bad class
          class User {
            private string $username;
            private string $passwordHash;

            public function getUsername(): string
            {
              return $this->username;
            }

            public function setUsername(string $username): void
            {
              $this->username = $username;
            }

            public function getPasswordHash(): string
            {
              return $this->passwordHash;
            }

            public function setPasswordHash(string $passwordHash): void
            {
              $this->passwordHash = $passwordHash;
            }
          }

          // Good class
          class User {
            private string $username;
            private string $passwordHash;

            public function __construct(string $username, string $passwordHash)
            {
              $this->username = $username;
              $this->passwordHash = $passwordHash;
            }

            public function toNickname(): string
            {
              return $this->username;
            }

            public function changePass(string $pass, callable $hash): void
            {
              $this->passwordHash = $hash($pass);
            }
          }
Law of Demeter

Despite entities should have behavior, they should respect The Law of Demeter (LoD).

          class User
          {
            // Bad code
            public function hasAccessTo(Resource $resource): bool
            {
              return (bool) array_filter(
                $this->role->getAccessLevels(),
                function (AccessLevel $acl) use ($resource): bool {
                  return $acl->canAccess($resource)
                }
              );
            }
          }

          class User
          {
            // Good code
            public function hasAccessTo(Resource $resource): bool
            {
              return $this->role->allowsAccessTo($resource);
            }
          }
Reasons:
  • more expressive
  • easier to test
  • less coupling
  • more flexible
  • easier to refactor
Validity

Entities should always be valid from construction.

            // Bad class
            class User {
              private string|null $username = null;
              private string|null $passwordHash = null;

              public function setUsername(string $username): void
              {
                $this->username = $username;
              }

              public function setPasswordHash(string $passwordHash): void
              {
                $this->passwordHash = $passwordHash;
              }
            }

            // Good class
            class User {
              private string $username;
              private string $passwordHash;

              public function __construct(string $username, string $passwordHash)
              {
                $this->username = $username;
                $this->passwordHash = $passwordHash;
              }
            }

If temporary state needed, DTO can be used.

Application layer

Avoid application layer in entities, because it leads to coupling.

Also it disallows from keeping entity valid.

            class UserController
            {
              // form reads from/writes to user entity (bad)
              public function registerAction(): void
              {
                $this->userForm->bind(new User());
              }
            }

            class UserController
            {
              // coupling between form and user (bad)
              public function registerAction(): void
              {
                $this->em->persist(User::fromFormData($this->form));
              }
            }

            class UserController
            {
              // good code
              public function registerAction(): void
              {
                $userDto = new UserDto();
                $this->userForm->bind($userDto);

                $this->em->persist(new User($userDto->email, $userDto->password));
              }
            }

Usage of Symfony Forms in entities is a bad practice, because it leads to coupling between domain and application. More information about Symfony Forms .

Sorting

Prefer sorting by datetime instead of id.

Reasons:
  • represents business logic
  • more stable
  • more predictable
  • more flexible
            class User
            {
              #[ORM\Column]
              private Uuid $id;

              #[ORM\Column]
              private DateTime $createdAt;
            }

            class UserRepository
            {
              public function findAll(): array
              {
                return $this->createQueryBuilder('u')
                  ->orderBy('u.createdAt', Criteria::DESC)
                  ->getQuery()
                  ->getResult();
              }
            }

Relationships

Use as less relationships as possible.

Reasons:
  • make work of Unit of Work easier
  • decouple domain models
  • keep code more stable

Do not map foreign keys, they have no domain meaning, and everything is done automatically by Doctrine.

Composite keys

Avoid composite keys in order to make work of Unit of Work easier.

Events

Reduce usage of life-cycle events as much as possible.

Reasons:
  • improve performance
  • avoid unexpected data transformation
  • lifecycle events are ORM-specific (Application layer) and shouldn't modify domain models lifecycle

Cascades

Try to avoid cascaded actions.

Reasons:
  • improve performance
  • take more control on application flow

Reserved words

Avoid usage of reserved words.

Collections

Initialize collections in constructor.

          class User
          {
            private Collection $addresses;
            private Collection $articles;

            public function __construct() {
              $this->addresses = new ArrayCollection();
              $this->articles = new ArrayCollection();
            }
          }

Disallow collection access from outside the entity.

          // Bad code
          class User
          {
            private Collection $sessions;

            ...

            public function getSessions(): Collection
            {
              return $this->sessions;
            }
          }

          public function addSession(Uuid $userId): void
          {
            $user = $this->repository->find($userId);

            $user->getSessions()->add(new Session($user));
          }

          // Good code
          class User
          {
            private Collection $sessions;

            ...

            public function addSession(): Session
            {
              $session = new Session($this);

              $this->sessions->add($session);

              return $session;
            }
          }

          public function addSession(Uuid $userId): void
          {
            $user = $this->repository->find($userId);

            $user->addSession();
          }

Transactions

Always take control of transactions explicitly, because Doctrine will start new transaction on each request to the database.

          // $em instanceof EntityManager
          $em->getConnection()->beginTransaction(); // suspend auto-commit
          try {
            //... do some work
            $user = new User();
            $user->setName('George');
            $em->persist($user);
            $em->flush();
            $em->getConnection()->commit();
          } catch (Exception $e) {
            $em->getConnection()->rollBack();
            throw $e;
          }

Identifiers

Auto-generated identifiers

Avoid auto-generated identifiers.

Reasons:
  • blocking db operations to receive/set identifier
  • lead to concurrency issues
  • denying bulk operations
  • disallow multi-request transactions
  • entities are invalid unless saved
  • coupling between model and database (infrastructure/application)

Solution:
Use UUID for that kind of stuff. For example: ramsey/uuid

          public function __construct()
          {
            $this->id = Uuid::uuid4();
          }
Domain-specific identifiers

Use domain-specific primary keys if possible.

Artificial primary keys don’t make any sense for domain, so you should avoid them and use domain-specific unique identifiers for it if possible.

           class Citizen
           {
          @@ -4,6 +4,5 @@
          -  private Uuid $id;
          +  private string $passportNumber;

          -  public function __construct()
          +  public function __construct($passportNumber)
             {
          -    $this->id = Uuid::uuid4();
          +    $this->passportNumber = $passportNumber;
             }
           }
Composite and derived keys

Avoid composite and derived keys.

Reasons:
  • make work of Unit of Work easier
  • it makes no sense to the domain
  • use unique constraint instead

Soft delete

Avoid soft-deletes.

Reasons:
  • prevent from using unique constraints
  • makes queries more complex
  • breaks data integrity
  • should be replaced with more specific domain concept

Repositories

Service repositories
What does documentation say?
  • Map entity to repository via repositoryClass property of Entity attribute.
  • Get repository from ObjectManager via getRepository ServiceLocator method.
  • Extend ServiceEntityRepository to use repository as a service.

Use repositories as services.

Reasons:
  • ObjectManager::getRepository() is a ServiceLocator, what is a bad practice.
  • Must be coupled with Entity repositoryClass='MyCustomRepository'.
  • Cannot use type-hints for ready-to-use EntityRepository methods.
  • Cannot have multiple repositories for one Entity.
  • IDE won't show actual Repository class without PHPDoc type-hint.
  • Extension used instead of Composition.
             // Bad code
             $repository = $entityManager->getRepository(Post::class);

             // Ok code
             namespace App\Repository;

             use App\Entity\Post;
             use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository
             use Doctrine\Persistence\ManagerRegistry;

             /**
              * @extends ServiceEntityRepository<Post>
              */
             final class PostRepository extends ServiceEntityRepository
             {
               public function __construct(ManagerRegistry $registry)
               {
                 parent::__construct($registry, Post::class);
               }

               ...
             }

             // Good code
             namespace App\Repository;

             use App\Entity\Post;
             use Doctrine\ORM\EntityManagerInterface;
             use Doctrine\ORM\EntityRepository;

             final readonly class PostRepository
             {
               /**
                * @var EntityRepository<Post> $repository
                */
               private EntityRepository $repository;

               public function __construct(EntityManagerInterface $entityManager)
               {
                 $this->repository = $entityManager->getRepository(Post::class);
               }

               ...
             }

Composition approach allows to get rid of marking Entity with repositoryClass='MyCustomRepository'.

            -#[ORM\Entity(repositoryClass: PostRepository::class)]
            +#[ORM\Entity]
             class Post
             {
               ...
             }
Separate MyRepository::get() and MyRepository::find() methods

MyRepository::get() should throw exception if entity not found.

MyRepository::find() should return null if entity not found.

            final class BlogPostRepository
            {
              public function getBySlug($slug): BlogPost
              {
                $found = $this->findOneBy(['slug' => (string) $slug]);

                if (!$found) {
                  throw BlogPostNotFoundException::bySlug($slug);
                }

                return $found;
              }

              public function findBySlug($slug): ?BlogPost
              {
                $found = $this->findOneBy(['slug' => (string) $slug]);

                return $found;
              }
            }

Tags:#PHP