Accepted
In our booking domain, we need to represent several concepts:
- Space: "padel", "pool", "gym"
- Date: "2025-10-15"
- Time Slot: 9, 10, 11, ..., 20
- Status: "FREE", "RESERVED"
We need to decide how to model these in our domain layer.
- Type safety: Prevent invalid values at compile/runtime
- Testability: Each concept should be independently testable
- Domain clarity: Code should read like business language
- Encapsulation: Validation logic should live with the data
- Immutability: Values shouldn't change after creation
class Booking
{
private string $space; // ❌ Any string is valid
private string $date; // ❌ "invalid" would be accepted
private int $timeSlot; // ❌ 99 would be accepted
private string $status; // ❌ "WHATEVER" would be accepted
}
// Usage problems:
$booking = new Booking('invalid-space', 'bad-date', 99, 'WRONG'); // ❌ Compiles fine!Problems:
- No validation at domain level
- Validation scattered across controllers, services
- Tests must validate the same thing in multiple places
- Easy to make mistakes: Mix up parameter order
- No encapsulation: Validation logic is duplicated
class BookingController
{
public function createBooking(Request $request)
{
$space = $request->get('space');
if (!in_array($space, ['padel', 'pool', 'gym'])) {
throw new BadRequestException();
}
// More validation...
}
}Pros:
- ✅ Less code initially
- ✅ Familiar pattern
Cons:
- ❌ Validation only at entry point (controller)
- ❌ Domain objects can be in invalid state
- ❌ Must duplicate validation in tests
- ❌ Not following Domain-Driven Design
enum Space: string
{
case PADEL = 'padel';
case POOL = 'pool';
case GYM = 'gym';
}Pros:
- ✅ Type-safe
- ✅ Built-in to PHP
- ✅ IDE autocomplete
Cons:
- ❌ Cannot add behavior or methods
- ❌ Cannot customize validation logic
- ❌ Limited for complex validations (like date format)
- ❌ Not suitable for
TimeSlot(range validation)
final class Space
{
private const VALID_SPACES = ['padel', 'pool', 'gym'];
private string $value;
public function __construct(string $value)
{
$value = trim($value);
if (!in_array($value, self::VALID_SPACES, true)) {
throw new \InvalidArgumentException(
sprintf('Invalid space. Allowed: %s', implode(', ', self::VALID_SPACES))
);
}
$this->value = $value;
}
public function value(): string
{
return $this->value;
}
public function equals(Space $other): bool
{
return $this->value === $other->value();
}
}Pros:
- ✅ Fail fast: Invalid values cannot be created
- ✅ Type safety: Cannot pass wrong type
- ✅ Encapsulation: Validation lives with the data
- ✅ Testable: Each VO has its own test suite
- ✅ Immutable: Once created, value cannot change
- ✅ Self-documenting: Clear what values are valid
- ✅ DDD best practice: Recommended by Eric Evans
Cons:
- ❌ More files to create
- ❌ More boilerplate code
Use Value Objects for all domain concepts
Create immutable Value Objects for:
Space- Enum-like with validationTimeSlot- Range validation (9-20)BookingDate- Date format validationBookingStatus- Factory methods for states
final class Space
{
private const VALID_SPACES = ['padel', 'pool', 'gym'];
public function __construct(string $value)
{
$value = trim($value); // Normalize
if (!in_array($value, self::VALID_SPACES, true)) {
throw new \InvalidArgumentException(/*...*/);
}
$this->value = $value;
}
}Why:
- Closed set of valid values
- String-based (matches API contract)
- Easy to serialize/deserialize
final class TimeSlot
{
private const MIN_HOUR = 9;
private const MAX_HOUR = 20;
public function __construct(int $hour)
{
if ($hour < self::MIN_HOUR || $hour > self::MAX_HOUR) {
throw new \InvalidArgumentException(/*...*/);
}
$this->hour = $hour;
}
public function format(): string
{
return sprintf('%02d:00', $this->hour); // Business logic in VO
}
}Why:
- Business rule: "Operating hours are 9:00-20:00"
- Validation ensures invariant
- Formatting logic encapsulated
final class BookingDate
{
public function __construct(string $value)
{
$date = \DateTime::createFromFormat('Y-m-d', $value);
if (!$date || $date->format('Y-m-d') !== $value) {
throw new \InvalidArgumentException('Invalid date format. Expected Y-m-d');
}
$this->value = $value;
}
}Why:
- Enforces consistent date format across system
- Prevents "2025-13-40" style errors
- Format validation is complex, belongs in VO
final class BookingStatus
{
private const FREE = 'FREE';
private const RESERVED = 'RESERVED';
private function __construct(string $value) // Private!
{
$this->value = $value;
}
public static function free(): self
{
return new self(self::FREE);
}
public static function reserved(): self
{
return new self(self::RESERVED);
}
public function isFree(): bool
{
return $this->value === self::FREE;
}
}Why:
- Factory methods prevent invalid states
- Expressive:
BookingStatus::free()vsnew BookingStatus('FREE') - Behavior methods:
isFree(),isReserved()
// Test is trivial to write
public function it_throws_exception_for_invalid_space(): void
{
$this->expectException(\InvalidArgumentException::class);
new Space('invalid');
}Result: 18 value object tests, all independent
$booking = new Booking(
new Space('padel'), // ✅ Guaranteed valid
new BookingDate('2025-10-15'), // ✅ Guaranteed valid format
new TimeSlot(14) // ✅ Guaranteed in range 9-20
);
// This CANNOT compile:
$booking = new Booking('invalid', 'bad-date', 99); // ❌ Type error!$timeSlot = new TimeSlot(14);
echo $timeSlot->format(); // "14:00"
// Formatting logic is not in controller or view// Before (primitives):
public function findBookings(string $space, string $date): array // ❌ What format?
// After (value objects):
public function findBookings(Space $space, BookingDate $date): array // ✅ Crystal clearIf we want to change allowed spaces:
// Only one place to change:
final class Space
{
private const VALID_SPACES = ['padel', 'pool', 'gym', 'tennis']; // ✅
}
// All tests validate this automatically
// All code using Space gets new validation for freeBefore: 0 files (just primitives)
After: 4 files (Space, TimeSlot, BookingDate, BookingStatus)
Worth it? YES
- Each file is < 50 lines
- Each file has single responsibility
- Each file is independently testable
// Before:
$booking = new Booking('padel', '2025-10-15', 14);
// After:
$booking = new Booking(
new Space('padel'),
new BookingDate('2025-10-15'),
new TimeSlot(14)
);Worth it? YES
- Prevents bugs at compile time
- Self-documenting
- Validates immediately
- ✅ 18 value object tests covering all validation rules
- ✅ Type safety: Compiler catches mistakes
- ✅ Fail fast: Invalid values cannot be created
- ✅ Single Responsibility: Validation logic lives in one place
- ✅ Testability: Each VO is independently tested
- ✅ Domain clarity: Code reads like business rules
- ❌ More files to create initially (~1 hour)
- ❌ More verbose object construction
- ❌ Learning curve for junior developers
- Value Objects are immutable (this is correct by design)
- Must unwrap values for persistence (
$space->value())
-
Type safety ✅
function book(Space $space) {} // Cannot pass string
-
Testability ✅
tests/Unit/Domain/ValueObject/ # 18 tests -
Domain clarity ✅
new Space('padel') # Clear what this represents
-
Encapsulation ✅
Space::__construct() # Validation here, nowhere else
-
Immutability ✅
final class Space # Cannot extend or modify
- Write tests first: TDD naturally leads to Value Objects
- Start simple: Don't add behavior until needed
- Be consistent: All domain concepts should be VOs, not some
- Fail fast: Validate in constructor, not later
- Immutability is key: No setters, ever
- Eric Evans: "Domain-Driven Design" (Chapter on Value Objects)
- Martin Fowler: "Value Object" pattern
- Vaughn Vernon: "Implementing Domain-Driven Design"
- PHP The Right Way: https://phptherightway.com/