Doctrine best practices

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);
}
}
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);
}
}
- more expressive
- easier to test
- less coupling
- more flexible
- easier to refactor
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.
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 .
Prefer sorting by datetime
instead of id
.
- 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();
}
}
Use as less relationships as possible.
- 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.
Avoid composite keys in order to make work of Unit of Work easier.
Reduce usage of life-cycle events as much as possible.
- improve performance
- avoid unexpected data transformation
- lifecycle events are ORM-specific (Application layer) and shouldn't modify domain models lifecycle
Try to avoid cascaded actions.
- improve performance
- take more control on application flow
Avoid usage of reserved words.
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();
}
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;
}
Avoid auto-generated identifiers.
- 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();
}
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;
}
}
Avoid composite and derived keys.
- make work of Unit of Work easier
- it makes no sense to the domain
- use unique constraint instead
Avoid soft-deletes.
- prevent from using unique constraints
- makes queries more complex
- breaks data integrity
- should be replaced with more specific domain concept
- Map entity to repository via
repositoryClass
property ofEntity
attribute. - Get repository from
ObjectManager
viagetRepository
ServiceLocator method. - Extend
ServiceEntityRepository
to use repository as a service.
Use repositories as services.
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
{
...
}
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;
}
}