objects namespaces and application architecture
Domain-Driven Design Orientation
Domain-driven design is an approach to software design that puts the business domain at the centre of the code. The goal is to model important business concepts clearly, using language the team and domain experts recognise.
DDD is not a requirement for every PHP project. It is most useful when the business rules are complex enough that an anemic pile of arrays, controllers, and database rows becomes hard to reason about.
As a junior developer, you do not need to master every DDD book pattern immediately. You do need to understand the vocabulary you will see in mature codebases: ubiquitous language, entities, value objects, aggregates, repositories, domain services, and bounded contexts.
Ubiquitous Language
Ubiquitous language means the code uses the same important words as the business.
If the business says a learner "enrolls" on a course, code named Enrollment and enroll() is clearer than UserCourseLink and createRelation(). If the business says an invoice can be "voided", code should not call the same operation deleteInvoice() unless it really deletes it.
Good names reduce translation mistakes.
<?php
declare(strict_types=1);
final class Enrollment
{
public function __construct(
public int $userId,
public int $courseId,
public bool $active = true,
) {
}
public function withdraw(): void
{
$this->active = false;
}
}
$enrollment = new Enrollment(userId: 10, courseId: 55);
$enrollment->withdraw();
echo $enrollment->active ? 'active' : 'withdrawn';
echo PHP_EOL;
// Prints:
// withdrawn
The class name and method name should match the domain, not just database mechanics.
Entities And Value Objects
An entity has identity. Two users with the same email address are still separate users if they have different IDs. An order remains the same order as its status changes.
A value object is defined by its value. Two Money objects with 1000 and GBP mean the same amount.
<?php
declare(strict_types=1);
final readonly class Money
{
public function __construct(
public int $amountPence,
public string $currency,
) {
if ($amountPence < 0) {
throw new InvalidArgumentException('Amount cannot be negative.');
}
}
public function add(self $other): self
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Currencies must match.');
}
return new self($this->amountPence + $other->amountPence, $this->currency);
}
}
$total = (new Money(1200, 'GBP'))->add(new Money(800, 'GBP'));
echo $total->amountPence . ' ' . $total->currency . PHP_EOL;
// Prints:
// 2000 GBP
Value objects are useful because they protect rules at the edge of a concept. Instead of passing loose integers and strings everywhere, the code passes a meaningful type.
Aggregates
An aggregate is a cluster of domain objects that should be changed together through one root object. The aggregate root protects the rules for the cluster.
For example, an Order may be the aggregate root for its order lines. Code outside the aggregate should not freely edit line totals and order status in separate places.
<?php
declare(strict_types=1);
final readonly class OrderLine
{
public function __construct(
public string $sku,
public int $quantity,
public int $unitPricePence,
) {
if ($quantity <= 0) {
throw new InvalidArgumentException('Quantity must be positive.');
}
}
public function totalPence(): int
{
return $this->quantity * $this->unitPricePence;
}
}
final class Order
{
/** @var list<OrderLine> */
private array $lines = [];
public function addLine(string $sku, int $quantity, int $unitPricePence): void
{
$this->lines[] = new OrderLine($sku, $quantity, $unitPricePence);
}
public function totalPence(): int
{
$total = 0;
foreach ($this->lines as $line) {
$total += $line->totalPence();
}
return $total;
}
}
$order = new Order();
$order->addLine('BOOK', 2, 1500);
$order->addLine('PEN', 1, 250);
echo $order->totalPence() . PHP_EOL;
// Prints:
// 3250
The order controls how lines are added and how totals are calculated. That is more expressive than building nested arrays in a controller.
Repositories In DDD
Repositories load and save aggregates or important domain objects. They should present methods that match domain needs, while infrastructure classes handle database details.
<?php
declare(strict_types=1);
interface OrderRepository
{
public function findById(int $id): ?Order;
public function save(Order $order): void;
}
The interface belongs near the domain or application layer because it describes what the domain needs. The concrete implementation belongs in infrastructure because it knows about SQL, ORM models, or external storage.
Bounded Contexts
A bounded context is a boundary where a model and its language are consistent.
The word "course" might mean different things in different parts of a system. In a sales context, a course may be a product with a price. In a learning context, a course may be lessons, progress, assessments, and completion rules. Forcing one class to represent every meaning can create confusion.
Bounded contexts let each part of the business model its concepts accurately.
Sales context:
CourseProduct
Price
Discount
Learning context:
Course
Lesson
Enrollment
Progress
In a smaller app, contexts may be folders or namespaces. In a larger organisation, they may become modules or separate services.
Domain Services
Some rules do not naturally belong to one entity or value object. A domain service can hold those rules.
For example, a rule comparing two different subscriptions or calculating eligibility across several objects may be clearer as a service. Keep domain services focused and named after the business rule, not Helper.
Avoid DDD Theatre
DDD can be overused. Creating Entity, ValueObject, Aggregate, Factory, Repository, Specification, and DomainService folders for a simple settings page is not good design.
A useful DDD model should make business rules easier to understand. If the code becomes harder to change because every simple field needs five classes, the design has missed the point.
Start with the language and the rules. Add structure where it protects something real.
What You Should Be Able To Do
After this lesson, you should be able to identify entities, value objects, aggregates, repositories, and bounded contexts in a PHP codebase. You should be able to explain why Order::addLine() can be better than editing an order-lines array directly, and why domain names should match business language.
For junior work, the practical skill is to make business rules visible in code instead of hiding them in controllers, arrays, and scattered conditionals.
Practice
Practice: Model A Small Order Aggregate
Create a small PHP model for an order with order lines.
Task
Build:
- an
OrderLinevalue object - an
Orderaggregate root - a method for adding lines
- a method for calculating the order total
The model should reject a line with a zero or negative quantity. Keep the rule inside the domain model, not in the calling script.
Use strict types. Keep the expected output in the PHP code block as printed lines or comments.
Check Your Work
Run cases for:
- adding two valid lines and printing the total
- trying to add a line with an invalid quantity
Afterward, explain which class is the aggregate root and which class is the value object.
Show solution
This solution keeps the quantity rule inside OrderLine and makes Order responsible for managing its lines.
<?php
declare(strict_types=1);
final readonly class OrderLine
{
public function __construct(
public string $sku,
public int $quantity,
public int $unitPricePence,
) {
if ($sku === '') {
throw new InvalidArgumentException('SKU cannot be empty.');
}
if ($quantity <= 0) {
throw new InvalidArgumentException('Quantity must be positive.');
}
if ($unitPricePence < 0) {
throw new InvalidArgumentException('Unit price cannot be negative.');
}
}
public function totalPence(): int
{
return $this->quantity * $this->unitPricePence;
}
}
final class Order
{
/** @var list<OrderLine> */
private array $lines = [];
public function addLine(string $sku, int $quantity, int $unitPricePence): void
{
$this->lines[] = new OrderLine($sku, $quantity, $unitPricePence);
}
public function totalPence(): int
{
$total = 0;
foreach ($this->lines as $line) {
$total += $line->totalPence();
}
return $total;
}
}
$order = new Order();
$order->addLine('BOOK', 2, 1500);
$order->addLine('PEN', 1, 250);
echo $order->totalPence() . PHP_EOL;
try {
$order->addLine('BAD', 0, 999);
} catch (InvalidArgumentException $exception) {
echo $exception->getMessage() . PHP_EOL;
}
// Prints:
// 3250
// Quantity must be positive.
Order is the aggregate root because outside code works through it to add lines and calculate totals. OrderLine is a value object because it represents a line's values and protects the rule that quantity must be positive.