DEV Community

Cover image for Value Objects in PHP 8: Entities
Christian Nastasi
Christian Nastasi

Posted on • Edited on

Value Objects in PHP 8: Entities

Table of contents

Introduction

In the previous articles, we learned how to create fundamental value objects and recognized their role in enhancing the stability and reliability of our applications.

This article will delve into another architectural pattern closely connected to value objects: Entities.

First, let's refresh our memory on the essential qualities of a Value Object.

Value Objects are the unsung heroes of our code, bringing immutability and predictability to our applications. They encapsulate small data pieces, ensuring their state remains constant once set. This predictability simplifies our code and contributes to overall system stability.

A Value Object describes a value, a measurement or something unitary and atomic. This is why if two Value Objects have the same value, they can be considered the same object (also if they are two different instantiated objects).

Explaining the concept in simple terms: 50 € equals 50 €.
However, 2 apples are different from 2 pears.

Entities

Despite Entities shares a structural similarity with Value Objects, they possess distinct characteristics and qualities.

For example, an Entity possesses a unique identity, allowing it to undergo state changes while retaining its individuality. This distinct identity makes it suitable for scenarios where persistence and mutability are crucial.

Let's dive deeper into the qualities that define an entity in our PHP applications.

Identity in Entities

The initial characteristic defining an entity is its unique identity. Unlike Value Objects, Entities bear a distinctive mark that distinguishes them. This identity maintains consistency throughout their existence, providing a stable reference point.

An entity's identity can take various forms, typically manifesting as a number or a string (examples include UUID, email, fiscal code). This concept aligns closely with the idea of a primary key in databases.

Take the example of a "User" entity in a system. The unique identifier (ID) linked to each user plays a crucial role in defining their identity. This significance endures, facilitating differentiation even when other attributes, such as the username or email, undergo alterations.

class User
{
    public function __construct(
        private readonly int $id,
        private string $name,
        private string $email
    ) {
        $this->validateId();
        $this->validateName();
        $this->validateEmail();
    }

    public function getId():int 
    {
        return $this->id;
    }

    public function getName():string 
    {
        return $this->name;
    }

    public function getEmail():string 
    {
        return $this->email;
    }

    public function __toString():string
    {
        return "{$this->id} | {$this->name} | {$this->email}";
    }

    // Validators
    // Additional methods for operations involving user data
}

// Usage:
$user = new User(1, 'John Doe', 'john@example.com');

// Output: 1 | John Doe | john@example.com
echo $user; 
Enter fullscreen mode Exit fullscreen mode

This example shows a structural difference between an Entity and a Value Object. Unlike Value Objects, Entities can't use public readonly properties because attributes like name and email might change. Uncontrolled changes are undesirable, so these properties are private/protected. Access to them is managed through getter and setter methods.

A __toString method can also help debug your entity easily.

Later in the article, we'll explore how to craft an entity with both immutable and mutable aspects, leveraging public readonly properties instead of traditional getters and setters.

Mutability and State Changes

Unlike Value Objects, Entities can undergo state changes during their lifecycle. This mutability grants them the flexibility to adapt to various scenarios, mirroring the dynamic nature of the real entities they represent.

In the context of our "User" entity example, users can dynamically update their profile information, alter their preferences, or modify their roles within a system. The inherent capacity of Entities to evolve over time makes them well-suited for modelling elements that experience variations in their attributes.

class User
{
    public function __construct(
        private readonly int $id,
        private string $name,
        private string $email
    ) {
        $this->validateId();
        $this->validateName();
        $this->validateEmail();
    }

    // Getters
    // validators
    // __toString

    public function changeName(string $name): void
    {
        $this->name = $name;

        $this->validateName();
    }

    public function changeEmail(string $email): void 
    {
        $this->email = $email;

        $this->validateEmail();
    }
}

// Usage:
$user = new User(1, 'John Doe', 'john@example.com');

// Output: 1 | John Doe | john@example.com
echo $user; 

$user->changeName('Frank Doe');

// Output: 1 | Frank Doe | john@example.com
echo $user; 

$user->changeEmail('frank@example.com');

// Output: 1 | Frank Doe | frank@example.com
echo $user; 
Enter fullscreen mode Exit fullscreen mode

The presented example shows the dynamic nature of the "User" entity. The entity's state is modified through the changeName and changeEmail methods, allowing users to update their information seamlessly and safely (using validators).
This mutability is a crucial aspect of Entities, enabling them to reflect the evolving characteristics of the real-world entities they represent.

It's noteworthy that two Entities, even if their attribute values differ, can be recognized as the same entity when sharing a common identifier. This suggests the possibility of referencing the same entity, albeit potentially representing different versions.

This lays the groundwork for exploring the concept of an "entity version," a topic we'll delve into in a subsequent chapter.

Persistence Beyond a Single Transaction

Entities exhibit persistence beyond a single transaction or request, ensuring their endurance through various interactions with the system. This persistence proves invaluable when modelling elements requiring consistent storage, retrieval, and referencing.

Consider an "Order" entity within an e-commerce system. Following a purchase, the order retains its identity, and all associated details, such as purchased items and the customer, persist within the system's database. This enduring presence allows for future reference and retrieval, maintaining a comprehensive record of transactions.

Similarly, once registered, a "User" will be persisted and retrieved each time access to the system occurs.

When storing Entities, a recommended pattern is the Repository Pattern. I will delve into it more extensively in the future, but for now, suffice it to know that it abstracts the concept of an "entity collection".

In an entity collection, you can add, update or delete an entity or retrieve it using the identifier. In an evolved collection, you can also search by some criteria.

So, we first define a contract with methods like add, update, delete and findById for managing and retrieving Entities.

interface UserRepository 
{
    public function add(User $user):void;

    public function update(User $user):void;

    public function delete(User $user):void;

    public function findById(int $id):?User;

    public function findByEmail(string $email):?User;
}
Enter fullscreen mode Exit fullscreen mode

Then, let the interface concrete implementation take care of the persistence and be responsible for the consistency of the data. This means that the concrete implementation has to ensure that everything will be done in a single transaction: if more than one write is necessary and an error occurs, then a rollback must be done. No broken data should be left behind.

Also, the code that will use the contract is unaware of where and how the entity will be stored, which could be anywhere (in a database, a file, or a remote service).

This pattern will also improve your testing suite a lot. Switching your concrete implementation with an in-memory repository will dramatically reduce the time needed to run a functional test suite.

Immutable Entities

As we hinted at in the preceding chapters, one viable approach enabled by the use of public readonly properties is immutability, as we've already seen in the two previous articles of this series.

This implies that a new instance is generated with every modification to the Entity. Here's how the "User" entity might appear using this approach.

class User
{
    public function __construct(
        public readonly int $id,
        public readonly string $name,
        public readonly string $email
    ) {
        $this->validateId();
        $this->validateName();
        $this->validateEmail();
    }

    // Validators
    // __toString

    public function changeName(string $name): User
    {
        return new User($this->id, $name, $this->email);
    }

    public function changeEmail(string $email): User 
    {
        return new User($this->id, $this->name, $email);
    }
}

// Usage:
$user = new User(1, 'John Doe', 'john@example.com');

// Output: 1 | John Doe | john@example.com
echo $user; 

$newUser = $user->changeName('Frank Doe')
                ->changeEmail('frank@example.com');

// Output: 1 | Frank Doe | frank@example.com
echo $newUser; 

// Output: 1 | John Doe | john@example.com
echo $user;  
Enter fullscreen mode Exit fullscreen mode

The first noticeable change is that now the properties are no longer private but public and readonly. This implies that getter methods are no longer necessary, and information access could be performed directly using the properties.

The second change is that the changeName and changeEmail methods now return a new "User" instance, allowing us to use a fluent syntax and chain multiple modifications in succession.

The last observation is that the data remains unchanged in the initial "User" instance. Consequently, we end up with two instances of the same entity (as they share the same identifier) but with different data. This could lead to errors if not handled carefully. A potential solution to this issue will be addressed shortly in the "Entity Version" chapter.

However, the drawback of this approach lies in the fact that, especially in highly structured and large Entities, creating a new object instance might not be efficient, especially if done frequently. Still, it could be an interesting approach to ensure the robustness of your application.

Entity Version

As we've seen in the previous chapters, when comparing Entities, the only parameter available is their identifier. Determining if two instances of the same entity possess identical data becomes challenging when internal data changes.

One might initially consider using a counter that increments upon internal data modification. The implementation could look something like this:

class User
{
    public function __construct(
        public readonly int $id,
        public readonly string $name,
        public readonly string $email,
        public readonly int $version = 0
    ) {
        $this->validateId();
        $this->validateName();
        $this->validateEmail();
    }

    // Validators
    // __toString

    public function changeName(string $name): User
    {
        return new User($this->id, $name, $this->email, $this->version + 1);
    }

    public function changeEmail(string $email): User 
    {
        return new User($this->id, $this->name, $email,  $this->version + 1);
    }
}
Enter fullscreen mode Exit fullscreen mode

However, this solution presents several issues and cannot be effectively used to ascertain the strict equality of two Entities:

$user = new User(1, 'John Doe', 'john@example.com');

$newUser1 = $user->changeName('Frank Doe');
$newUser2 = $user->changeEmail('frank@example.com');

// Output: 1 | John Doe | john@example.com | v0
echo $user;  

// Output: 1 | Frank Doe | john@example.com | v1
echo $newUser1; 

// Output: 1 | John Doe | frank@example.com | v1
echo $newUser2;

// Output: bool(true)
var_dump($newUser1->version === $newUser2->version);
Enter fullscreen mode Exit fullscreen mode

We need to find another solution. An intriguing approach is to create a non-random hash string based on entity data, providing a stable and comparable version identifier. A simple way to achieve this is as follows:

class User
{
    public readonly string $version;

    public function __construct(
        public readonly int $id,
        public readonly string $name,
        public readonly string $email,
    ) {
        $this->validateId();
        $this->validateName();
        $this->validateEmail();

        $this->version = $this->calculateVersion();
    }

    // Validators
    // __toString
    // setter

    private function calculateVersion(): string 
    {
         return md5((string)$this);
    }
}

$user = new User(1, 'John Doe', 'john@example.com');

$newUser1 = $user->changeName('Frank Doe');
$newUser2 = $user->changeEmail('frank@example.com');
$newUser3 = $newUser1->changeName('John Doe');

var_dump($user->version === $newUser1->version);     // false
var_dump($user->version === $newUser2->version);     // false
var_dump($newUser1->version === $newUser2->version); // false
var_dump($user->version === $newUser3->version);     // true
Enter fullscreen mode Exit fullscreen mode

Of course, many different hash functions could be used for this purpose, but I just wanted to demonstrate the concept, so I used the simplest one. With this trick, by the way, some optimization could be done. For example, store an entity only if changed or decide if a cache is invalid and needs a refresh, using the hash as a reference.

Mixing Entities with Value Objects

In all my examples, I have consistently used primitive properties for simplicity. However, nothing forbids us from applying what we've learned in the previous articles and incorporating Value Objects within our Entities.

Here's an example of the "User" entity transformed using Value Objects:

class User
{
    public readonly string $version;

    public function __construct(
        public readonly UserId $id,
        public readonly UserName $name,
        public readonly Email $email,
    ) {
        $this->version = $this->calculateVersion();
    }

    public function changeName(UserName $name): User
    {
        return new User($this->id, $name, $this->email);
    }

    public function changeEmail(Email $email): User 
    {
        return new User($this->id, $this->name, $email);
    }

    public function __toString(): string
    {
        return "{$this->id} | {$this->name} | {$this->email}";
    }

    private function calculateVersion(): string 
    {
         return md5((string)$this);
    }
}
Enter fullscreen mode Exit fullscreen mode

Property validation essentially disappears in this adaptation. The rest of the class remains quite similar to the version with primitives. However, it carries the advantage of centralizing validation rules for certain properties repeated across multiple Entities.
Of course, like the Composite Value Objects, we might need to validate business rules between correlated properties.

Practical Considerations: Choosing Between Value Objects and Entities

Understanding when to use Value Objects versus Entities is pivotal for effective system design. Here are some practical considerations:

Use Value Objects When:

  • The concept you're modelling doesn't require a unique identity.
  • Immutability and predictability are paramount.
  • You're dealing with smaller, isolated pieces of data.

Use Entities When:

  • A unique identity is crucial for the concept.
  • The element undergoes state changes during its lifecycle.
  • Persistence and adaptability are significant requirements.

Conclusion

In conclusion, this article explored the architectural pattern of Entities closely connected to Value Objects in PHP applications. The fundamental qualities of Value Objects, such as immutability and predictability, were revisited to set the stage for understanding Entities.

Entities, distinguished by their unique identity and mutability, were examined in-depth, showcasing how Entities can effectively model real-world entities, allowing for state changes while maintaining a stable identity.
The persistence of Entities beyond a single transaction was discussed, emphasizing their importance in scenarios requiring consistent storage and retrieval.

Implementing Immutable Entities using public readonly properties was presented as an alternative approach. This approach involves creating new instances for each modification, ensuring the integrity of the original data. The concept of an "entity version" was introduced, exploring methods to handle changes in entity data over time.

The article also demonstrated the integration of Value Objects within Entities, showcasing how these patterns can collaborate to enhance code clarity and maintainability. Practical considerations for choosing between Value Objects and Entities were outlined, guiding developers based on specific system requirements.

Ultimately, the choice between Value Objects and Entities depends on the nature of the concepts being modelled and the desired system characteristics.

By mastering these architectural patterns, PHP developers can design robust, predictable, and adaptable applications that align with best practices in software architecture.

Top comments (7)

Collapse
 
myks92 profile image
Myks92

Hi! Thanks for the article! I would like to know if there are examples of integration with Doctrine? if not, is it planned? For example, I wonder where Attributes are placed? In the promotion constructor?

Collapse
 
cnastasi profile image
Christian Nastasi

Hi @myks92, I'm happy you'd appreciate it.

I didn't think about this topic, but it could be interesting to write an article about it.

But I will give you a short answer anyway.

We have a problem here: The entities described in my article are different from Doctrine Entities because, despite the same name, they implement two distinct patterns.

Doctrine Entities use the Data Mapper approach, where we have a 1:1 relation between the physical database table and the class. For every field of the class, we have a field on a table. For every class, we also have a table.
Also, the structure is pretty flat. It's not expected to have tree-like structures with nested objects because it's a mess using a relational database. It's a different story if you're using Doctrine ODM and MongoDB instead of Doctrine ORM.

Instead, DDD Entities doesn't care how the object will persist. You design your object exactly how you need it. So, you could have very complex structures with many nested objects. Using a No-SQL database could help to persist this kind of structure because you don't have to normalize and flatten the data and create a complex map to convert your object. You just need a good serializer/deserializer to do the job for you.

Wrapping up, why is it bad to mix up Doctrine Entities and DDD Entities? You don't want to limit your expressivity and be bound by infrastructure limits. Also, doing so violates the dependency rules, where the domain must not know anything about the application and infrastructure layer, and Doctrine is Infrastructure.

What do you think? Does that make sense to you?

Let me know

Collapse
 
myks92 profile image
Myks92 • Edited

Thanks for the detailed comment. I want to say right away that it would be interesting to continue this topic – integration with Doctrine, or other methods. I think it would be interesting to expand the article on the subject of integration and the possible storage of entities.

Regarding the Doctrine Entity, I do not quite agree with you, or you might have misunderstood me. Doctrine also allows you to work with the DDD Entity and Value Object. In my current project, it looks like this:

Entity:

#[ORM\Entity]
#[ORM\Table(name: 'auth_users')]
class User extends AggregateRoot
{
    #[ORM\Column(type: 'auth_user_id')]
    #[ORM\Id]
    private Id $id;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
    private \DateTimeImmutable $joinDate;

    #[ORM\Column(type: 'auth_user_username', unique: true)]
    private Username $username;

    #[ORM\Embedded(class: Name::class)]
    private Name $name;

    #[ORM\Column(type: 'auth_user_email', unique: true)]
    private Email $email;

    #[ORM\Column(type: Types::STRING, nullable: true)]
    private ?string $passwordHash = null;

    #[ORM\Column(type: 'auth_user_status', length: 16)]
    private Status $status;

    #[ORM\Embedded(class: Token::class)]
    private ?Token $joinConfirmToken = null;

    #[ORM\Embedded(class: Token::class)]
    private ?Token $passwordResetToken = null;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
    private ?\DateTimeImmutable $passwordResetDate = null;

    #[ORM\Column(type: 'auth_user_email', nullable: true)]
    private ?Email $newEmail = null;

    #[ORM\Embedded(class: Token::class)]
    private ?Token $newEmailToken = null;

    #[ORM\Column(type: 'auth_user_role', length: 16)]
    private Role $role;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
    private ?\DateTimeImmutable $editDate = null;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
    private ?\DateTimeImmutable $loginDate = null;

    /**
     * @var Collection<array-key,UserAccount>
     */
    #[ORM\OneToMany(mappedBy: 'user', targetEntity: UserAccount::class, cascade: ['all'], orphanRemoval: true)]
    private Collection $accounts;

    private function __construct(Id $id, \DateTimeImmutable $date, Username $username, Name $name, Email $email, Status $status)
    {
        $this->id = $id;
        $this->joinDate = $date;
        $this->name = $name;
        $this->email = $email;
        $this->status = $status;
        $this->role = Role::user();
        $this->username = $username;
        $this->accounts = new ArrayCollection();
    }

    public static function create(
        Id $id,
        \DateTimeImmutable $date,
        Name $name,
        Email $email,
        string $passwordHash,
    ): self {
        $user = new self($id, $date, new Username($email->getLocal()), $name, $email, Status::active());
        $user->passwordHash = $passwordHash;

        return $user;
    }

    public static function joinByAccount(
        Id $id,
        \DateTimeImmutable $date,
        Name $name,
        Email $email,
        Account $account,
    ): self {
        $user = new self($id, $date, new Username($email->getLocal()), $name, $email, Status::active());
        $user->accounts->add(new UserAccount($user, $account));

        return $user;
    }

    public static function requestJoinByEmail(
        Id $id,
        \DateTimeImmutable $date,
        Name $name,
        Email $email,
        string $passwordHash,
        Token $token,
    ): self {
        $user = new self($id, $date, new Username($email->getLocal()), $name, $email, Status::wait());
        $user->passwordHash = $passwordHash;
        $user->joinConfirmToken = $token;

        return $user;
    }
}
Enter fullscreen mode Exit fullscreen mode

DoctrineType:

final class IdType extends GuidType
{
    public const string NAME = 'auth_user_id';

    #[\Override]
    public function convertToDatabaseValue($value, AbstractPlatform $platform): mixed
    {
        return $value instanceof Id ? $value->getValue() : $value;
    }

    #[\Override]
    public function convertToPHPValue($value, AbstractPlatform $platform): ?Id
    {
        return empty($value) ? null : new Id((string) $value);
    }

    #[\Override]
    public function getName(): string
    {
        return self::NAME;
    }

    #[\Override]
    public function requiresSQLCommentHint(AbstractPlatform $platform): bool
    {
        return true;
    }
}
Enter fullscreen mode Exit fullscreen mode

Config:

$configurator->extension('doctrine', [
        'dbal' => [
            'types' => [
                User\IdType::NAME => User\IdType::class,
                User\EmailType::NAME => User\EmailType::class,
                User\RoleType::NAME => User\RoleType::class,
                User\StatusType::NAME => User\StatusType::class,
                User\UsernameType::NAME => User\UsernameType::class,
            ],
        ],
        'orm' => [
            'mappings' => [
                'Auth' => [
                    'is_bundle' => false,
                    'type' => 'attribute',
                    'dir' => __DIR__ . '/Entity',
                    'prefix' => 'App\Auth\Entity',
                    'alias' => 'Auth',
                ],
            ],
        ],
    ]);
Enter fullscreen mode Exit fullscreen mode

I use attributes to integrate my entity with Doctrine ORM. You may use another method. Please share with us.

In any case, I am looking forward to a new article from you about the topic of DDD. It's very interesting and exciting. As I mentioned, you could share the article "Integration of DDD into Symfony" or you could also share your thoughts on the Bounded Context.

Thank you for your work!

Thread Thread
 
cnastasi profile image
Christian Nastasi

That's interesting.

Basically, you created a Doctrine Type for every value object that you had defined, which could be expensive but, in fact, could be a good way to manage it.

However, this approach has several cons: you bind yourself with Doctrine.

What if you have a legacy DB, and thus, your entities could be better designed?
What if your API resources are poorly designed, too?
What if part of your data comes from outside (p.e. a remote service)?

So, binding with Doctrine like this makes sense when your project is small and well-designed from the start and the interaction with external web services is low.

Otherwise, you need an anticorruption layer, and then you should have two(or three) layers of entities:

  • APIs entities (could be optional)
  • Domain entities
  • Doctrine Entities

In the middle, between each layer, you need a Mapper or a Serializer / Deserializer to translate one data into another.

This approach also gives you a very high degree of liberty because you can denormalize your database table and be completely independent of how you use the data and how the data persists, especially when you are using a relational database.

It is a different story when you are using a NoSQL like MongoDB. You can persist with your entities as they are without modification. Still, an anticorruption layer could do the job if you have a legacy database with a poorly designed schema.

About new articles, I'm pretty busy right now, and I don't plan to write something pretty soon, but in the future, I will. So stay tuned.

Thread Thread
 
myks92 profile image
Myks92

You're right. In my implementation, entities depend on Doctrine, but this is just a compromise. With the help of Doctrine, we can safely describe the schema separately from the entity, as well as the doctrine type. ORM is an object relational mapper. That's why everything is fair here.

On the other hand, I also notice how doctrine ORM sometimes affects my domain entity. I would like to get rid of it, but I don't quite understand how. Doctrine ORM has good infrastructure. That's why I chose her for now. Developing your own mapper is very expensive and redundant in most cases. Perhaps there's some kind of ready made mapper? That would be great!

Thanks for your comment! I have subscribed to your channel and will be waiting for the next article. I believe that with this article, you can help not only me but also other developers facing similar problems. It would be great if you could give an example of the Doctrine ORM and improve upon it. Please describe all the disadvantages you listed here.

Have a good day!

Thread Thread
 
cnastasi profile image
Christian Nastasi

Thanks for the subscription.

If you think about it, a Doctrine Type is already a Mapper, but it works only for fields and not for the entire Entity.

Thanks again and have a good day too

Collapse
 
mm-novelt profile image
Mathias Muntz

If the ID, name and email need validation, you must transform each of these properties into ValueObject and move the validation inside each of them, because in your case, the validateXxx methods are part of of the entity, so in the case where another aggregate must also validate the email, you must duplicate the code or use a trait, the VO solves this problem.