Domain Layer
The Domain layer contains pure business logic with no dependencies on CodeIgniter or any framework. This page explains each component with detailed examples.
Table of Contents
- Why a Separate Domain Layer?
- Domain Generator (Spark Command)
- Entities (Step-by-Step)
- Value Objects (Step-by-Step)
- PHP Enums (Step-by-Step)
- Repository Interfaces
- Domain Services
- Policies
- Domain Exceptions
- Docker Verification
1. Why a Separate Domain Layer?
The Problem
In typical CI4 apps, business logic is mixed with framework code:
// Model with mixed concerns
class UserModel extends Model
{
protected $table = 'users';
public function registerUser($data)
{
// Validation - business rule
if (strlen($data['password']) < 8) {
throw new Exception('Password too short');
}
// Email normalization - business rule
$data['email'] = strtolower($data['email']);
// Hash password - security logic
$data['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
// Database insert - infrastructure
return $this->insert($data);
}
}Problems:
- Can't test without database
- Validation rules duplicated across models
- Tied to CI4 Model class
The Solution
Separate pure business logic from framework code:
// Domain Entity - pure PHP, no framework
class User
{
private Email $email; // Value object validates email
private string $passwordHash;
public function __construct(Email $email, string $password)
{
PasswordPolicy::validate($password); // Business rule
$this->email = $email;
$this->passwordHash = password_hash($password, PASSWORD_DEFAULT);
}
}
// Infrastructure Repository - handles database
class UserRepository implements UserRepositoryInterface
{
public function save(User $user): void
{
$this->db->table('users')->insert([...]);
}
}Simple Analogy
Think of Domain like a rulebook for a game:
| Concept | Game Analogy | Code |
|---|---|---|
| Entity | Game pieces (king, knight) | User, Order, Product |
| Value Object | Board squares (A1, B2) | Email, Money, Address |
| Policy | Rules (how knight moves) | PasswordPolicy |
| Repository | Game box (stores pieces) | UserRepository |
The rulebook doesn't care if you're playing on a wooden board or a computer—it just defines the rules.
2. Domain Generator (Spark Command)
php spark make:domain to generate a complete domain structure instantly!
Usage
# Generate full domain structure
docker exec ci4-php php spark make:domain Product
# Generate only Entity file
docker exec ci4-php php spark make:domain Invoice --entity-only
# Force overwrite existing files
docker exec ci4-php php spark make:domain Product --forceGenerated Structure
The command creates a complete DDD domain with the following structure:
app/Domain/Product/
├── Entities/
│ └── Product.php # Entity with properties and methods
├── Contracts/
│ └── ProductRepositoryInterface.php # Repository interface
├── Repositories/
│ └── ProductRepository.php # Database implementation
├── Services/
│ └── ProductService.php # Domain service
├── UseCases/
│ └── CreateProduct.php # Use case implementation
├── Exceptions/
│ └── ProductException.php # Domain exceptions
└── Enums/
└── ProductStatus.php # Status enumGenerated Files Overview
| File | Purpose |
|---|---|
Entity | Core domain object with business methods |
RepositoryInterface | Contract defining data access operations |
Repository | Database implementation of the interface |
Service | Business logic orchestration |
UseCase | Single-purpose action (CQRS pattern) |
Exception | Domain-specific error handling |
Enum | Type-safe status/state values |
Example Output
Creating domain: Product
Created: Domain/Product/Entities/
Created: Domain/Product/Contracts/
Created: Domain/Product/Services/
Created: Domain/Product/UseCases/
Created: Domain/Product/Exceptions/
Created: Domain/Product/Enums/
Created: Domain/Product/Repositories/
Created: Entities/Product.php
Created: Contracts/ProductRepositoryInterface.php
Created: Repositories/ProductRepository.php
Created: Services/ProductService.php
Created: Exceptions/ProductException.php
Created: Enums/ProductStatus.php
Created: UseCases/CreateProduct.php
Domain 'Product' created successfully!
Location: app/Domain/Product/3. Entities (Step-by-Step)
Technical Definition
An Entity is a core business object with a unique identity. Two entities with the same data but different IDs are different.
Is it the same as a Database Table?
No. A table is for storage (foreign keys, efficiency). An Entity is for behavior (business rules). Sometimes they map 1:1, but often an Entity is richer than a table row.
Step 1: Create the Entity File
Where to put it:app/Domain/{Context}/Entities/. It is a single PHP file.
File: app/Domain/User/Entities/User.php
<?php
namespace App\Domain\User\Entities;
use App\Domain\Shared\ValueObjects\Email;
class User
{
private int $id;
private string $name;
private Email $email;
private string $passwordHash;
private array $roles = [];
private \DateTimeImmutable $createdAt;
public function __construct(
string $name,
Email $email,
string $passwordHash
) {
$this->name = $name;
$this->email = $email;
$this->passwordHash = $passwordHash;
$this->createdAt = new \DateTimeImmutable();
}
}Step 2: Add Getters
// --- Getters (read access) ---
public function getId(): int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function getEmail(): Email
{
return $this->email;
}
public function getPasswordHash(): string
{
return $this->passwordHash;
}
public function getRoles(): array
{
return $this->roles;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}Step 3: Add Business Methods
// --- Business Logic (not just setters!) ---
/**
* Assign a role to this user
*/
public function assignRole(string $role): void
{
if (!in_array($role, $this->roles, true)) {
$this->roles[] = $role;
}
}
/**
* Check if user has a specific role
*/
public function hasRole(string $role): bool
{
return in_array($role, $this->roles, true);
}
/**
* Remove a role from this user
*/
public function removeRole(string $role): void
{
$this->roles = array_values(
array_filter($this->roles, fn($r) => $r !== $role)
);
}
/**
* Change user's password
*/
public function changePassword(string $newPasswordHash): void
{
$this->passwordHash = $newPasswordHash;
}
/**
* Verify a password against stored hash
*/
public function verifyPassword(string $password): bool
{
return password_verify($password, $this->passwordHash);
}Usage Example
// Create a new user
$email = new Email('john@example.com');
$user = new User('John Doe', $email, password_hash('secret123', PASSWORD_DEFAULT));
// Use business methods
$user->assignRole('member');
$user->assignRole('admin');
if ($user->hasRole('admin')) {
echo "User is an admin!";
}
// Verify password
if ($user->verifyPassword('secret123')) {
echo "Password correct!";
}3. Value Objects (Step-by-Step)
Technical Definition
A Value Object is an immutable object defined by its value, not identity. Two value objects with the same value are equal.
Step 1: Create Email Value Object
File: app/Domain/Shared/ValueObjects/Email.php
<?php
namespace App\Domain\Shared\ValueObjects;
class Email
{
private string $value;
public function __construct(string $email)
{
// Normalize: lowercase and trim
$email = strtolower(trim($email));
// Validate format
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException(
"Invalid email format: {$email}"
);
}
$this->value = $email;
}
public function getValue(): string
{
return $this->value;
}
public function getDomain(): string
{
return substr($this->value, strpos($this->value, '@') + 1);
}
public function equals(Email $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}Step 2: Usage Examples
// Valid email - works
$email = new Email('John.DOE@Example.COM');
echo $email->getValue(); // "john.doe@example.com" (normalized!)
echo $email->getDomain(); // "example.com"
// Invalid email - throws exception
try {
$email = new Email('not-an-email');
} catch (\InvalidArgumentException $e) {
echo $e->getMessage(); // "Invalid email format: not-an-email"
}
// Comparing emails
$email1 = new Email('user@example.com');
$email2 = new Email('USER@EXAMPLE.COM');
var_dump($email1->equals($email2)); // true (both normalized to same value)Step 3: Create Money Value Object
File: app/Domain/Shared/ValueObjects/Money.php
<?php
namespace App\Domain\Shared\ValueObjects;
class Money
{
private int $cents; // Store as cents to avoid floating point issues
private string $currency;
public function __construct(int $cents, string $currency = 'USD')
{
if ($cents < 0) {
throw new \InvalidArgumentException('Money cannot be negative');
}
$this->cents = $cents;
$this->currency = strtoupper($currency);
}
public static function fromDollars(float $dollars, string $currency = 'USD'): self
{
return new self((int) round($dollars * 100), $currency);
}
public function getCents(): int
{
return $this->cents;
}
public function getDollars(): float
{
return $this->cents / 100;
}
public function getCurrency(): string
{
return $this->currency;
}
public function add(Money $other): Money
{
$this->ensureSameCurrency($other);
return new Money($this->cents + $other->cents, $this->currency);
}
public function format(): string
{
$symbol = match($this->currency) {
'USD' => '$',
'EUR' => '€',
'GBP' => '£',
default => $this->currency . ' ',
};
return $symbol . number_format($this->getDollars(), 2);
}
private function ensureSameCurrency(Money $other): void
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException(
"Cannot combine {$this->currency} with {$other->currency}"
);
}
}
}Money Usage Example
// Create money
$price = Money::fromDollars(19.99);
$tax = Money::fromDollars(1.60);
// Add them
$total = $price->add($tax);
echo $total->format(); // "$21.59"
echo $total->getCents(); // 21594. Repository Interfaces
Technical Definition
A Repository Interface is a contract that defines data access operations. Domain defines WHAT is needed; Infrastructure implements HOW.
3.1 More Value Object Examples (Top 5)
Value Objects clarify code intent significantly. Here are 5 common examples:
| Value Object | Solves Problem | Example Value |
|---|---|---|
Distance | Unit confusion (Miles vs Km) | new Distance(100, 'km') |
Temperature | Scale confusion (F vs C) | new Temperature(37, 'C') |
Coordinates | Lat/Lon pairing validation | new Coordinates(40.7128, -74.0060) |
Password | Hashing logic/Complexity rules | Password::fromRaw('secret123') |
DateRange | Start > End validation | new DateRange($start, $end) |
// Without Value Objects
function calculateShipping(float $dist, string $unit) { ... }
calculateShipping(100, 'miles'); // Easy to pass wrong string
// With Value Objects
function calculateShipping(Distance $dist) { ... }
calculateShipping(Distance::fromMiles(100)); // Type-safe and clear!Create the Interface
File: app/Domain/User/Repositories/UserRepositoryInterface.php
<?php
namespace App\Domain\User\Repositories;
use App\Domain\User\Entities\User;
use App\Domain\Shared\ValueObjects\Email;
interface UserRepositoryInterface
{
/**
* Find user by ID
*/
public function findById(int $id): ?User;
/**
* Find user by email
*/
public function findByEmail(Email $email): ?User;
/**
* Get all users with pagination
*/
public function findAll(int $limit = 20, int $offset = 0): array;
/**
* Save a new user
*/
public function save(User $user): void;
/**
* Update an existing user
*/
public function update(User $user): void;
/**
* Delete a user
*/
public function delete(int $id): void;
/**
* Check if email already exists
*/
public function emailExists(Email $email): bool;
/**
* Count total users
*/
public function countAll(): int;
}Why Interfaces?
// Domain Service uses INTERFACE (doesn't know about MySQL)
class UserService
{
public function __construct(
private UserRepositoryInterface $repo // Interface, not concrete class
) {}
public function findUser(int $id): ?User
{
return $this->repo->findById($id);
}
}
// In production: use real MySQL repository
$service = new UserService(new MySQLUserRepository());
// In tests: use mock repository
$mockRepo = new InMemoryUserRepository();
$service = new UserService($mockRepo);3.5. PHP Enums (Step-by-Step)
Technical Definition
PHP 8.1 Enums (Enumerations) are a type-safe way to define a set of named values. They replace magic strings and constants.
Enums vs Value Objects
| Feature | Enum | Value Object |
|---|---|---|
| Use Case | Fixed set of values (roles, statuses) | Values that need validation (email, money) |
| Validation | Built-in (only defined cases allowed) | Custom validation in constructor |
| Equality | Case comparison (===) | equals() method |
| Example | UserRole::ADMIN | new Email('user@example.com') |
Step 1: Create UserRole Enum
File: app/Domain/User/Enums/UserRole.php
<?php
namespace App\Domain\User\Enums;
enum UserRole: string
{
case ADMIN = 'admin';
case MEMBER = 'member';
case GUEST = 'guest';
public function label(): string
{
return match($this) {
self::ADMIN => 'Administrator',
self::MEMBER => 'Member',
self::GUEST => 'Guest',
};
}
public function isAdmin(): bool
{
return $this === self::ADMIN;
}
}Step 2: Usage in Entity
<?php
namespace App\Domain\User\Entities;
use App\Domain\User\Enums\UserRole;
class User
{
public function __construct(
private int $id,
private string $email,
private UserRole $role // Type-safe! Only valid UserRole values
) {}
public function getRole(): UserRole
{
return $this->role;
}
public function isAdmin(): bool
{
return $this->role->isAdmin();
}
public function changeRole(UserRole $newRole): void
{
$this->role = $newRole;
}
}Step 3: Practical Examples
// Creating users with type-safe roles
$admin = new User(1, 'admin@example.com', UserRole::ADMIN);
$member = new User(2, 'user@example.com', UserRole::MEMBER);
// Type safety prevents invalid values
$user = new User(3, 'test@example.com', 'superuser'); // ERROR! Type error
// Checking roles
if ($user->getRole() === UserRole::ADMIN) {
echo "User is an admin";
}
// Using helper methods
if ($user->getRole()->isAdmin()) {
echo "Admin access granted";
}
// Getting label for display
echo $user->getRole()->label(); // "Administrator"Step 4: OrderStatus Enum (With State Transitions)
File: app/Domain/Order/Enums/OrderStatus.php
<?php
namespace App\Domain\Order\Enums;
enum OrderStatus: string
{
case PENDING = 'pending';
case PROCESSING = 'processing';
case SHIPPED = 'shipped';
case DELIVERED = 'delivered';
case CANCELLED = 'cancelled';
public function canTransitionTo(self $newStatus): bool
{
return match($this) {
self::PENDING => in_array($newStatus, [
self::PROCESSING,
self::CANCELLED,
]),
self::PROCESSING => in_array($newStatus, [
self::SHIPPED,
self::CANCELLED,
]),
self::SHIPPED => $newStatus === self::DELIVERED,
default => false,
};
}
public function color(): string
{
return match($this) {
self::PENDING => 'yellow',
self::PROCESSING => 'blue',
self::SHIPPED => 'purple',
self::DELIVERED => 'green',
self::CANCELLED => 'red',
};
}
}Usage with State Validation
class Order
{
public function __construct(
private int $id,
private OrderStatus $status = OrderStatus::PENDING
) {}
public function updateStatus(OrderStatus $newStatus): void
{
if (!$this->status->canTransitionTo($newStatus)) {
throw new \DomainException(
"Cannot transition from {$this->status->value} to {$newStatus->value}"
);
}
$this->status = $newStatus;
}
}
// Valid transition
$order = new Order(1);
$order->updateStatus(OrderStatus::PROCESSING); // Works!
// Invalid transition
$order->updateStatus(OrderStatus::DELIVERED); // DomainException!Step 5: PaymentMethod Enum (Backed with Business Logic)
File: app/Domain/Payment/Enums/PaymentMethod.php
<?php
namespace App\Domain\Payment\Enums;
enum PaymentMethod: string
{
case CREDIT_CARD = 'credit_card';
case PAYPAL = 'paypal';
case BANK_TRANSFER = 'bank_transfer';
case CASH = 'cash';
public function isOnline(): bool
{
return in_array($this, [
self::CREDIT_CARD,
self::PAYPAL,
]);
}
public function processingFeePercentage(): float
{
return match($this) {
self::CREDIT_CARD => 2.9,
self::PAYPAL => 3.5,
self::BANK_TRANSFER => 0.0,
self::CASH => 0.0,
};
}
}Real-World Usage
class Payment
{
public function __construct(
private float $amount,
private PaymentMethod $method
) {}
public function getTotalWithFees(): float
{
$feePercentage = $this->method->processingFeePercentage();
return $this->amount * (1 + $feePercentage / 100);
}
}
$payment = new Payment(100.00, PaymentMethod::CREDIT_CARD);
echo $payment->getTotalWithFees(); // 102.90
$cashPayment = new Payment(100.00, PaymentMethod::CASH);
echo $cashPayment->getTotalWithFees(); // 100.00When to Use Enums
- You have a fixed, known set of values (roles, statuses, types)
- Values are immutable and won't change at runtime
- You want type safety and IDE autocomplete
- You need to avoid magic strings
- Values need custom validation (email format, positive numbers)
- Values are user-provided and dynamic
- You need derived properties (email domain, money currency)
- Values have complex equality rules
4. Repository Interfaces
Technical Definition
A Domain Service contains business logic that doesn't belong to a single Entity. It orchestrates operations.
Create UserService
File: app/Domain/User/Services/UserService.php
<?php
namespace App\Domain\User\Services;
use App\Domain\User\Entities\User;
use App\Domain\User\Repositories\UserRepositoryInterface;
use App\Domain\User\Exceptions\UserNotFoundException;
use App\Domain\User\Exceptions\UserAlreadyExistsException;
use App\Domain\User\Policies\PasswordPolicy;
use App\Domain\Shared\ValueObjects\Email;
class UserService
{
public function __construct(
private UserRepositoryInterface $userRepository
) {}
/**
* Register a new user
*/
public function register(string $name, string $emailStr, string $password): User
{
// 1. Create email value object (validates format)
$email = new Email($emailStr);
// 2. Check if email already exists
if ($this->userRepository->emailExists($email)) {
throw new UserAlreadyExistsException($emailStr);
}
// 3. Validate password against policy
PasswordPolicy::validate($password);
// 4. Create user entity
$user = new User(
$name,
$email,
password_hash($password, PASSWORD_DEFAULT)
);
// 5. Assign default role
$user->assignRole('member');
// 6. Save to repository
$this->userRepository->save($user);
return $user;
}
/**
* Find user by ID
*/
public function findById(int $id): User
{
$user = $this->userRepository->findById($id);
if ($user === null) {
throw UserNotFoundException::withId($id);
}
return $user;
}
/**
* Assign role to user
*/
public function assignRole(int $userId, string $role): void
{
$user = $this->findById($userId);
$user->assignRole($role);
$this->userRepository->update($user);
}
/**
* Check if email is available
*/
public function isEmailAvailable(string $emailStr): bool
{
$email = new Email($emailStr);
return !$this->userRepository->emailExists($email);
}
}6. Policies
Technical Definition
A Policy encapsulates complex business rules that can be reused.
File: app/Domain/User/Policies/PasswordPolicy.php
<?php
namespace App\Domain\User\Policies;
class PasswordPolicy
{
public const MIN_LENGTH = 8;
public const REQUIRE_UPPERCASE = true;
public const REQUIRE_LOWERCASE = true;
public const REQUIRE_NUMBER = true;
public const REQUIRE_SPECIAL = false;
public static function validate(string $password): void
{
$errors = [];
if (strlen($password) < self::MIN_LENGTH) {
$errors[] = 'Password must be at least ' . self::MIN_LENGTH . ' characters';
}
if (self::REQUIRE_UPPERCASE && !preg_match('/[A-Z]/', $password)) {
$errors[] = 'Password must contain at least one uppercase letter';
}
if (self::REQUIRE_LOWERCASE && !preg_match('/[a-z]/', $password)) {
$errors[] = 'Password must contain at least one lowercase letter';
}
if (self::REQUIRE_NUMBER && !preg_match('/[0-9]/', $password)) {
$errors[] = 'Password must contain at least one number';
}
if (self::REQUIRE_SPECIAL && !preg_match('/[!@#$%^&*(),.?":{}|<>]/', $password)) {
$errors[] = 'Password must contain at least one special character';
}
if (!empty($errors)) {
throw new \InvalidArgumentException(implode('. ', $errors));
}
}
}Usage
// Valid password
PasswordPolicy::validate('MySecure123'); // No exception
// Invalid password
try {
PasswordPolicy::validate('weak');
} catch (\InvalidArgumentException $e) {
echo $e->getMessage();
// "Password must be at least 8 characters. Password must contain..."
}7. Domain Exceptions
File: app/Domain/User/Exceptions/UserNotFoundException.php
<?php
namespace App\Domain\User\Exceptions;
class UserNotFoundException extends \DomainException
{
public static function withId(int $id): self
{
return new self("User with ID {$id} not found");
}
public static function withEmail(string $email): self
{
return new self("User with email {$email} not found");
}
}File: app/Domain/User/Exceptions/UserAlreadyExistsException.php
<?php
namespace App\Domain\User\Exceptions;
class UserAlreadyExistsException extends \DomainException
{
public function __construct(string $email)
{
parent::__construct("User with email {$email} already exists");
}
}8. Docker Verification
Check Domain Structure
docker-compose exec php ls -la /var/www/html/app/Domain/
# Output:
# Shared
# User
docker-compose exec php ls -la /var/www/html/app/Domain/User/
# Output:
# Entities
# Exceptions
# Policies
# Repositories
# ServicesTest Value Object
# Open PHP console
docker-compose exec php php spark tinker
# Test Email
> $email = new \App\Domain\Shared\ValueObjects\Email('TEST@EXAMPLE.COM');
> echo $email->getValue(); // "test@example.com"
# Test invalid email
> $email = new \App\Domain\Shared\ValueObjects\Email('invalid');
// Throws InvalidArgumentException