What you'll learn
Move from procedural scripts to objects that protect business rules. You will design typed state, behavior-rich classes, and immutable value objects instead of exposing arrays that any caller can corrupt.
By the end of this lesson, you'll be able to:
- Design a class around an invariant
- Use visibility, constructor promotion, and readonly safely
- Distinguish entities from value objects
Core mental model
Professional PHP is less about memorizing APIs and more about choosing a clear boundary for each responsibility. Use this table as a decision guide while reading the examples.
| Concept | What it protects | Decision rule |
|---|---|---|
| Encapsulation | Valid state | Change private state only through named behavior. |
| Entity | Identity over time | Use identity when two otherwise equal records remain distinct. |
| Value object | Meaningful immutable values | Compare by value and reject invalid construction. |
Professional workflow
Build the feature in small, verifiable steps. Each step leaves the system in a state you can test.
- Describe the domain object boundary: its inputs, outputs, invariants, and expected failures.
- Implement the smallest happy path behind an explicit contract.
- Add validation and translate low-level failures into language the caller understands.
- Exercise the boundary with realistic data, then inspect output, logs, and resource cleanup.
- Refactor only after behavior is protected by a repeatable check.
Make the boundary visible
Guided code lab
Protect a course lifecycle
The class exposes intentions—publish and rename—while its state remains private.
<?php
declare(strict_types=1);
final class Course
{
public function __construct(
public readonly int $id,
private string $title,
private bool $published = false,
) {
$this->rename($title);
}
public function rename(string $title): void
{
$title = trim($title);
if ($title === '') {
throw new InvalidArgumentException('Title is required.');
}
$this->title = $title;
}
public function publish(): void { $this->published = true; }
public function title(): string { return $this->title; }
}Make money impossible to misuse
Integer minor units avoid floating-point surprises and readonly prevents accidental mutation.
<?php
final readonly class Money
{
public function __construct(public int $cents, public string $currency)
{
if ($cents < 0) throw new InvalidArgumentException('Negative money');
if (!preg_match('/^[A-Z]{3}$/', $currency)) throw new InvalidArgumentException('Currency');
}
public function add(self $other): self
{
if ($this->currency !== $other->currency) throw new LogicException('Currency mismatch');
return new self($this->cents + $other->cents, $this->currency);
}
}Production practice
Contract
Keep properties private by default; expose queries and commands named in the domain.
Verification
Test construction boundaries, state transitions, equality, and rejected input.
Operations
Objects should not secretly open databases or read global request state.
Common failure mode
Independent workshop
Create an Enrollment aggregate that can enroll, cancel, and report status without permitting an invalid transition.
Your finished workshop must include:
- Typed Enrollment and StudentId classes
- At least three rejected transitions
- A small CLI demonstration
Definition of done
Recap & quick check
Key takeaways
- Objects combine state with the behavior that protects it.
- Constructor validation prevents invalid objects from escaping.
- readonly is ideal for value objects.
- Entities compare by identity; values compare by content.
Quick check
1. Why keep mutable properties private?
2. Which type best represents a currency amount?
3. What does readonly prevent?
Keep the workshop: later phases deliberately build on these boundaries, so today's small example can become part of your portfolio architecture.