# 0003 — Repositories for writes, query objects for complex reads

**Status:** Accepted · 2026-08-01

## Context

"Repository pattern" in Laravel usually degrades into one of two failures. Either
every model gets a repository that only forwards to `Model::find()` — pure
ceremony — or the repository grows a `search(array $filters)` method that is a
query builder wearing a disguise, and the boundary stops meaning anything.

The pressure comes from list screens. A patient index has a dozen optional
filters, dynamic sorting, joins and computed columns, served to DataTables.

## Decision

A deliberate CQRS-lite split.

```
writes  → Service → Repository → Model
reads   → Service or Controller → QueryObject → Builder
```

**Repositories** exist on aggregate roots only, and for three stated reasons: a
test seam, a stable cross-module contract, and a place to hang a caching or
read-replica decorator later. `BaseRepository` exposes no `where()`, no
`orderBy()`, no builder passthrough. Concrete repositories add domain-meaningful
finders (`findActiveByPhone`, `countRegisteredSince`).

**Query objects** (`App\Foundation\Query\QueryObject`) own the complicated reads,
with an allow-list of sortable columns because sort columns arrive from the
request and must never reach SQL unchecked.

Repositories return models and collections. We do not pretend Eloquent is
swappable for another ORM; that abstraction costs far more than it could return.

## Consequences

**Good.** The write path stays testable and the read path stays fast. Neither
corrupts the other. The rule "no builder passthrough" is concrete enough to
enforce in review.

**Bad.** Two places to look for "how do I get patients". Mitigated by the naming
convention and by §4 of the module authoring guide.

**Known limitation.** Larastan cannot propagate the repository's `TModel`
template through `Model::query()`, which is annotated `Builder<static>`. Two
errors in `BaseRepository` are ignored in `phpstan.neon`, scoped by identifier,
path and count, with the reasoning recorded there. The alternative — widening the
return types to plain `Model` — would delete real type safety from every
repository to satisfy a tool.
