Repository
The Repository pattern hides the data store behind a collection-like interface. Callers ask "give me the user with this id" instead of "SELECT * FROM users WHERE id = ?". The win: business code is testable with an in-memory repo, the storage layer can swap (Postgres -> Mongo -> Elastic) without touching the rest, and queries get named in one place.
Repository for a domain entity + tests
EXAMPLE
<?php
// ============================================================
// Domain entity — owns business rules, knows NOTHING about storage
// ============================================================
final class Order {
public function __construct(
public string $id,
public string $customer,
public int $totalCents,
public string $status = 'new',
public ?DateTimeImmutable $paidAt = null,
) {}
public function pay(DateTimeImmutable $at): void {
if ($this->status !== 'new') throw new DomainException('already past new');
$this->status = 'paid';
$this->paidAt = $at;
}
}
// ============================================================
// Repository interface — the collection-like contract
// ============================================================
interface OrderRepository {
public function findById(string $id): ?Order;
public function findOpenForCustomer(string $customerId): array; // Order[]
public function save(Order $order): void;
}
// ============================================================
// Production implementation — talks to PDO
// ============================================================
final class PdoOrderRepository implements OrderRepository {
public function __construct(private PDO $pdo) {}
public function findById(string $id): ?Order {
$st = $this->pdo->prepare('SELECT * FROM orders WHERE id = :id');
$st->execute(['id' => $id]);
$row = $st->fetch(PDO::FETCH_ASSOC);
return $row ? $this->hydrate($row) : null;
}
public function findOpenForCustomer(string $customerId): array {
$st = $this->pdo->prepare(
'SELECT * FROM orders WHERE customer_id = :c AND status IN (\'new\', \'paid\') ORDER BY created_at DESC'
);
$st->execute(['c' => $customerId]);
return array_map([$this, 'hydrate'], $st->fetchAll(PDO::FETCH_ASSOC));
}
public function save(Order $order): void {
$this->pdo->prepare(
'INSERT INTO orders (id, customer_id, total_cents, status, paid_at)
VALUES (:id, :c, :t, :s, :p)
ON DUPLICATE KEY UPDATE status = VALUES(status), paid_at = VALUES(paid_at)'
)->execute([
'id' => $order->id, 'c' => $order->customer,
't' => $order->totalCents, 's' => $order->status,
'p' => $order->paidAt?->format('Y-m-d H:i:s'),
]);
}
private function hydrate(array $row): Order {
return new Order(
id: $row['id'],
customer: $row['customer_id'],
totalCents: (int) $row['total_cents'],
status: $row['status'],
paidAt: $row['paid_at'] ? new DateTimeImmutable($row['paid_at']) : null,
);
}
}
// ============================================================
// In-memory implementation — used in tests
// ============================================================
final class InMemoryOrderRepository implements OrderRepository {
/** @var array<string, Order> */
private array $store = [];
public function findById(string $id): ?Order { return $this->store[$id] ?? null; }
public function findOpenForCustomer(string $customerId): array {
return array_values(array_filter($this->store, fn(Order $o) =>
$o->customer === $customerId && in_array($o->status, ['new','paid'])));
}
public function save(Order $order): void { $this->store[$order->id] = $order; }
}
// ============================================================
// Service layer — uses the repository through the interface only
// ============================================================
final class PayOrder {
public function __construct(private OrderRepository $repo, private Clock $clock) {}
public function __invoke(string $orderId): Order {
$o = $this->repo->findById($orderId)
?? throw new RuntimeException('order not found');
$o->pay($this->clock->now());
$this->repo->save($o);
return $o;
}
}
// ============================================================
// Test — no DB required, runs in 1ms
// ============================================================
$repo = new InMemoryOrderRepository();
$repo->save(new Order('o1', 'alice', 4995));
$svc = new PayOrder($repo, new FixedClock(new DateTimeImmutable('2026-06-18 09:00:00')));
$res = $svc('o1');
assert($res->status === 'paid');
interface Clock { public function now(): DateTimeImmutable; }
final class FixedClock implements Clock {
public function __construct(private DateTimeImmutable $now) {}
public function now(): DateTimeImmutable { return $this->now; }
}
Why it matters
Keep repository methods as named, intention-revealing queries: `findOpenForCustomer`, not `findBy(["status" => ["new","paid"]])`. Generic "find by" methods leak SQL-ish thinking into your domain code and make every consumer responsible for understanding the storage layer; named methods read as business sentences and constrain the data layer to one place.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// Hides the data source behind a clean domain API.
class UserRepo {
constructor(db) { this.db = db; }
async byEmail(email) { return await this.db('users').where({ email }).first(); }
async create(u) { return await this.db('users').insert(u).returning('*'); }
}
Try it Yourself »
Discussion
Loading…