Recommended resources
The freely available resources this lesson will direct you to.
- What is a Domain Service in Domain-Driven Design?www.youtube.com
- Value Objects Practical Example - What is a Value Object? - Full PHP 8 Tutorialwww.youtube.com
- Best Practice - An Introduction To Domain-Driven Designlearn.microsoft.com
- Effective Aggregate Design Part I: Modeling a Single ...www.dddcommunity.org
Welcome back. Previously, you chose a modular monolith as a sensible starting point for the workshop application, while keeping Workshop Planning and Reservations as distinct decision-making boundaries. Now we can look inside Reservations. How should its central rule—confirmed places must not exceed the approved booking limit—be represented in code?
This lesson uses that rule to distinguish entities, value objects, and domain services. You’ll see why the admission decision belongs on an entity, why the limit can be a value object, and what would have to change before a domain service became appropriate. Allow about 40 minutes, including the video and readings.
Start with the rule, not the classes
An invariant is a condition that must remain true after every successful business operation. For a workshop, the rule is:
That immediately gives us two operations to examine. Confirming a reservation increases the number of confirmed places; changing the limit can reduce the number of places available. Either operation could break the rule, so the same decision-making boundary must govern both. Putting the check in a React button or a Laravel controller would not be enough: another caller could bypass it, and two requests could arrive at nearly the same time.
In domain-driven design, an aggregate gives such related changes a consistency boundary. Its root entity is the entry point for operations that could affect the invariant. Read the short passage below for the reasoning behind that boundary.
Effective Aggregate Design Part I: Modeling a Single ...
In Effective Aggregate Design, Part I, Vaughn Vernon explains why a true business invariant—not an attractive-looking object graph—should determine an aggregate's scope.
On printed p. 3, find “Rule: Model True Invariants In Consistency Boundaries.” Read the consistency explanation, concentrating on what must be true when a transaction commits. Then, on printed p. 4 under “Rule: Design Small Aggregates,” read the sizing guidance. Notice that an aggregate need not contain every object related to a business concept.
For our example, call the root WorkshopBookings. It has an identity—the workshop whose bookings it controls—and it owns the approved limit and the set of currently confirmed reservation identifiers. A confirmation, cancellation, or limit change goes through that root. Descriptive workshop details, such as its title, need not be loaded merely to confirm a place.
The following aggregate diagram illustrates the same boundary principle in a different domain.

The diagram does not prescribe an Order-shaped design for Reservations. It helps answer a more general question: Which facts must one root control to keep its rule true? For workshop admission, that is the approved limit together with authoritative confirmation state.
Give values and entities different responsibilities
An entity remains the same domain thing as its attributes change. A reservation identified by its reservation ID is still that reservation if it is cancelled. Likewise, WorkshopBookings remains the booking authority for a particular workshop as its approved limit changes. Identity and lifecycle matter for both.
A value object is identified by its contents instead. Two approved limits of 12 places represent the same value, even if they were constructed separately. If an approved limit changes from 12 to 10, we replace the old value; we do not need to track the history of that particular limit object. A value object can also reject an impossible value at construction, so invalid instances cannot travel through the model.
Watch the PHP example with this distinction in mind. Its shipping domain differs from ours, but the design move is the same: replace ambiguous scalars with immutable values that carry their own rules.
Value Objects Practical Example - What is a Value Object? - Full PHP 8 Tutorial
Watch “Value Objects Practical Example” by Program With Gio for a PHP implementation of value-based identity, constructor validation, and immutability.
Begin with identity versus value, where the video contrasts entities with value objects. Continue through the PHP implementation; focus on validation and why an operation returns a new value rather than modifying the original. Finally, watch the refactor, noting the warning against wrapping every scalar in a value object.
Here is a small PHP 8.2 model of the workshop rule. ApprovedBookingLimit is a value object. WorkshopBookings is an entity and aggregate root. Zero is allowed as a limit so that bookings can be closed, but an existing confirmation prevents changing the limit to zero.
final readonly class ApprovedBookingLimit
{
public function __construct(public int $places)
{
if ($places < 0) {
throw new InvalidArgumentException(
'The booking limit cannot be negative.'
);
}
}
}
final class WorkshopBookings
{
/** @var array<string, true> */
private array $confirmedReservationIds = [];
public function __construct(
public readonly string $workshopId,
private ApprovedBookingLimit $limit,
) {
if ($workshopId === '') {
throw new InvalidArgumentException('A workshop ID is required.');
}
}
public function confirm(string $reservationId): void
{
if ($reservationId === '') {
throw new InvalidArgumentException('A reservation ID is required.');
}
if (isset($this->confirmedReservationIds[$reservationId])) {
throw new DomainException('Reservation already confirmed.');
}
if (count($this->confirmedReservationIds) >= $this->limit->places) {
throw new DomainException('Workshop is fully booked.');
}
$this->confirmedReservationIds[$reservationId] = true;
}
public function cancel(string $reservationId): void
{
if (!isset($this->confirmedReservationIds[$reservationId])) {
throw new DomainException('Reservation is not confirmed.');
}
unset($this->confirmedReservationIds[$reservationId]);
}
public function changeLimit(ApprovedBookingLimit $next): void
{
if ($next->places < count($this->confirmedReservationIds)) {
throw new DomainException(
'Limit cannot fall below confirmed places.'
);
}
$this->limit = $next;
}
}
Notice where each rule lives. The value object rejects a negative limit regardless of who constructs it. The root rejects a third confirmation when the limit is two, and rejects lowering that limit to one while two confirmations remain. It also prevents cancelling a reservation it does not currently count. No external “booking validator” needs to inspect the root’s private state and then edit it.
This is a focused domain sketch, not a complete persistence design. It keeps only active reservation IDs; a real application may also keep separate reservation records and cancellation history. For a workshop with very many active reservations, loading all IDs for each admission could be costly. You would then revisit how to represent and persist the same authoritative rule rather than assuming that a large in-memory collection is required.
There is a second, more urgent limitation: two requests can each load a root that appears to have one place left. Both in-memory confirm() calls could succeed. The persistence operation must therefore protect the authoritative state—for example, by using appropriate transaction and concurrency control—and save a confirmation and its admission-state change consistently. An encapsulated domain rule does not, by itself, solve concurrent writes. Later lessons will address transaction boundaries and concurrency control in detail.
Add a domain service only when there is no natural owner
A common reaction to business logic is to create a BookingService and put every rule there. That often leaves entities as passive data holders: the service reads their fields, checks their validity, and changes them from the outside. In this example, that would obscure the answer to “Who may change the approved limit or confirm a place?” The root already has a clear answer.
A domain service is useful when an operation expresses a domain rule but naturally belongs to neither an entity nor a value object. It is not simply a class named Service, nor is it the Laravel class that receives an HTTP request, fetches records, and saves them.
Best Practice - An Introduction To Domain-Driven Design
Read the entity and service discussions in “An Introduction to Domain-Driven Design,” from MSDN Magazine, to sharpen the ownership test before adding another class.
Under “Entities Have an Identity and a Life,” read the entity discussion, focusing on the argument for placing behavior with its owner. Then find “Domain Services Model Primary Operations” and read the service discussion. Finally, read the opening of the “Application services” discussion, from application services, to distinguish coordination and client-facing work from a domain rule.
Consider a hypothetical extension to the workshop product: a booking price depends on both a workshop’s category and a buyer’s membership tier, under one pricing policy used across many workshops. If neither the workshop nor the buyer owns that policy, a domain service such as BookingPricePolicy could take those facts and return a price value. The service would contain the pricing decision. An application service could obtain the workshop and buyer facts, call the policy, and arrange persistence or an HTTP response.
That is a reasoned choice, not a rule that every calculation spanning two inputs needs a service. If the behavior is merely “add these two amounts in the same currency,” it likely belongs on a Money value object. If a workshop itself owns the complete pricing rule and all facts needed to apply it, a method on that entity may be clearer. Ask, in order: Does a value own this behavior? Does an entity own this decision? If neither does, is there a cohesive domain operation worth naming?
For the original admission invariant, the answer is still WorkshopBookings, not a domain service. Moving confirm() into a service would not make the capacity decision more correct; it would move a rule away from the state it protects.
Bringing the model forward
You now have a practical way to model a rule: identify what must stay true, decide which authoritative state must change together, and give the protecting behavior to its aggregate root. Use value objects for meaningful, self-validating values such as an approved booking limit. Introduce a domain service when a genuine business operation has no natural entity or value-object owner—not as a default home for logic.
The workshop example also exposes a useful boundary between model correctness and runtime correctness: the root can reject an invalid operation, while storage must still prevent concurrent requests from both committing an invalid outcome. Next, you’ll examine how presentation, application, domain, and infrastructure dependencies should be directed so that this model can be used from Laravel without making it depend on HTTP or persistence details.