Implementing DDD in PHP

Sergei Pantiushin
7 min readMay 1, 2024

Learn to implement DDD in PHP! Understand structured domains, bounded contexts, and services to apply these principles to your projects.

Here’s a guide to implementing DDD in PHP. In this example, we will create a web service Billie that allows adding companies, adding invoices and marking an invoice as paid.

An invoice is a document issued by a seller (creditor) to the buyer (debtor). It provides details about a sale or services, including the quantities, costs. Factoring is a process where a company (creditor) sells its invoice to a third-party factoring company (Billie). The factoring company then takes care of collecting the money from the debtor. Since there is always the risk that a debtor won’t pay their invoices, Billie sets a debtor limit for each
company. This means that Billie won’t accept the invoice if the debtor’s total amount of open invoices reaches the limit.

Understand Core DDD Principles

Domain-Driven Design (DDD) is a methodology for designing and managing the complexity of software projects by focusing on the domain model. Implementing DDD in PHP requires some understanding of DDD principles, which are then translated into code through various design patterns.

  • Domain Model: Represents the core business concepts, entities, and processes.
  • Bounded Context: Defines the boundary within which a particular model applies.
  • Entities and Value Objects: Entities have a unique identity, while value objects do not.
  • Aggregates: A group of related entities and value objects treated as a single unit.
  • Repositories: Handle retrieving and persisting aggregates.

Implementing Bounded Contexts

Organize your codebase into folders representing bounded contexts.

For simplicity, we will consider two main bounded contexts:

  • Company Management Context — Handles company-related operations.
  • Invoicing Context — Deals with creating and managing invoices.

The following is the structure of the application folders.

src
Company // Company Management Context
Application
Command
Query
Domain
Company
Infrastructure
Api
Rest
Persistence
Doctrine
UserInterface
Controller
Invoice // Invoicing Context
Application
Command
Query
Domain
DebtorLimit
Invoice
Infrastructure
Api
Rest
Persistence
Doctrine
UserInterface
Controller

Domain

The Domain Layer defines the business rules and logic in a way that they are isolated from infrastructure concerns. This separation allows the business logic to evolve independently from technological changes, making the system more maintainable and adaptable.

<?php

declare(strict_types=1);

namespace App\Invoice\Domain\Invoice;

use Symfony\Component\Uid\Uuid;

final class Invoice
{
private Uuid $id;
private Uuid $debtorId;
private Uuid $creditorId;
private int $amount;
private \DateTimeImmutable $issueDate;
private \DateTimeImmutable $dueDate;
private InvoiceStatus $status;

public function __construct(
Uuid $id,
Uuid $debtorId,
Uuid $creditorId,
int $amount,
\DateTimeImmutable $issueDate,
\DateTimeImmutable $dueDate,
) {
$this->id = $id;
$this->debtorId = $debtorId;
$this->creditorId = $creditorId;
$this->amount = $amount;
$this->issueDate = $issueDate;
$this->dueDate = $dueDate;
$this->status = InvoiceStatus::Open;
}

public function markAsPaid(): void
{
$this->status = InvoiceStatus::Paid;
}

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

public function getDebtorId(): Uuid
{
return $this->debtorId;
}

public function getCreditorId(): Uuid
{
return $this->creditorId;
}

public function getAmount(): int
{
return $this->amount;
}

public function getIssueDate(): \DateTimeImmutable
{
return $this->issueDate;
}

public function getDueDate(): \DateTimeImmutable
{
return $this->dueDate;
}

public function getStatus(): InvoiceStatus
{
return $this->status;
}
}

Core Domain Models for example application:

  • Company
    - Attributes: Company ID, Name, Type, Address
    - Methods: UpdateDetails()
  • Invoice
    - Attributes: Invoice ID, Debtor ID, Creditor ID, Amount, Issue Date, Due Date, Status
    - Methods: MarkAsPaid()
  • Debtor Limit
    - Attributes: Debtor ID, Limit Amount, Current Utilized Amount
    - Methods: UpdateLimit(), CalculateRemainingCredit(), CanAcceptInvoice()

Application

The application layer contains classes called commands and command handlers. A command represents what is to be done. It is a simple DTO data object containing only values of primitive type and their simple lists. There is always a command handler that knows how to handle a particular command. Typically, the command handler (which is also known as Application Service) performs any orchestration required. It uses data from the command object to create an aggregate or pull data from a repository and perform some action on it. The aggregate is then often saved.

<?php

declare(strict_types=1);

namespace App\Invoice\Application\Command\AddInvoice;

use Symfony\Component\Uid\Uuid;

final class AddInvoiceCommand
{
public function __construct(
public Uuid $id,
public Uuid $debtorId,
public Uuid $creditorId,
public int $amount,
public \DateTimeImmutable $issueDate,
public \DateTimeImmutable $dueDate,
) {
}
}
<?php

declare(strict_types=1);

namespace App\Invoice\Application\Command\AddInvoice;

use App\Common\Bus\CommandHandler;
use App\Invoice\Domain\DebtorLimit\Repository\DebtorLimitRepositoryInterface;
use App\Invoice\Domain\Invoice\Invoice;
use App\Invoice\Domain\Invoice\Repository\InvoiceRepositoryInterface;

final readonly class AddInvoiceCommandHandler implements CommandHandler
{
public function __construct(
private DebtorLimitRepositoryInterface $debtorLimitRepository,
private InvoiceRepositoryInterface $invoiceRepository,
) {
}

public function __invoke(AddInvoiceCommand $command): void
{
$limit = $this->debtorLimitRepository->findDebtorLimit($command->debtorId);
if ($limit === null) {
throw new \DomainException('Debtor limit not found');
}

$totalOpenInvoicesAmount = $this->invoiceRepository->getTotalOpenInvoicesAmount($command->debtorId);
$debtorLimit = $limit->getLimitAmount();

if ($totalOpenInvoicesAmount + $command->amount > $debtorLimit) {
throw new \DomainException('Debtor limit exceeded');
}

$invoice = new Invoice(
$command->id,
$command->debtorId,
$command->creditorId,
$command->amount,
$command->issueDate,
$command->dueDate,
);

$this->invoiceRepository->save($invoice);
}
}

At the application level, we can still use Unit tests. Below is an example test for AddInvoiceCommandHandler. We use mock implementations of repositories for this tests.

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Application;

use App\Invoice\Application\Command\AddInvoice\AddInvoiceCommand;
use App\Invoice\Application\Command\AddInvoice\AddInvoiceCommandHandler;
use App\Invoice\Domain\DebtorLimit\DebtorLimit;
use App\Invoice\Infrastructure\Persistence\Mock\DebtorLimitRepositoryMock;
use App\Invoice\Infrastructure\Persistence\Mock\InvoiceRepositoryMock;
use Codeception\Test\Unit;
use Symfony\Component\Uid\Uuid;

class AddInvoiceCommandHandlerTest extends Unit
{
public function test()
{
$debtorLimit = new DebtorLimit(
id: Uuid::fromString('00000000-0000-0000-0000-000000000004'),
debtorId: Uuid::fromString('00000000-0000-0000-0000-000000000002'),
limitAmount: 100,
currentUtilizedAmount: 0,
);
$debtorLimitRepository = new DebtorLimitRepositoryMock($debtorLimit);
$invoiceRepository = new InvoiceRepositoryMock();
$handler = new AddInvoiceCommandHandler($debtorLimitRepository, $invoiceRepository);
$command = new AddInvoiceCommand(
id: Uuid::fromString('00000000-0000-0000-0000-000000000001'),
debtorId: Uuid::fromString('00000000-0000-0000-0000-000000000002'),
creditorId: Uuid::fromString('00000000-0000-0000-0000-000000000003'),
amount: 1,
issueDate: new \DateTimeImmutable('2021-01-01 00:00:00'),
dueDate: new \DateTimeImmutable('2021-01-02 00:00:00'),
);

$handler($command);

$result = $invoiceRepository->getSavedEntity();
self::assertEquals([
'id' => '00000000-0000-0000-0000-000000000001',
'debtor_id' => '00000000-0000-0000-0000-000000000002',
'creditor_id' => '00000000-0000-0000-0000-000000000003',
'amount' => 1,
'issue_date' => '2021-01-01 00:00:00',
'due_date' => '2021-01-02 00:00:00',
], [
'id' => $result->getId()->toRfc4122(),
'debtor_id' => $result->getDebtorId()->toRfc4122(),
'creditor_id' => $result->getCreditorId()->toRfc4122(),
'amount' => $result->getAmount(),
'issue_date' => $result->getIssueDate()->format('Y-m-d H:i:s'),
'due_date' => $result->getDueDate()->format('Y-m-d H:i:s'),
]);
}
}

Infrastructure

Infrastructure Layer provides technical capabilities to support the Domain Layer and Application Layer. It acts as the bridge between the application and the external systems, handling concerns like data persistence, communication, and third-party services.

The layered architecture provides a very useful way of separation. However, we can improve the structure further by analyzing the different ways in which the application interacts with the world, through “ports.” A port is an abstract concept, and it might not have a direct representation in the codebase (other than as a namespace or a directory). A port can be something like:

  • UserInterface
  • API
  • Persistence
  • Messaging
Invoice // Invoicing Context
...
Infrastructure
Api // Port
Rest // Adapter
Persistence // Port
Doctrine // Adapter

There is a port for each way in which the application’s use cases can be invoked. For each of these abstract ports, we need code to interact with the outside world. We need code to handle HTTP messages so that users can interact with our application over the internet. We need code to communicate with the database to persist our data. We write at least one adapter for each port of our application. Since adapter code is related to connecting the application to the outside world, it’s infrastructure code and should be located in the infrastructure layer.

At this level, we utilize Symfony components to their fullest potential:

  • Serializer — for transform raw data to DTO
  • Vadidator — for validate user data
  • Messenger — for run Application Commands
    #[Route('/invoices', methods: ['POST'])]
public function addInvoice(Request $request, EntityManagerInterface $em): JsonResponse
{
/** @var InvoiceDTO $invoiceDTO */
$invoiceDTO = $this->serializer->deserialize($request->getContent(), InvoiceDTO::class, 'json');
$errors = $this->validator->validate($invoiceDTO);

if (count($errors) > 0) {
return new JsonResponse(['errors' => (string) $errors], 400);
}
$invoiceId = Uuid::v7();
$this->commandBus->dispatch(new AddInvoiceCommand(
$invoiceId,
$invoiceDTO->debtorId,
$invoiceDTO->creditorId,
$invoiceDTO->amount,
$invoiceDTO->issueDate,
$invoiceDTO->dueDate,
));

return new JsonResponse(['invoiceId' => $invoiceId->toRfc4122(), 'status' => 'created'], 201);
}

Conclusion

Implementing Domain-Driven Design (DDD) in PHP requires a comprehensive understanding of both DDD principles and PHP application architecture. The Billie example demonstrates the structured separation of concerns that DDD brings to software development. Here are key takeaways:

  1. Structured Domain Modeling: The Billie example highlights how DDD helps organize the domain model, separating concerns into distinct entities like Invoice and DebtorLimit. This makes business logic modular and easy to understand.
  2. Bounded Contexts and Aggregates: By splitting functionality into bounded contexts like Company Management and Invoicing, and using aggregates to maintain consistency, DDD ensures clear boundaries around domain logic. This helps reduce complexity and prevents domain knowledge leakage.
  3. Application Layer for Orchestration: The application layer is responsible for coordinating domain logic and other services, like handling commands and orchestrating actions through handlers. It effectively bridges the domain and infrastructure layers.
  4. Infrastructure Layer as a Bridge: By encapsulating technical concerns such as data persistence and external communication, the infrastructure layer acts as the bridge between application code and the external environment. Adopting Symfony components further enhances productivity and integration.
  5. Testing and Validation: DDD principles emphasize testing domain logic through unit tests and validation to ensure consistency. The example illustrates how to validate user inputs and simulate repository interactions.

By following these principles, developers can ensure that their applications are robust, maintainable, and aligned with business requirements. The journey to mastering DDD may seem complex, but adhering to these guidelines helps build clear, manageable systems.

For a more in-depth study, you can refer to the repository:
https://github.com/psfpro/billie
https://github.com/psfpro/billie/pull/1

--

--