Take a look at this quintessential User entity class.
<?php
#[ORM\Entity(table: 'users')]class User{ #[ORM\Id, ORM\Column(type: "integer")] private int $id;
#[ORM\Column(type: "string")] private string $email;
#[ORM\Column(type: "string")] private string $password;
#[ORM\Column(type: "string")] private string $name;
#[ORM\Column(type: 'datetime_immutable')] private \DateTimeImmutable $createdAt;
public function getId(): int { return $this->id; }
public function getEmail(): string { return $this->email; }
public function setEmail(string $email): void { $this->email = $email; }
public function getPassword(): string { return $this->password; }
public function setPassword(string $password): void { $this->password = $password; }
public function getName(): string { return $this->name; }
public function setName(string $name): void { $this->name = $name; }}Does your User entity look like this? If so, you’re committing one of the deadliest sins in ORM design: you have an Anemic Domain Model.
This is one of the worst anti-patterns in software development. To explain why this is a bad idea, let me show you what
a class that uses this entity would look like:
<?php
readonly class UserService{ public function __construct( private UserRepository $users, ) {
public function register(string $email, string $password): void { $user = new User(); $user->setEmail($email); $user->setPassword($password); $user->setCreatedAt(new \DateTimeImmutable());
$this->users->add($user); }
public function changePassword(int $userId, string $currentPassword, string $newPassword): void { $user = $this->users->ofId($userId);
if ($currentPassword === '') { throw new \DomainException('Current password cannot be empty'); }
if ($newPassword === '') { throw new \DomainException('New password cannot be empty'); }
if (!\password_verify($currentPassword, $user->getPassword())) { throw new \DomainException('Current password does not match'); }
$hash = \password_hash($newPassword, \PASSWORD_DEFAULT); $user->setPassword($hash);
$this->users->add($user); }
public function resetPassword(int $userId, string $newPassword): void { $user = $this->users->ofId($userId);
$hash = \password_hash($newPassword, \PASSWORD_DEFAULT); $user->setPassword($hash);
$this->users->add($user); }}There are several problems with this example.
Notice how both methods duplicate the password hashing logic? This is a clear sign of trouble.
Duplication is dangerous in object-oriented design because of drift. When duplicated logic needs to change, you must
remember to change it everywhere. Miss one spot, and you end up with inconsistent behavior. What happens if we want to
switch to sodium_password hashing? We need to update both places. This example is trivial, but in a real codebase you
could have dozens of duplicates scattered across multiple files.
I’ve intentionally left a mistake here. Notice how changePassword enforces validation rules that resetPassword does not?
One checks for empty strings, the other doesn’t. Someone forgot to add these checks. This is widespread in real codebases.
Now this code is praying that some other layer validates the input.
Look at how entity creation happens in the service, not in the entity itself. The entity is just a data container with no
behavior. The service is responsible for creating the entity and setting its initial state. But what happens when you forget
to set a property that’s NOT NULL in the database? How do you know these rules? How can you ensure client code follows them?
There is also a massive security risk: Notice how the service can call getPassword in changePassword? This means any code
with access to the entity can accidentally leak the password. If you dump this into a serializer, you’re praying some other
layer configured serialization to ignore this method. Think about the real world. Who should know a user’s password if not
the user themselves? Why should our code be different?
I’ve seen code like this in countless projects, resulting in bloated controllers and huge, repetitive service layers. But why does this happen so often?
You might be thinking, “But this is how Symfony tutorials teach it. This is what Maker Bundle generates.” Exactly. And that’s the problem.
We’re not thinking about entities as part of a system. We’re thinking about them as data containers. As database tables. As JSON objects. We’re not thinking about them as part of a system that needs to be maintained and evolved over time.
‘Quick and Dirty’ is Just Dirty
We need to think about the behavior that belongs to entities and encapsulate it there. What invariants must be protected?
What transformations belong to the entity itself? Bottom line: we need to think, and that takes time. Many developers don’t
want to think about these things because they just want to do the job fast — by running bin/console make:entity or
generating getters and setters with PhpStorm and calling it a day. This is, in a way, a very real form of laziness.
Here’s the truth: there is no fast. If you want to build a maintainable system, you need to take the time to think about the design. You need to take the time to write tests. You need to take the time to write good code. You need to take the time to do it right, because contrary to popular belief, the first time is the only time.
Here are three questions you can ask yourself when designing entities that will help you think about them as part of a system, not just as data containers.
What Is The Minimum Viable Entity?
The first question is What’s the minimum amount of information I need to define in this entity in order for it to be valid? The answer will vary from domain to domain, but it’s a good starting point. It helps you define the core of the entity.
For our User entity, the minimum is an email and creation timestamp. Notice I’m making password nullable — we’ll handle
different creation scenarios with factory methods. Everything else is additional information that can be nullable.
What you want to do is enforce this at the code level. Those properties become part of your constructor to prevent any code from creating an entity with incomplete data. The minimum has to be there. Here’s what our user class looks like now:
17 collapsed lines
<?php
#[ORM\Entity(table: 'users')]class User{ #[ORM\Id, ORM\Column(type: "integer")] private int $id = 0; // This is set by the database, so we default to 0.
#[ORM\Column(type: "string")] private string $email; #[ORM\Column(type: "string")] private ?string $password = null; #[ORM\Column(type: "datetime_immutable")] private \DateTimeImmutable $createdAt; #[ORM\Column(type: "string", nullable: true)] private ?string $name = null;
public function __construct( string $email, \DateTimeImmutable $createdAt, ?string $password = null, ?string $name = null, ) { $this->email = $email; $this->password = $password; $this->createdAt = $createdAt; $this->name = $name; }36 collapsed lines
public function getId(): int { return $this->id; }
public function getEmail(): string { return $this->email; }
public function setEmail(string $email): void { $this->email = $email; }
public function getPassword(): string { return $this->password; }
public function setPassword(string $password): void { $this->password = $password; }
public function getName(): string { return $this->name; }
public function setName(string $name): void { $this->name = $name; }}The benefits are clear. No code can create a User without providing the minimum required information. The entity is
now in charge of protecting its own invariants at the time of creation.
However, sometimes you need to create entities with different data based on system needs. For instance, creating a user without a password is valid if we’re “inviting” the user into the system and they’ll set their password later.
For this case, we can create static factory methods in the entity itself. For instance, we could create a createInvited
method that takes only the email and creation date and sets the password to null. This way we keep the constructor for the
“main” way of creating the entity and add factory methods for special cases.
When there’s more than one way of creating an entity, I like to make the constructor private and give proper names to each
creation process. Making the constructor private forces all creation to go through these named methods, which makes the code
self-documenting: User::register() vs User::invite() tells you exactly what’s happening.
17 collapsed lines
<?php
#[ORM\Entity(table: 'users')]class User{ #[ORM\Id, ORM\Column(type: "integer")] private int $id = 0; // This is set by the database, so we default to 0.
#[ORM\Column(type: "string")] private string $email; #[ORM\Column(type: "string")] private ?string $password = null; #[ORM\Column(type: "datetime_immutable")] private \DateTimeImmutable $createdAt; #[ORM\Column(type: "string", nullable: true)] private ?string $name = null;
public static function register(string $email, \DateTimeImmutable $now, string $password): self { return new self($email, $now, $password); }
public static function invite(string $email, \DateTimeImmutable $now): self { return new self($email, $now); }
private function __construct( string $email, \DateTimeImmutable $createdAt, ?string $password = null, ?string $name = null, ) { $this->email = $email; $this->password = $password; $this->createdAt = $createdAt; $this->name = $name; }36 collapsed lines
public function getId(): int { return $this->id; }
public function getEmail(): string { return $this->email; }
public function setEmail(string $email): void { $this->email = $email; }
public function getPassword(): string { return $this->password; }
public function setPassword(string $password): void { $this->password = $password; }
public function getName(): string { return $this->name; }
public function setName(string $name): void { $this->name = $name; }}What Can Change And How?
The second question is What data can change in this entity and for what reasons? This is where you think about the behavior of your entity.
The worst thing you can do is let client code (code that has access to an instance of an entity) set whatever properties it wants. This is a recipe for disaster. Think about the behavior of your entity and encapsulate it within the entity itself. Think about the invariants that need to be protected and enforce them within the entity to make sure it never ends up in an invalid state.
What happens when you call setPassword with an empty string? You might say a developer will never do that, but all it
takes is a forgotten validator constraint in some form class. Also, what if someone passes the plain password instead of
the hash?
The User entity is not a data container. It’s a model of a user in our system. It needs to enforce the rules of what a
user is and what it can do. It needs to protect its invariants. It needs to be a mini-domain of its own.
The first thing we need to do is get rid of setPassword and introduce two new methods: changePassword and resetPassword.
These are two valid operations that can occur on a user. Then, we’ll enforce the invariants inside these methods while
keeping things as DRY as possible.
63 collapsed lines
<?php
#[ORM\Entity(table: 'users')]class User{ #[ORM\Id, ORM\Column(type: "integer")] private int $id = 0; // This is set by the database, so we default to 0.
#[ORM\Column(type: "string")] private string $email; #[ORM\Column(type: "string")] private ?string $password = null; #[ORM\Column(type: "datetime_immutable")] private \DateTimeImmutable $createdAt; #[ORM\Column(type: "string", nullable: true)] private ?string $name = null;
public static function register(string $email, \DateTimeImmutable $now, string $password): self { return new self($email, $now, $password); }
public static function invite(string $email, \DateTimeImmutable $now): self { return new self($email, $now); }
private function __construct( string $email, \DateTimeImmutable $createdAt, ?string $password = null, ?string $name = null, ) { $this->email = $email; $this->password = $password; $this->createdAt = $createdAt; $this->name = $name; }
public function getId(): int { return $this->id; }
public function getEmail(): string { return $this->email; }
public function setEmail(string $email): void { $this->email = $email; }
public function getName(): string { return $this->name; }
public function setName(string $name): void { $this->name = $name; }
public function changePassword(string $currentPassword, string $newPassword): void { if ($currentPassword === '') { throw new \DomainException('Current password cannot be empty'); }
if (!\password_verify($currentPassword, $this->password)) { throw new \DomainException('Current password does not match'); }
$this->hashPassword($newPassword); }
public function resetPassword(string $newPassword): void { $this->hashPassword($newPassword); }
private function hashPassword(string $newPassword): void { if ($newPassword === '') { throw new \DomainException('New password cannot be empty'); }
$this->password = \password_hash($newPassword, \PASSWORD_DEFAULT); }}These new methods ensure that at no point we end up with an invalid password in the entity. They properly encapsulate the password logic within the entity itself. The entity is now in charge of protecting its own invariants.
We’re starting to think about the entity as a proper model of a user in our system, not just as a data container. Now,
every piece of code calling changePassword or resetPassword is subject to the same rules. More logic lives in the
innermost part of the system, closer to the data, and the outer layers can just call the entity methods without worrying
about the details.
What Should Remain Hidden?
The third question is What data must remain hidden from the outside world and how can I achieve that? This question touches on one of the pillars of object-oriented design: encapsulation — the idea that the internal state of an object should be shared in a controlled fashion. Encapsulation is important because the fewer details you expose about the inner workings of an object, the more flexibility you have to change it later.
Code should not be able to read the hashed password: this is a detail that belongs internally to the User class. Luckily,
exposing the password is not necessary anymore because password-changing operations now happen inside the entity, so we can
make this field private and remove the getter.
Some getters can be removed as well. For instance, the getId getter is not necessary because the id is assigned by the
database and will never change. The same goes for the getCreatedAt getter because the creation date of the user cannot
and should not be modified. If we make these properties public readonly we can remove these getters as well.
Lastly, using a bit of extra PHP features like constructor property promotion and asymmetric visibility, our bloated, lifeless entity can be refactored into something elegant, concise, and robust.
14 collapsed lines
<?php
#[ORM\Entity(table: 'users')]class User{ public static function register(string $email, \DateTimeImmutable $now, string $password): self { return new self($email, $now, $password); }
public static function invite(string $email, \DateTimeImmutable $now): self { return new self($email, $now); }
#[ORM\Id, ORM\Column(type: "integer")] public readonly int $id;
public function __construct( #[ORM\Column(type: "string")] private(set) string $email, // This is public, but only changeable within the class #[ORM\Column(type: "string")] private string $password, // This is private. It is not exposed and only changeable within the class #[ORM\Column(type: "datetime_immutable")] public readonly \DateTimeImmutable $createdAt, // This is public but cannot be changed #[ORM\Column(type: "string", nullable: true)] private(set) ?string $name = null, // This is public but only changeable within the class ) { // No need to assign the properties here, they are already assigned by the constructor }
27 collapsed lines
public function changePassword(string $currentPassword, string $newPassword): void { if ($currentPassword === '') { throw new \DomainException('Current password cannot be empty'); }
if (!\password_verify($currentPassword, $this->password)) { throw new \DomainException('Current password does not match'); }
$this->hashPassword($newPassword); }
public function resetPassword(string $newPassword): void { $this->hashPassword($newPassword); }
private function hashPassword(string $newPassword): void { if ($newPassword === '') { throw new \DomainException('New password cannot be empty'); }
$this->password = \password_hash($newPassword, \PASSWORD_DEFAULT); }}Thinking about encapsulation is vital. Ask yourself: Who can read this field? Can this field change? From where? Can I make it public and eliminate boilerplate getter functions? The language features and version you have at your disposal will dictate how you can implement these ideas. The example above uses PHP 8.4 features, and I would encourage anyone to update to this version as of today.
Your Penance
Let’s see how our service layer looks now with the improved entity:
<?php
readonly class UserService{ public function __construct( private UserRepository $users, ) {}
public function register(string $email, string $password): void { $user = User::register($email, new \DateTimeImmutable(), $password); $this->users->add($user); }
public function changePassword(int $userId, string $currentPassword, string $newPassword): void { $user = $this->users->ofId($userId); $user->changePassword($currentPassword, $newPassword); $this->users->add($user); }
public function resetPassword(int $userId, string $newPassword): void { $user = $this->users->ofId($userId); $user->resetPassword($newPassword); $this->users->add($user); }}Look at how thin and focused the service layer has become. No duplication. No validation logic scattered around. No security risks. The service simply orchestrates the operations, while the entity enforces all the business rules. This is what we want.
This week, I challenge you: Pick one entity in your codebase and apply these principles. Refactor it so it moves towards being a proper model of a concept in your domain. Don’t get comfortable in your sloth!
As you do this, you’ll realize that your entities will grow in size, but this comes with the benefit of the code that uses them decreasing in size. You might think of this effort as just shuffling code around. But this is exactly what you want: your invariants and the core of your business model should live as close as possible to the data they protect and operate on.
I also encourage you to read more about Domain Driven Design and Hexagonal Architecture: it’s a fascinating and very effective way of thinking about software design.