What you'll learn
Version database structure and keep persistence choices behind a clean application contract. Migrations explain how schemas evolve; repositories and mappers translate between storage rows and domain objects.
By the end of this lesson, you'll be able to:
- Write reversible, deployable migrations
- Define a repository around domain needs
- Map rows without leaking PDO into business logic
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 |
|---|---|---|
| Migration | Repeatable schema history | Make each change small, ordered, and safe for deployed data. |
| Repository | Collection-like domain access | Expose use-case queries, not generic table operations. |
| Data mapper | Domain/storage separation | Translate explicitly when shapes or lifecycles differ. |
Professional workflow
Build the feature in small, verifiable steps. Each step leaves the system in a state you can test.
- Describe the persistence boundary 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
Make a staged schema change
Add the nullable column first, backfill separately, then enforce NOT NULL in a later release.
ALTER TABLE courses ADD COLUMN slug VARCHAR(190) NULL;
CREATE UNIQUE INDEX courses_slug_unique ON courses (slug);
-- Backfill in bounded batches before a later migration runs:
-- ALTER TABLE courses ALTER COLUMN slug SET NOT NULL;Map persistence explicitly
The repository owns SQL and mapping; callers receive domain objects.
<?php
final readonly class PdoCourseRepository implements CourseRepository
{
public function __construct(private PDO $db) {}
public function get(int $id): Course
{
$statement = $this->db->prepare('SELECT id, title, status FROM courses WHERE id = :id');
$statement->execute(['id' => $id]);
$row = $statement->fetch();
if (!$row) throw new CourseNotFound($id);
return Course::restore((int) $row['id'], $row['title'], CourseStatus::from($row['status']));
}
}Production practice
Contract
Repositories speak domain language and never return half-initialized objects or raw database errors.
Verification
Run migrations from empty and from the previous production schema; contract-test repository implementations.
Operations
Plan large backfills separately, monitor locks, and make rollback implications explicit.
Common failure mode
Independent workshop
Add publish scheduling to an existing course database without downtime and expose it through a repository.
Your finished workshop must include:
- Multi-step forward migration plan
- Mapper handling old and new rows during rollout
- Repository contract and integration tests
Definition of done
Recap & quick check
Key takeaways
- Migrations are executable schema history.
- Safe rollout may require several releases.
- Repositories model domain access.
- Mappers isolate storage representation.
Quick check
1. Why stage a large required-column change?
2. What should a repository return?
3. What must migration tests cover?
Keep the workshop: later phases deliberately build on these boundaries, so today's small example can become part of your portfolio architecture.