In Practice: Implementing the Order System — An Example That Cuts Across All Layers
The overall picture of the implementation
In this chapter, we integrate what we have learned so far and implement the order system.
Because this chapter is an example that assembles existing classes, the following classes are not defined here; we use what the chapters that own them define.
ConfirmOrderUseCase/ConfirmOrderCommand— Chapter 11OrderPolicy— Chapter 12 (theRoleenum and theUsermodel's$castscome from Chapter 18)OrderRepositoryInterface/OrderModel/OrderLineModel/RepositoryServiceProvider— Chapter 13- The exception classes (
InvalidOrderStateException/OrderAlreadyConfirmedException) — Chapter 16 - The value objects (
OrderId/OrderLine/Money, etc.) and the domain events (HasDomainEvents/OrderConfirmed, etc.) — Chapters 5, 6, and 9
This chapter's implementation and test fences are written in the order path comment → namespace → use. Put a class you bring in from another chapter at the location its path comment shows — or, for chapters that have no path comments, the location in "Directory structure" below. PSR-4 derives the namespace from that path.
[Features to implement]
1. Create an order (POST /orders)
2. Confirm an order (POST /orders/{id}/confirm)
[Related aggregates]
- Order aggregate (Order, OrderLine, ShippingAddress)
Stock checks are handled by Chapter 11's ConfirmOrderUseCase, so this chapter does not implement them
Directory structure
app/
├── Domain/
│ ├── Order/
│ │ ├── Order.php
│ │ ├── OrderId.php
│ │ ├── Event/
│ │ │ ├── OrderCancelled.php
│ │ │ ├── OrderConfirmed.php
│ │ │ └── OrderShipped.php
│ │ ├── Exception/
│ │ │ ├── InvalidOrderStateException.php
│ │ │ └── OrderAlreadyConfirmedException.php
│ │ ├── OrderLine.php
│ │ ├── OrderLineId.php
│ │ ├── OrderStatus.php
│ │ ├── ProductId.php
│ │ ├── ShippingAddress.php
│ │ └── OrderRepositoryInterface.php # defined in Chapter 13
│ ├── Shared/
│ │ ├── DomainEventDispatcherInterface.php
│ │ ├── HasDomainEvents.php
│ │ └── Money.php
│ └── User/
│ └── UserId.php
│
├── Application/
│ └── UseCase/
│ └── Order/
│ ├── CreateOrderUseCase.php
│ ├── CreateOrderCommand.php
│ ├── ConfirmOrderUseCase.php # defined in Chapter 11
│ └── ConfirmOrderCommand.php # defined in Chapter 11
│
├── Infrastructure/
│ ├── Eloquent/
│ │ ├── OrderModel.php # defined in Chapter 13
│ │ └── OrderLineModel.php # defined in Chapter 13
│ ├── Event/
│ │ └── LaravelDomainEventDispatcher.php
│ ├── Repository/
│ │ └── EloquentOrderRepository.php
│ └── Provider/
│ └── RepositoryServiceProvider.php
│
├── Policies/
│ └── OrderPolicy.php # defined in Chapter 12
│
├── Providers/
│ └── AppServiceProvider.php # boot() is shared with Chapter 9
│
└── Http/
├── Controllers/
│ └── OrderController.php
└── Requests/
└── CreateOrderRequest.php
The flow of data
Implementing each layer
The presentation layer
The presentation layer's responsibility is receiving the HTTP request and returning the response. It holds no business logic and delegates processing to the UseCase.
// app/Http/Controllers/OrderController.php
namespace App\Http\Controllers;
use App\Application\UseCase\Order\ConfirmOrderCommand;
use App\Application\UseCase\Order\ConfirmOrderUseCase;
use App\Application\UseCase\Order\CreateOrderUseCase;
use App\Http\Requests\CreateOrderRequest;
use App\Infrastructure\Eloquent\OrderModel;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Gate;
final class OrderController extends Controller
{
/**
* Constructor injection
*
* Laravel's service container automatically instantiates the UseCase and passes it in.
* This makes it easier to swap in mock objects during testing.
*/
public function __construct(
private readonly CreateOrderUseCase $createOrderUseCase,
private readonly ConfirmOrderUseCase $confirmOrderUseCase,
) {}
/**
* Create an order
*
* Controller responsibilities:
* 1. Validation (delegated to FormRequest)
* 2. Convert the HTTP request to a Command object
* 3. Run the UseCase
* 4. Convert the result to an HTTP response
*
* What the Controller "does not" do:
* - Business logic (the UseCase's responsibility)
* - Database operations (the Repository's responsibility)
* - Creating domain objects (the Domain's responsibility)
*/
public function store(CreateOrderRequest $request): JsonResponse
{
// Already validated by FormRequest
// toCommand() converts the HTTP request to a Command object
$orderId = $this->createOrderUseCase->execute($request->toCommand());
// Convert the UseCase's result to an HTTP response
// Returning the 201 Created status code indicates resource creation
return response()->json(['orderId' => $orderId->value()], 201);
}
/**
* Confirm an order
*
* For simple cases, you can create the Command directly without using a FormRequest
*/
public function confirm(int $id): JsonResponse
{
// findOrFail() obtains the target the Policy needs; the existence check belongs to the UseCase (Chapter 12)
$order = OrderModel::findOrFail($id);
// Prevent confirming someone else's order. The owner check lives in OrderPolicy::confirm() (Chapter 12)
Gate::authorize('confirm', $order);
$this->confirmOrderUseCase->execute(new ConfirmOrderCommand($id));
return response()->json(['message' => 'Order confirmed']);
}
}
// app/Http/Requests/CreateOrderRequest.php
namespace App\Http\Requests;
use App\Application\UseCase\Order\CreateOrderCommand;
use Illuminate\Foundation\Http\FormRequest;
final class CreateOrderRequest extends FormRequest
{
/**
* Validation rules
*
* What you define here is only "validity as an HTTP request."
* Business rules (e.g. stock checks, order limits) are verified in the domain layer.
*
* Separation of responsibilities:
* - FormRequest: type/format validation at the HTTP layer
* - Domain: business-rule validation
*
* Compared with Chapter 12's CreateOrderRequest, this class lacks these four things.
* - in rules(): 'shipping' => ['required', 'array'] (the existence check for the shipping object itself)
* - in rules(): max:100 on 'items.*.quantity' (the maximum quantity per item)
* - authorize() (Laravel's default is true, so the code you copy still works without it)
* - messages() (the custom error messages; without max:100 the matching message is not needed)
* The location differs from Chapter 12 too (Chapter 12 puts it under Requests/Order/), so copying both gives you two classes
*/
public function rules(): array
{
return [
// Validation of the address information
'shipping.prefecture' => ['required', 'string', 'max:255'],
'shipping.city' => ['required', 'string', 'max:255'],
'shipping.street' => ['required', 'string', 'max:255'],
// Validation of the order lines
'items' => ['required', 'array', 'min:1'], // at least one item is required
'items.*.productId' => ['required', 'integer', 'min:1'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
'items.*.unitPrice' => ['required', 'integer', 'min:0'], // assumed to be sent in yen
];
}
/**
* Convert the HTTP request to a Command object
*
* With this conversion:
* 1. The UseCase layer does not need to know HTTP details (such as $_POST)
* 2. The same UseCase can be run from the CLI or a queue
* 3. Testing becomes easy (you can create the Command object directly)
*/
public function toCommand(): CreateOrderCommand
{
return new CreateOrderCommand(
$this->input('shipping.prefecture'),
$this->input('shipping.city'),
$this->input('shipping.street'),
$this->input('items'),
// The orderer comes from the authenticated user, not the request body
$this->user()->id,
);
}
}
// app/Providers/AppServiceProvider.php
namespace App\Providers;
use App\Infrastructure\Eloquent\OrderModel;
use App\Policies\OrderPolicy;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Chapter 9's Event::listen goes in this same boot(); copying only one of them drops the other
// Laravel looks for OrderModelPolicy for OrderModel, so register the pair explicitly (Chapter 12)
Gate::policy(OrderModel::class, OrderPolicy::class);
}
}
Gate::authorize() assumes an authenticated user. Place the POST /orders/{id}/confirm route under Chapter 18's auth:api. If the request stays unauthenticated, the owner check cannot run and the endpoint always returns 403.
// Bad example: the Controller manipulates domain objects directly
final class OrderController extends Controller
{
public function store(CreateOrderRequest $request): JsonResponse
{
$order = new Order();
$order->status = 'draft';
foreach ($request->items as $item) {
$orderLine = new OrderLine();
$orderLine->product_id = $item['productId'];
$orderLine->quantity = $item['quantity'];
$order->orderLines()->save($orderLine);
}
// Business rules scattered in the Controller
if ($order->totalAmount() > 1000000) {
throw new Exception('The order total exceeds the limit');
}
return response()->json($order);
}
}
// Good example: delegate processing to the UseCase and keep the Controller thin
final class OrderController extends Controller
{
public function __construct(
private readonly CreateOrderUseCase $createOrderUseCase,
) {}
public function store(CreateOrderRequest $request): JsonResponse
{
$orderId = $this->createOrderUseCase->execute($request->toCommand());
return response()->json(['orderId' => $orderId->value()], 201);
}
}
Keep the Controller layer thin and always place business logic in the UseCase layer or below.
The application layer
The application layer's responsibility is coordinating the use case (workflow). It combines domain objects to realize a single business scenario.
// app/Application/UseCase/Order/CreateOrderCommand.php
namespace App\Application\UseCase\Order;
/**
* Order creation command
*
* Benefits of a Command object:
* 1. A data structure independent of the HTTP request
* 2. The same UseCase can be run from the CLI or a queue
* 3. Improved type safety (static analysis like PHPStan works)
*
* By defining it as an immutable object, we prevent unintended changes.
*/
final class CreateOrderCommand
{
/**
* @param string $prefecture the shipping prefecture
* @param string $city the shipping city
* @param string $street the shipping street address
* @param array<array{productId: int, quantity: int, unitPrice: int}> $items the order lines
* @param int $userId the orderer; the source of orders.user_id (Chapter 13)
*/
public function __construct(
public readonly string $prefecture,
public readonly string $city,
public readonly string $street,
public readonly array $items,
public readonly int $userId,
) {}
}
// app/Application/UseCase/Order/CreateOrderUseCase.php
namespace App\Application\UseCase\Order;
use App\Domain\Order\Exception\InvalidOrderStateException;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderRepositoryInterface;
use App\Domain\Order\ProductId;
use App\Domain\Order\ShippingAddress;
use App\Domain\Shared\Money;
use App\Domain\User\UserId;
/**
* Order creation use case
*
* What this UseCase does:
* 1. Issue a new order ID
* 2. Turn the shipping address into a value object
* 3. Create the order entity
* 4. Add each item as an order line
* 5. Persist the order
*
* What this UseCase "does not" do:
* - Validate business rules (the domain layer's responsibility)
* - Operate the database directly (the infrastructure layer's responsibility)
* - Build the HTTP response (the presentation layer's responsibility)
*/
final class CreateOrderUseCase
{
/**
* Depends on the Repository interface
*
* By depending on the interface rather than the concrete class
* (EloquentOrderRepository):
* - You can swap in a mock for testing
* - It is resilient to implementation changes (Eloquent to Doctrine, etc.)
* - The domain layer does not depend on external technology
*/
public function __construct(
private readonly OrderRepositoryInterface $orderRepository
) {}
/**
* Create an order
*
* @param CreateOrderCommand $command the command passed from the HTTP layer
* @return OrderId the generated order ID
* @throws InvalidOrderStateException on a domain-rule violation
*/
public function execute(CreateOrderCommand $command): OrderId
{
// Step 1: issue a new order ID
// By delegating the ID-issuing logic to the Repository,
// you can hide the implementation, such as DB auto-increment or UUID
$orderId = $this->orderRepository->nextIdentity();
// Step 2: convert the shipping address to a value object
// Treat it as a meaningful type, not a primitive string
$shippingAddress = new ShippingAddress(
$command->prefecture,
$command->city,
$command->street
);
// Step 3: create the order entity
// Using a factory method guarantees that it is created
// in the correct initial state (DRAFT)
$order = Order::create($orderId, $shippingAddress);
// Step 4: add each item as an order line
foreach ($command->items as $item) {
// The order line ID also needs to be issued
$lineId = $this->orderRepository->nextLineIdentity();
// The addItem() method validates business rules
// e.g. "items cannot be added to a confirmed order"
$order->addItem(
$lineId,
new ProductId($item['productId']), // a value object, not a primitive
$item['quantity'],
new Money($item['unitPrice'], 'JPY'), // amounts are handled together with the currency
);
}
// Step 5: persist the order
// This is a new order, so use create(). Order does not hold its orderer, so pass it (Chapter 13)
// Transaction management happens inside create() (see Chapter 15 for details)
$this->orderRepository->create($order, new UserId($command->userId));
// Step 6: return the generated order ID
// It is converted to an HTTP response in the Controller layer
return $orderId;
}
}
The flow of data transformation when creating an order:
By using the appropriate data type at each layer, you achieve type safety and clarify business rules.
// Bad example: the UseCase depends directly on Eloquent
final class CreateOrderUseCase
{
public function execute(CreateOrderCommand $command): OrderId
{
// Manipulating the Eloquent model directly
$order = new OrderModel();
$order->status = 'draft';
$order->shipping_prefecture = $command->prefecture;
$order->save(); // depends on Eloquent
foreach ($command->items as $item) {
$orderLine = new OrderLineModel();
$orderLine->order_id = $order->id;
$orderLine->product_id = $item['productId'];
$orderLine->save(); // depends on Eloquent
}
return new OrderId($order->id);
}
}
// Good example: depend on the Repository interface
final class CreateOrderUseCase
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository
) {}
public function execute(CreateOrderCommand $command): OrderId
{
$orderId = $this->orderRepository->nextIdentity();
$shippingAddress = new ShippingAddress(
$command->prefecture,
$command->city,
$command->street
);
$order = Order::create($orderId, $shippingAddress); // use the domain entity
// Adding the order lines is the same as in the implementation above
$this->orderRepository->create($order, new UserId($command->userId)); // depend on the interface
return $orderId;
}
}
The UseCase should not know the persistence details. Handle it abstractly through the Repository interface.
The domain layer
The domain layer's responsibility is expressing and protecting business rules. It does not depend on the framework or the database, and handles only pure business logic.
// app/Domain/Order/Order.php
namespace App\Domain\Order;
use App\Domain\Order\Event\OrderCancelled;
use App\Domain\Order\Event\OrderConfirmed;
use App\Domain\Order\Event\OrderShipped;
use App\Domain\Order\Exception\InvalidOrderStateException;
use App\Domain\Order\Exception\OrderAlreadyConfirmedException;
use App\Domain\Shared\HasDomainEvents;
use App\Domain\Shared\Money;
use DateTimeImmutable;
/**
* The root of the order aggregate
*
* An aggregate is a "boundary within which consistency must be guaranteed."
* The Order aggregate guarantees the consistency of the order itself and all its order lines (OrderLine).
*
* Aggregate rules:
* 1. Do not let code outside the aggregate access its internal entities (OrderLine) directly
* 2. All operations go through the root entity (Order)
* 3. Always uphold the invariants
*/
final class Order
{
// Recording and pulling domain events is delegated to the trait (Chapter 9)
use HasDomainEvents;
/** @var OrderLine[] the collection of order lines */
private array $orderLines;
/**
* Make the constructor private so it cannot be newed directly from outside
*
* Why make it private?
* - To prevent objects from being created in an invalid state
* - By forcing the use of factory methods (create, reconstruct),
* correct initialization is enforced
*/
private function __construct(
private readonly OrderId $id, // the identifier is immutable (cannot be changed thanks to readonly)
private OrderStatus $status, // the status is changeable
private readonly ShippingAddress $shippingAddress, // the shipping address is immutable
array $orderLines,
private readonly DateTimeImmutable $createdAt, // the creation time is immutable
private int $version = 1, // the version used for optimistic locking (Chapter 15)
) {
$this->orderLines = $orderLines;
}
/**
* Factory method that creates a new order
*
* Invariants at creation:
* - The status is always DRAFT
* - The order lines are an empty array
*
* @param OrderId $id the order ID (already issued via the Repository)
* @param ShippingAddress $shippingAddress the shipping address
* @return self the order entity
*/
public static function create(OrderId $id, ShippingAddress $shippingAddress): self
{
// Always start with the DRAFT status
// This guarantees the rule "an order right after creation is always in draft state"
return new self($id, OrderStatus::DRAFT, $shippingAddress, [], new DateTimeImmutable());
}
/**
* Factory method that restores from the DB
*
* The difference from create():
* - create(): new creation as business logic (always DRAFT)
* - reconstruct(): restoration from persisted data (any status)
*
* Intended to be called only from the Repository layer.
* Omitting the creation time and version falls back to the restore time and 1, so the Repository passes the DB values.
*/
public static function reconstruct(
OrderId $id,
OrderStatus $status,
ShippingAddress $shippingAddress,
array $orderLines,
?DateTimeImmutable $createdAt = null,
int $version = 1
): self {
return new self($id, $status, $shippingAddress, $orderLines, $createdAt ?? new DateTimeImmutable(), $version);
}
/**
* Add an item to the order
*
* Business rules:
* 1. Items can only be added while in draft state
* 2. Cannot add after confirmation or cancellation
*
* By validating business rules inside this method,
* we prevent an invalid state.
*
* @throws InvalidOrderStateException if called outside the draft state
*/
public function addItem(
OrderLineId $lineId,
ProductId $productId,
int $quantity,
Money $unitPrice
): void {
// Validate the business rule
// By doing this validation inside the entity,
// we prevent the rule from leaking outside (such as into the UseCase)
if (!$this->status->isDraft()) {
throw new InvalidOrderStateException('Items can only be added while in draft state');
}
// Add the order line
// OrderLine is treated as a value object (immutable)
$this->orderLines[] = new OrderLine($lineId, $productId, $quantity, $unitPrice);
}
/**
* Confirm the order
*
* Business rules:
* 1. Can only be confirmed from the draft state
* 2. At least one order line is required
*
* State transitions happen only inside the entity;
* the status cannot be changed directly from outside.
*
* @throws OrderAlreadyConfirmedException if the order is already confirmed
* @throws InvalidOrderStateException on any other business-rule violation
*/
public function confirm(): void
{
// An order that is already confirmed is a conflict, treated separately (mapped to 409 in Chapter 16)
if ($this->status->isConfirmed()) {
throw new OrderAlreadyConfirmedException($this->id);
}
// Rule 1: check whether the state allows confirmation
if (!$this->status->canBeConfirmed()) {
throw new InvalidOrderStateException('This order cannot be confirmed');
}
// Rule 2: check that the order lines are not empty
if (empty($this->orderLines)) {
throw new InvalidOrderStateException('Cannot confirm an order with no line items');
}
// Once all rules pass, change the status
$this->status = OrderStatus::CONFIRMED;
// Record what happened as an event; the repository dispatches it (Chapter 9)
$this->recordEvent(new OrderConfirmed(
$this->id,
array_map(fn (OrderLine $line) => $line->id(), $this->orderLines),
$this->totalAmount(),
new DateTimeImmutable(),
));
}
/**
* Cancel the order
*
* Business rules:
* - A shipped order cannot be cancelled, etc.
*
* @param string $reason the cancellation reason; becomes the OrderCancelled payload (Chapter 9)
*/
public function cancel(string $reason): void
{
if (!$this->status->canBeCancelled()) {
throw new InvalidOrderStateException('This order cannot be cancelled');
}
$this->status = OrderStatus::CANCELLED;
// The reason is a required field of the event, so it arrives as an argument (Chapter 9)
$this->recordEvent(new OrderCancelled($this->id, $reason, new DateTimeImmutable()));
}
/**
* Ship the order
*
* Business rules:
* - Only a confirmed order can be shipped
*
* @param string $trackingNumber the tracking number; becomes the OrderShipped payload (Chapter 9)
*/
public function ship(string $trackingNumber): void
{
if (!$this->status->canBeShipped()) {
throw new InvalidOrderStateException('This order cannot be shipped');
}
$this->status = OrderStatus::SHIPPED;
$this->recordEvent(new OrderShipped($this->id, $trackingNumber, new DateTimeImmutable()));
}
/**
* Calculate the total amount
*
* By keeping the calculation logic inside the domain entity,
* you can consolidate the business rule (how the total is calculated) in one place.
*
* @return Money the order's total amount
*/
public function totalAmount(): Money
{
$total = new Money(0, 'JPY');
foreach ($this->orderLines as $line) {
// Add Money objects together
// Treating them as value objects prevents currency mismatches
$total = $total->add($line->subtotal());
}
return $total;
}
// Getters
// Using readonly guarantees they cannot be changed from outside
public function id(): OrderId { return $this->id; }
public function status(): OrderStatus { return $this->status; }
public function shippingAddress(): ShippingAddress { return $this->shippingAddress; }
public function createdAt(): DateTimeImmutable { return $this->createdAt; }
// For optimistic locking. Matched against the version column in the DB (Chapter 15)
public function version(): int { return $this->version; }
public function incrementVersion(): void { $this->version++; }
/**
* Get the order lines
*
* Returning the array as-is risks it being modified from outside,
* so ideally you would return a copy or wrap it in a ReadOnlyCollection.
* For simplicity, this implementation returns the array as-is.
*/
public function orderLines(): array { return $this->orderLines; }
}
-
Make the constructor private
- Allow creation only via factory methods (create, reconstruct)
- Prevent an invalid initial state
-
Keep business rules inside the entity
- Rules like "items cannot be added to a confirmed order" are validated inside addItem()
- Do not write rules outside the entity (such as in the UseCase)
-
Always uphold invariants
- No matter what operation is performed, the entity always stays in a valid state
- e.g. "an order with no line items cannot be confirmed"
-
Keep calculation logic inside the entity too
- A calculation like totalAmount() is part of the domain knowledge
- Calculating it in the UseCase layer scatters the logic
-
Leverage value objects
- Use meaningful types such as Money, OrderStatus, and ProductId
- Avoid the direct use of primitive types (int, string)
// Bad example: there is a public setter
final class Order
{
private OrderStatus $status;
public function setStatus(OrderStatus $status): void
{
$this->status = $status; // can be changed while ignoring business rules
}
}
// Usage: business rules can be ignored
$order->setStatus(OrderStatus::CONFIRMED); // can be confirmed even with no order lines
// Good example: expose only methods that express intent
final class Order
{
private OrderStatus $status;
/** @var OrderLine[] */
private array $orderLines;
public function confirm(): void
{
// Validate the business rule inside the method
if (empty($this->orderLines)) {
throw new InvalidOrderStateException('Cannot confirm an order with no line items');
}
$this->status = OrderStatus::CONFIRMED;
}
}
// Usage: the business rule is always upheld
$order->confirm(); // an exception is thrown if there are no order lines
Exposing setters makes it possible to change state while bypassing business rules. Expose only methods that express intent (confirm, cancel, etc.).
The infrastructure layer
The infrastructure layer's responsibility is hiding the persistence details. It implements the Repository interface defined in the domain layer and handles the conversion between domain entities and Eloquent models.
// app/Infrastructure/Repository/EloquentOrderRepository.php
namespace App\Infrastructure\Repository;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderLine;
use App\Domain\Order\OrderLineId;
use App\Domain\Order\OrderRepositoryInterface;
use App\Domain\Order\OrderStatus;
use App\Domain\Order\ProductId;
use App\Domain\Order\ShippingAddress;
use App\Domain\Shared\DomainEventDispatcherInterface;
use App\Domain\Shared\Money;
use App\Domain\User\UserId;
use App\Infrastructure\Eloquent\OrderModel;
use Illuminate\Support\Facades\DB;
/**
* An implementation of OrderRepository using Eloquent
*
* What this layer does:
* 1. Convert between domain entities and Eloquent models
* 2. Manage transactions
* 3. Database-specific operations (eager loading, etc.)
*
* What this layer "does not" do:
* - Validate business rules (the domain layer's responsibility)
* - Control the workflow (the application layer's responsibility)
*/
final class EloquentOrderRepository implements OrderRepositoryInterface
{
/**
* Inject the dispatcher
*
* The domain events recorded by the entity are dispatched here after the commit (Chapter 9).
* The binding between the interface and the implementation lives in Chapter 13's RepositoryServiceProvider.
*/
public function __construct(
private readonly DomainEventDispatcherInterface $eventDispatcher,
) {}
/**
* Find an order by ID
*
* @param OrderId $id the order ID
* @return Order|null the found order (null if it does not exist)
*/
public function findById(OrderId $id): ?Order
{
// with() for eager loading → avoids the N+1 problem (see Chapter 13 for details)
// Retrieve the order (orders) and the order lines (order_lines) at once
$model = OrderModel::with('orderLines')->find($id->value());
// If the Eloquent model exists, convert it to a domain entity; otherwise null
return $model ? $this->toEntity($model) : null;
}
/**
* Create a new order
*
* Order does not hold its orderer (Chapter 18), so the owner is passed in from the UseCase.
* The differences from save() are that it does not use findOrNew() and that it assigns user_id.
*/
public function create(Order $order, UserId $userId): void
{
DB::transaction(function () use ($order, $userId) {
$orderModel = new OrderModel();
// Neither the identifier nor the owner is in $fillable; assign them directly
$orderModel->id = $order->id()->value();
$orderModel->user_id = $userId->value();
$orderModel->fill([
'status' => $order->status()->value,
'total_amount' => $order->totalAmount()->amount(),
// This is a new row, so write the initial version (1) as-is (Chapter 13)
'version' => $order->version(),
'shipping_prefecture' => $order->shippingAddress()->prefecture(),
'shipping_city' => $order->shippingAddress()->city(),
'shipping_street' => $order->shippingAddress()->street(),
])->save();
$this->saveOrderLines($orderModel, $order->orderLines());
});
// Pull the recorded domain events and dispatch them after the commit (Chapter 9)
$events = $order->pullDomainEvents();
DB::afterCommit(fn () => $this->eventDispatcher->dispatchAll($events));
}
/**
* Save (update) an order
*
* This method's responsibilities:
* 1. Convert the domain entity to Eloquent models
* 2. Save the order itself and the order lines within a transaction
* 3. Properly handle deleted order lines as well
*
* Points about transaction management:
* - Since this saves a single aggregate, manage the transaction inside the Repository
* - The order and its lines are saved together (consistency guarantee)
* - If it fails midway, roll everything back
*/
public function save(Order $order): void
{
// Why wrap it in a transaction:
// to keep the order itself (orders) and the order lines (order_lines) consistent
DB::transaction(function () use ($order) {
// Step 1: save the order itself
// findOrNew(): fetch it if it exists, otherwise a new instance
$orderModel = OrderModel::findOrNew($order->id()->value());
// The identifier is already assigned in the domain; it is not in $fillable
$orderModel->id = $order->id()->value();
// Data to save: map the domain entity's state to columns
$orderModel->fill([
'status' => $order->status()->value, // Enum → string
'total_amount' => $order->totalAmount()->amount(),
// version is not written here; conflict detection is handled by the optimistic lock in Chapter 15
'shipping_prefecture' => $order->shippingAddress()->prefecture(),
'shipping_city' => $order->shippingAddress()->city(),
'shipping_street' => $order->shippingAddress()->street(),
])->save();
// Step 2: save the order lines (including deletions)
$this->saveOrderLines($orderModel, $order->orderLines());
});
// If the UseCase has opened an outer DB::transaction(), the transaction above is only a
// SAVEPOINT rather than a real commit, so DB::afterCommit() waits for the outermost commit (Chapter 9)
$events = $order->pullDomainEvents();
DB::afterCommit(fn () => $this->eventDispatcher->dispatchAll($events));
}
/**
* Generate the next order ID
*
* Encapsulates the ID-issuing strategy:
* - Currently the "max value + 1" approach (a simplified version for learning in this book)
* - Swapping in a positive-integer scheme such as Snowflake stays inside this method
* - Changing the ID type also changes OrderId (Chapter 5)
*
* ⚠️ For production, see "On issuing IDs in production" below
*/
public function nextIdentity(): OrderId
{
$maxId = OrderModel::max('id') ?? 0;
return new OrderId($maxId + 1);
}
/**
* Generate the next order line ID
*
* Order lines also have independent IDs, so they need to be issued separately
*/
public function nextLineIdentity(): OrderLineId
{
$maxId = DB::table('order_lines')->max('id') ?? 0;
return new OrderLineId($maxId + 1);
}
/**
* Convert from an Eloquent model to a domain entity
*
* With this conversion:
* - The domain layer does not need to know about Eloquent
* - Changes to the table structure do not affect the domain layer
* - Business logic and data structure can be separated
*
* @param OrderModel $model the Eloquent model
* @return Order the domain entity
*/
private function toEntity(OrderModel $model): Order
{
// Convert the collection of order lines
// Eloquent Collection → array of domain entities
$orderLines = $model->orderLines->map(fn($line) => new OrderLine(
new OrderLineId($line->id), // primitive → value object
new ProductId($line->product_id),
$line->quantity,
new Money($line->unit_price, 'JPY'), // amount → Money object
))->toArray();
// Use reconstruct() to restore from the DB
// Why use reconstruct() instead of create():
// - Data read from the DB can have any status
// - create() assumes "always DRAFT," so it is inappropriate
return Order::reconstruct(
new OrderId($model->id),
// string → Enum. Throws a ValueError on an invalid value to fail fast (see Chapter 13)
OrderStatus::from($model->status),
new ShippingAddress(
$model->shipping_prefecture,
$model->shipping_city,
$model->shipping_street
),
$orderLines,
// Pass the value from the DB. Omitting it would use the restore time instead (Chapter 13)
$model->created_at->toImmutable(),
// Pass the optimistic-lock version from the DB too (Chapter 15). Omitting it
// restarts from 1, so the second update onward is judged as a conflict
$model->version,
);
}
/**
* Save the order lines (including additions, updates, and deletions)
*
* Why this processing is complex:
* - It must handle not just additions but also deleted lines
* - e.g. if you delete one item while editing an order, it should be deleted from the DB too
*
* Algorithm:
* 1. Get the list of order line IDs the current entity holds
* 2. Delete lines that exist in the DB but not in the entity
* 3. Save all the lines the entity holds (findOrNew + fill)
*
* @param OrderModel $orderModel the parent order
* @param array $orderLines the array of order lines to save
*/
private function saveOrderLines(OrderModel $orderModel, array $orderLines): void
{
// The list of currently valid order line IDs
$currentLineIds = array_map(fn($line) => $line->id()->value(), $orderLines);
// Delete the removed order lines from the DB
// e.g. if there were 3 before editing and 2 after, remove the 1 that was deleted
$orderModel->orderLines()->whereNotIn('id', $currentLineIds)->delete();
// Save all order lines (create or update)
foreach ($orderLines as $line) {
// findOrNew() through a HasMany relation sets the foreign key (order_id) automatically
$lineModel = $orderModel->orderLines()->findOrNew($line->id()->value());
// The identifier is not in $fillable; assign it directly (Chapter 13)
$lineModel->id = $line->id()->value();
// Data to save: domain entity → table columns
$lineModel->fill([
'product_id' => $line->productId()->value(),
'quantity' => $line->quantity(),
'unit_price' => $line->unitPrice()->amount(), // Money → int
])->save();
}
}
}
This book's nextIdentity() / nextLineIdentity() use the MAX(id) + 1 approach, but this can cause ID collisions when multiple processes issue IDs at the same time. In production, we recommend one of the following.
- DB Auto Increment: the DB issues the ID on
INSERT(Laravel's$table->id()is sufficient). The ID is only settled after saving, so this reshapes the very flow of this chapter — "issue the ID, then create the entity" - UUID v7: a UUID that can be sorted chronologically (
Str::uuid7()is available from Laravel 11.17 onward). It is a string, soOrderId(Chapter 5), which only accepts anint, changes as well - Snowflake ID: a unique ID for distributed systems. It is a 63-bit positive integer, so it fits
OrderId'sint
In other words, only Snowflake ID keeps the swap inside nextIdentity(). Changing the ID type changes OrderId, and changing when the ID is issued changes the UseCase's flow.
This book uses it as an example to teach the design point that "the ID-issuing method is hidden in the repository." In real operation, swap it out for a thread-safe ID-issuing method.
-
Hide the conversion logic
- Make conversion methods like toEntity() / toModel() private
- Do not leak the table structure to the domain layer
-
Optimize performance with eager loading
- Avoid the N+1 problem with
with('orderLines') - The domain layer does not need to worry about performance optimization
- Avoid the N+1 problem with
-
Put transactions in the right place
- Saving a single aggregate: transaction inside the Repository
- Saving multiple aggregates: transaction inside the UseCase (see Chapter 15)
-
Encapsulate the ID-issuing strategy
- Hide the issuing method in nextIdentity()
- Swapping in a positive-integer scheme such as Snowflake stays inside this method (changing the ID type also changes OrderId)
-
create() for new orders, save() for updates
- Creation takes the orderer (user_id) as an argument, so the paths are separate
- Both use findOrNew + fill to keep the $fillable protection
// Bad example: returning an array
// OrderRepositoryInterface's contract returns ?Order, so this shape cannot satisfy it
final class EloquentOrderRepository
{
public function findById(OrderId $id): ?array
{
$model = OrderModel::with('orderLines')->find($id->value());
return $model ? $model->toArray() : null;
}
}
// Problems:
// 1. The return value is an array, so it is not type-safe
// 2. Business logic (such as confirm()) cannot be used
// 3. It has no behavior as a domain model
// Good example: return the domain entity
// Only findById() is excerpted; the other methods are as in the implementation above
final class EloquentOrderRepository
{
public function findById(OrderId $id): ?Order
{
$model = OrderModel::with('orderLines')->find($id->value());
return $model ? $this->toEntity($model) : null;
}
}
// Benefits:
// 1. Type-safe (the Order type is guaranteed)
// 2. Business logic is usable ($order->confirm(), etc.)
// 3. It can be treated as a domain model
Always make the Repository return a domain entity.
Test examples
Tests for each layer have different purposes and approaches.
Domain-layer tests (no DB, fast)
Tests for domain logic can run without a database. This lets you write fast, reliable tests.
// tests/Unit/Domain/Order/OrderPracticeTest.php
namespace Tests\Unit\Domain\Order;
use App\Domain\Order\Exception\InvalidOrderStateException;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderLineId;
use App\Domain\Order\ProductId;
use App\Domain\Order\ShippingAddress;
use App\Domain\Shared\Money;
use Tests\TestCase;
// A separate class and file from Chapter 17's OrderTest
final class OrderPracticeTest extends TestCase
{
/**
* Business-rule test: items cannot be added to a confirmed order
*
* The purpose of this test:
* - Verify that the domain entity correctly protects the business rule
* - Verify that an invalid state transition is prevented
*
* Why not use a DB:
* - Testing business logic does not require persistence
* - Test execution is fast
* - The test is stable (does not depend on DB state)
*/
public function test_cannot_add_item_to_confirmed_order(): void
{
// Arrange: prepare the test data
$order = Order::create(
new OrderId(1),
new ShippingAddress('Tokyo', 'Shibuya', '1-1-1')
);
$order->addItem(new OrderLineId(1), new ProductId(1), 2, new Money(1000, 'JPY'));
$order->confirm(); // confirm the order
// Act & Assert: verify that adding an item after confirmation throws an exception
$this->expectException(InvalidOrderStateException::class);
$this->expectExceptionMessage('Items can only be added while in draft state');
$order->addItem(new OrderLineId(2), new ProductId(2), 1, new Money(500, 'JPY'));
}
/**
* Business-rule test: an order with no line items cannot be confirmed
*/
public function test_cannot_confirm_order_with_empty_lines(): void
{
// Arrange: create an order (do not add items)
$order = Order::create(
new OrderId(1),
new ShippingAddress('Tokyo', 'Shibuya', '1-1-1')
);
// Act & Assert: trying to confirm with no items throws an exception
$this->expectException(InvalidOrderStateException::class);
$this->expectExceptionMessage('Cannot confirm an order with no line items');
$order->confirm();
}
/**
* Calculation-logic test: the total amount is calculated correctly
*/
public function test_total_amount_is_calculated_correctly(): void
{
// Arrange
$order = Order::create(
new OrderId(1),
new ShippingAddress('Tokyo', 'Shibuya', '1-1-1')
);
$order->addItem(new OrderLineId(1), new ProductId(1), 2, new Money(1000, 'JPY')); // 2,000 yen
$order->addItem(new OrderLineId(2), new ProductId(2), 3, new Money(500, 'JPY')); // 1,500 yen
// Act
$totalAmount = $order->totalAmount();
// Assert: total of 3,500 yen
$this->assertEquals(3500, $totalAmount->amount());
}
}
UseCase-layer tests (leveraging mocks)
In UseCase tests, you mock the Repository to verify the correctness of the workflow.
// tests/Unit/Application/UseCase/Order/CreateOrderUseCasePracticeTest.php
namespace Tests\Unit\Application\UseCase\Order;
use App\Application\UseCase\Order\CreateOrderCommand;
use App\Application\UseCase\Order\CreateOrderUseCase;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderLineId;
use App\Domain\Order\OrderRepositoryInterface;
use App\Domain\Order\OrderStatus;
use App\Domain\User\UserId;
use Tests\TestCase;
// A separate class and file from Chapter 17's CreateOrderUseCaseTest
final class CreateOrderUseCasePracticeTest extends TestCase
{
public function test_order_is_created_correctly(): void
{
// Arrange: create the Repository mock
$orderRepository = $this->createMock(OrderRepositoryInterface::class);
// When nextIdentity() is called, return OrderId(1)
$orderRepository->expects($this->once())
->method('nextIdentity')
->willReturn(new OrderId(1));
// nextLineIdentity() is called twice (two items)
$orderRepository->expects($this->exactly(2))
->method('nextLineIdentity')
->willReturnOnConsecutiveCalls(
new OrderLineId(1),
new OrderLineId(2)
);
// The UseCase calls create($order, $userId), not save().
// Write the expectation down to the second argument
$orderRepository->expects($this->once())
->method('create')
->with($this->callback(function (Order $order) {
// A callback constraint may be invoked more than once per match,
// so return a boolean instead of asserting
return $order->status() === OrderStatus::DRAFT
&& count($order->orderLines()) === 2;
}), new UserId(7));
// Create the UseCase
$useCase = new CreateOrderUseCase($orderRepository);
// Act: run the command
$command = new CreateOrderCommand(
prefecture: 'Tokyo',
city: 'Shibuya',
street: '1-1-1',
items: [
['productId' => 1, 'quantity' => 2, 'unitPrice' => 1000],
['productId' => 2, 'quantity' => 1, 'unitPrice' => 500],
],
userId: 7,
);
$orderId = $useCase->execute($command);
// Assert: verify the returned order ID
$this->assertEquals(1, $orderId->value());
}
}
Repository-layer tests (integration tests using a DB)
The Repository layer is tested against a real DB to verify its integration with the database.
// tests/Integration/Infrastructure/Repository/EloquentOrderRepositoryPracticeTest.php
namespace Tests\Integration\Infrastructure\Repository;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderLineId;
use App\Domain\Order\OrderStatus;
use App\Domain\Order\ProductId;
use App\Domain\Order\ShippingAddress;
use App\Domain\Shared\Money;
use App\Domain\User\UserId;
use App\Infrastructure\Event\LaravelDomainEventDispatcher;
use App\Infrastructure\Repository\EloquentOrderRepository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
// Putting this in the same file as Chapter 17's EloquentOrderRepositoryTest would collide
// on the class name, so this chapter's tests live in a separate class and file
final class EloquentOrderRepositoryPracticeTest extends TestCase
{
use RefreshDatabase; // reset the DB for each test
public function test_can_save_and_retrieve_order(): void
{
// Arrange
$repository = new EloquentOrderRepository(new LaravelDomainEventDispatcher());
$order = Order::create(
new OrderId(1),
new ShippingAddress('Tokyo', 'Shibuya', '1-1-1')
);
$order->addItem(
new OrderLineId(1),
new ProductId(100),
2,
new Money(1000, 'JPY')
);
// Act: save (a new order, so use create)
$repository->create($order, new UserId(7));
// Assert: retrieve and verify
$fetchedOrder = $repository->findById(new OrderId(1));
$this->assertNotNull($fetchedOrder);
$this->assertEquals(OrderStatus::DRAFT, $fetchedOrder->status());
$this->assertCount(1, $fetchedOrder->orderLines());
}
public function test_order_line_deletion_is_reflected(): void
{
// Arrange: save an order with 2 items
$repository = new EloquentOrderRepository(new LaravelDomainEventDispatcher());
$order = Order::create(new OrderId(1), new ShippingAddress('Tokyo', 'Shibuya', '1-1-1'));
$order->addItem(new OrderLineId(1), new ProductId(100), 2, new Money(1000, 'JPY'));
$order->addItem(new OrderLineId(2), new ProductId(200), 1, new Money(500, 'JPY'));
$repository->create($order, new UserId(7));
// Act: re-fetch the order and delete one item
$fetchedOrder = $repository->findById(new OrderId(1));
$remainingLine = $fetchedOrder->orderLines()[0]; // keep only the first one
$modifiedOrder = Order::reconstruct(
$fetchedOrder->id(),
$fetchedOrder->status(),
$fetchedOrder->shippingAddress(),
[$remainingLine], // keep only one
// Pass the creation time and the version read from the DB too. Omitting them
// would use the restore time and reset the version to 1 (Chapter 13)
$fetchedOrder->createdAt(),
$fetchedOrder->version(),
);
$repository->save($modifiedOrder);
// Assert: verify that there is now one item
$refetchedOrder = $repository->findById(new OrderId(1));
$this->assertCount(1, $refetchedOrder->orderLines());
}
}
| Test target | Uses DB | Mocks | Test purpose |
|---|---|---|---|
| Domain layer | No | No | The correctness of business rules |
| UseCase layer | No | Yes | The correctness of the workflow |
| Repository layer | Yes | No | The correctness of persistence |
| Controller layer | Yes | Yes (partly) | The correctness of the HTTP response |
The principles of the test pyramid:
- Write the most domain-layer tests (fast and stable)
- Write a moderate number of UseCase-layer tests
- Keep Repository-layer tests to the necessary minimum (slow and unstable)
See Chapter 17 "Testing Strategy" for details.
Summary
In this chapter, we took a detailed look at each layer's responsibilities and the data flow through the implementation of the order system.
Key points:
-
Clearly separate each layer's responsibilities
- Controller: only HTTP request/response conversion
- UseCase: only coordinating the workflow
- Domain: only expressing and protecting business rules
- Repository: only hiding the persistence details
-
Understand the flow of data transformation
- HTTP request → Command → value object → domain entity → Eloquent model
-
Consolidate business rules in the domain layer
- Validate rules inside the entity's methods
- Do not allow invalid state changes from outside
-
Write tests according to each layer's responsibilities
- Test the domain layer fast without a DB
- Test the Repository layer against a real DB
What to read next
In the next chapter, we will present the book's conclusion and resources for deepening your learning further.
- Chapter 20 "Conclusion and Next Steps" — the roadmap for gradual adoption, and books and resources to read next
- Chapter 6 "Entities" — the implementation this chapter's
Orderis based on: factory methods and invariants - Chapter 13 "The Repository Pattern" — the implementation this chapter's
EloquentOrderRepositoryis based on: transactions and event dispatch - Chapter 17 "Testing Strategy" — how to split tests per layer, and the implementation this chapter's tests are based on