Oleksandr Bredikhin
5 min readJun 18, 2024

Why I use Laravel with Doctrine.đŸ€”

Laravel is a great framework that provides many capabilities. A few years ago, I thought that the future of PHP frameworks would be small assemblies like micro-frameworks, such as Lumen, where you gather all the necessary packages used in the project.

The reality turned out to be different, and we still use frameworks like Symfony and Laravel. Laravel even stopped new Lumen releases, leaving only bug fixes. However, this doesn’t prevent us from using components from different frameworks together.

One such component that I integrate into my projects is Doctrine. I won’t go into detail about the differences between Doctrine and Eloquent implementations, but I’ll tell you about the key advantages that are important to me.

  1. Entities.

In my projects, I implement the Domain-Driven Design (DDD) approach. One of the key building blocks of this approach is entities. They should have their own behavior, not be anemic, and be responsible only for the tasks they are intended for. Eloquent is used for these purposes, but it can make it difficult to maintain code and lead to a violation of the Single Responsibility Principle (SRP). Developers use one Eloquent model for the entire project, thinking about the DRY (Don’t Repeat Yourself) principle, which can negatively affect coupling and cohesion. Implementing the Active Record pattern, Eloquent often leads to the creation of anemic objects. These objects are limited exclusively to data representation and typically do not have business logic. Eloquent already has a lot of redundant methods and functionality that do not always correspond to my preferences and are not under my control. This can lead to undesirable consequences and changes in the logic of the entity, which I do not consider the best approach. I prefer to use Doctrine, as it allows me to build full-fledged objects with behavior and provides better integration with DDD principles.

Here, I want to demonstrate how the same database table can be interpreted differently depending on the context of the domain. Administrators can create users using the functionality provided in their context, while users are only allowed to modify their own data within their context. It is not necessary to extract all fields that are not needed in a specific implementation. This approach makes the code cleaner, allows the separation of entity logic into different areas of responsibility, and contributes to improved readability and maintainability of the project.

<?php

declare(strict_types=1);

namespace AdminOffice\Users\UsersManagement\Domain;

use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;
use Shared\Domain\Aggregate\AggregateRoot;

/**
* @ORM\Entity
* @ORM\Table(name="users")
*/
final class UsersEntity extends AggregateRoot
{
private function __construct(
/**
* @ORM\Id
* @ORM\Column(type="string", name="uuid", unique=true)
*/
private readonly string $uuid,
/**
* @ORM\Column(type="string", name="first_name")
*/
private readonly string $firstName,
/**
* @ORM\Column(type="string", name="second_name")
*/
private readonly string $secondName,
// Other parameters here
) {
}

public static function createFromVO(UserManagementVO $userVO): self
{
$uuid = Uuid::uuid7()->toString();
$user = new self(
$uuid,
$userVO->getFirstName(),
$userVO->getSecondName(),
// Other parameters here
);
$user->setCreatedAt();
// Additional logic for creating user
$user->record(new TriggerUserCreatedMessage($uuid));
// Additional logic for creating events or messages
return $user;
}
}
<?php

declare(strict_types=1);

namespace UserOffice\User\Profile\Domain;

use Doctrine\ORM\Mapping as ORM;

/**
* @ORM\Entity
* @ORM\Table(name="users")
*/
final class UserProfileEntity
{
/**
* @ORM\Id
* @ORM\Column(type="string", name="uuid", unique=true,)
*/
private string $uuid;

/**
* @ORM\GeneratedValue
* @ORM\Column(type="string", name="first_name")
*/
private string $firstName;

/**
* @ORM\GeneratedValue
* @ORM\Column(type="string", name="second_name")
*/
private string $secondName;

private function __construct() {}

public function update(string $firstName, string $secondName): self
{
$this->firstName = $firstName;
$this->secondName = $secondName;

return $this;
}

public function getFullName(): string
{
return sprintf('%s %s', $this->firstName, $this->secondName);
}
}

2. Clear structure and adherence to the Single Responsibility Principle.
Doctrine entities focus on representing business logic and functionality without knowing anything about the database. This allows for a separation of concerns: entities handle operations and behavior, while the repository layer is responsible for data access and interaction with the database.

Doctrine encourages the use of the Repository pattern, where each repository is responsible for handling database queries and operations related to a specific entity. Separation of concerns ensures that each repository class has a single responsibility of managing interactions with its corresponding entity.

Combined with the principle of Single Responsibility, this approach leads to a more organized and maintainable codebase. It allows to focus on the business logic within entities while keeping database operations encapsulated in dedicated repository classes, promoting a cleaner and clearer architecture.

3. Unit tests.
If Eloquent has any behavior for unit tests you either need to connect to the database with all the associated consequences or mock the necessary class. Eloquent interacts directly with the database, which can make it difficult to create isolated tests without real database interactions. Additionally, code using Eloquent often has multiple dependencies on various models and services, making it complex to create and manage mocks or stubs for testing.

With entities in Doctrine, everything is much simpler; you can easily create the necessary class implementations and test them.

<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use UserOffice\User\Profile\Domain\UserProfileEntity;
use ReflectionException;

final class UserProfileEntityTest extends TestCase
{
/**
* @throws ReflectionException
*/
public function testUpdateUserInfo(): void
{
$reflectionClass = new ReflectionClass(UserProfileEntity::class);
$userProfile = $reflectionClass->newInstanceWithoutConstructor();
$reflectionProperty = $reflectionClass->getProperty('firstName');
$reflectionProperty->setValue($userProfile, 'FirstName');

$reflectionProperty = $reflectionClass->getProperty('secondName');
$reflectionProperty->setValue($userProfile, 'LastName');

$expectedFullName = 'John Doe';
$userProfile->update('John', 'Doe');
$actualFullName = $userProfile->getFullName();

$this->assertEquals($expectedFullName, $actualFullName);
}

/**
* @throws ReflectionException
*/
public function testGetFullName(): void
{
$reflectionClass = new ReflectionClass(UserProfileEntity::class);
$userProfile = $reflectionClass->newInstanceWithoutConstructor();
$reflectionProperty = $reflectionClass->getProperty('firstName');
$reflectionProperty->setValue($userProfile, 'John');

$reflectionProperty = $reflectionClass->getProperty('secondName');
$reflectionProperty->setValue($userProfile, 'Doe');

$expectedFullName = 'John Doe';
$actualFullName = $userProfile->getFullName();

$this->assertEquals($expectedFullName, $actualFullName);
}
}

4. Static Analysis.
Using static analysis tools like Psalm and PHPStan often reveals numerous errors when working with Eloquent ORM. This is because Eloquent employs a lot of “magic” under the hood, which complicates code analysis and understanding. While it is possible to suppress warnings and continue working, this is not the most effective solution as it reduces the quality of checks and makes it harder to identify potential issues in the code.

As a conclusion.
Using Laravel Vapor with Octane, I encountered issues with dropped connections resulting in a ‘Connection timed out’ error. Fortunately, everything was resolved by utilizing AWS Proxy.

One of the drawbacks is that it’s not always possible to make classes final because Doctrine creates cached proxies that inherit from your entity.

Transitioning to Doctrine may require some time for learning, especially if one doesn’t have prior experience. Nonetheless, Eloquent remains a powerful tool suitable for projects of varying complexities. The choice ultimately lies with the developer.

Thank you, and I hope my experience and thoughts have been helpful in some way. If you have any questions or want to share your thought, let’s discuss in comments.đŸ‘‡đŸ»