Repositories
A repository is a class bound to a table. The query is assembled by calling methods, values travel as bound parameters, and the result comes back as objects of your entity. Below: how to declare one, how to assemble a query, and every method taken apart — what it accepts, what it returns, and what it does when nothing was found.
What a repository is
The place where the queries of one table live. Without it SQL spreads through controllers as strings: the same selection is rewritten wherever it is needed, parameters are bound by hand, and a renamed column is discovered at runtime.
// without a repository
$stmt = $pdo->prepare('SELECT * FROM users WHERE status = :s ORDER BY id DESC LIMIT 20');
$stmt->execute(['s' => 'active']);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); // untyped arrays
// with one
$users = UserRepository::instance('u')
->where(Qb::eq('u.status', 'active'))
->orderBy('u.id DESC')
->limit(20)
->findAll(); // User[]Three things this buys beyond brevity: values always travel as bound parameters rather than concatenation; the result is hydrated into an entity, so the editor knows the fields; and a subquery, a join or a CTE takes another repository rather than a string — which brings its own bound parameters along.
Declaring one
<?php
namespace Main\Repositories;
use Flytachi\Winter\Ppa\Stereotype\Repository;
use Main\Configurations\MainDbConfig;
use Main\Entities\User;
/** @extends Repository<User> */
class UserRepository extends Repository
{
public static string $table = 'users';
protected string $entityClassName = User::class;
protected string $dbConfigClassName = MainDbConfig::class;
}| Property | Required | What it sets |
|---|---|---|
$table |
yes | the table name; public static, because it is read without an instance |
$dbConfigClassName |
yes | which database to talk to |
$entityClassName |
in practice | what rows are hydrated into |
$schema |
no | the schema, when the table is not in the default one |
`@extends` is not decoration
The line /** @extends Repository<User> */ pins the template parameter to your entity.
With it findById() returns ?User and the editor knows the fields. Without it —
object, and completion goes quiet; the code runs the same either way.
Stereotypes
| Class | What it gives | When to take it |
|---|---|---|
Repository |
assembly + reading + writing | the ordinary case |
RepositoryView |
assembly + reading | a database view, a report, a read-only table |
RepositoryCrud |
assembly + writing | a log, a queue — written to, not read |
CteRepo |
assembly | a fragment that only ever exists as a CTE or a subquery |
The choice is not about style but about what cannot be called by mistake: RepositoryView
has no delete(), and that shows in completion.
Getting an instance
instance()
public static function instance(?string $as = null): static| Argument | Type | Default | What it does |
|---|---|---|---|
$as |
?string |
null |
the table alias in the query |
Returns a new repository instance with clean query state.
UserRepository::instance(); // SELECT id, email FROM users
UserRepository::instance('u'); // SELECT u.id, u.email FROM users uAn alias is needed wherever a query has more than one table: without it the columns of a join cannot be told apart. Making a habit of it saves rewriting at the first join.
Every call is a new query
instance() returns a new object, and conditions accumulate on the object. Assembling
in one chain is not a matter of style: two instance() calls are two independent queries,
and “adding to” an instance you obtained earlier appends to whatever it already carries.
as()
public function as(string $alias): staticSets the alias on an instance you already have — the same as the argument of instance(),
but mid-assembly.
forBy()
public function forBy(string $context): staticAppends a FOR … clause — a row lock held for the transaction.
UserRepository::instance('u')->where(Qb::eq('u.id', $id))->forBy('UPDATE')->find();
// SELECT u.id, u.email FROM users u WHERE u.id = :iqb0 FOR UPDATEFor when a row is read in order to be changed immediately, and nobody else may change it in between. It works inside a transaction and not on every driver — SQLite has no such syntax.
Method reference
Assembling a query
Every method in this group returns static, so calls chain. None of them goes to the
database: the query leaves only when something is read or written.
select()
public function select(string $option): static| Argument | Type | What it does |
|---|---|---|
$option |
string |
the column list, as a string, as in SQL |
By default the entity’s columns are selected. A custom list is for aggregates, and for not dragging along what is not needed.
UserRepository::instance('u')
->select('u.id, COUNT(*) AS n')
->groupBy('u.id')
->having('COUNT(*) > 3');
// SELECT u.id, COUNT(*) AS n FROM users u GROUP BY u.id HAVING COUNT(*) > 3A custom `select()` changes the result type
An arbitrary column list may not match the entity’s shape, so hydration falls back to
stdClass. That shows in getEntityClassName(), and it means the result carries $row->n
rather than typed fields.
from()
public function from(RepositoryInterface|string $repository): staticReplaces the source: a subquery, or another name, instead of the table.
UserRepository::instance('u')->from(OrderRepository::instance('o')->select('o.user_id'));
// SELECT u.id, u.email FROM (SELECT o.user_id FROM orders o) uA repository brings its bound parameters along; a string is simply substituted.
where(), andWhere(), orWhere(), xorWhere()
public function where(?Qb $qb): static
public function andWhere(Qb $qb): static
public function orWhere(Qb $qb): static
public function xorWhere(Qb $qb): static| Method | What it does |
|---|---|
where() |
sets the condition |
andWhere() |
appends with AND |
orWhere() |
appends with OR |
xorWhere() |
appends with XOR |
Conditions are built by Qb — values inside it become bound
parameters rather than part of the query text.
UserRepository::instance('u')
->where(Qb::eq('u.id', 1))
->andWhere(Qb::gt('u.age', 0))
->orWhere(Qb::eq('u.id', 2));
// WHERE u.id = :iqb3 AND u.age > :iqb4 OR u.id = :iqb5null in where() does nothing — it is not a reset. To clear the query there is
cleanCache().
join(), joinInner(), joinLeft(), joinRight(), joinCross()
public function join(RepositoryInterface|string $repository, Qb|string $on): static| Argument | Type | What it does |
|---|---|---|
$repository |
RepositoryInterface|string |
what to join: a repository, or 'orders o' as a string |
$on |
Qb|string |
the join condition |
joinCross() takes only the first argument — a cartesian product has no condition.
UserRepository::instance('u')->joinLeft(OrderRepository::instance('o'), 'o.user_id = u.id');
// SELECT u.id, u.email FROM users u LEFT JOIN orders o ON(o.user_id = u.id)Joining a repository rather than a string pays for the same reason as in from(): it
carries its own table name, schema and bound parameters. The string form is there for when
the table has no repository at all.
with(), withRecursive()
public function with(string $name, RepositoryInterface $repository, ?string $modifier = null): static
public function withRecursive(string $name, RepositoryInterface $repository): static| Argument | Type | What it does |
|---|---|---|
$name |
string |
the CTE’s name, used to refer to it later |
$repository |
RepositoryInterface |
the query that becomes the CTE’s body |
$modifier |
?string |
a dialect hint, such as MATERIALIZED |
UserRepository::instance('u')->with('recent', OrderRepository::instance('o')->where(Qb::gt('o.total', 100)));
// WITH recent AS (SELECT o.id, o.user_id, o.total FROM orders o WHERE o.total > :iqb2)
// SELECT u.id, u.email FROM users uwithRecursive() emits WITH RECURSIVE — for walking trees and graphs.
union(), unionAll()
public function union(RepositoryInterface $repository): static
public function unionAll(RepositoryInterface $repository): staticCombines result sets. union() removes duplicates, unionAll() does not and is therefore
cheaper.
UserRepository::instance('u')->union(UserRepository::instance('u2')->where(Qb::eq('u2.id', 2)));
// SELECT u.id, u.email FROM users u UNION SELECT u2.id, u2.email FROM users u2 WHERE u2.id = :iqb1The column lists of both halves must match — that is SQL’s requirement, not the layer’s.
groupBy(), having(), orderBy()
public function groupBy(string $context): static
public function having(string $context): static
public function orderBy(string $context): staticThey take a SQL fragment as a string, verbatim.
->groupBy('u.id')->having('COUNT(*) > 3')->orderBy('u.created_at DESC, u.id DESC')These are fragments, not values
The string is substituted into the query as it is. Never build one from user input: ordering is code, and escaping does not make code safe. Map the input to a fixed set:
$sort = match ($request->query('sort')) {
'newest' => 'u.created_at DESC',
'email' => 'u.email ASC',
default => 'u.id DESC',
};limit()
public function limit(int $limit, int $offset = 0): static| Argument | Type | Default | What it does |
|---|---|---|---|
$limit |
int |
— | how many rows to return |
$offset |
int |
0 |
how many to skip |
UserRepository::instance('u')->limit(5, 10);
// … LIMIT 5 OFFSET 10For pages, pagination is easier: it works out the offset itself and returns the meta.
Reading
Every method in this group runs the query. The ?string $entityClassName in all of
them replaces the hydration class without touching the column set.
find()
public function find(?string $entityClassName = null): ?objectThe first row of the query.
Returns an entity object, or null when there are no rows. It does not throw — the
absence of a result is an answer, not a failure.
$user = UserRepository::instance('u')->where(Qb::eq('u.email', $email))->find();
if ($user === null) {
return ResponseEntity::notFound();
}findAll()
public function findAll(?string $entityClassName = null): arrayReturns an array of entities; empty when nothing matched.
$users = UserRepository::instance('u')->where(Qb::eq('u.status', 'active'))->findAll();There is no size limit here — the method returns as many rows as it found. For large tables
add a limit(), or take pagination.
findById(), findBy(), findAllBy()
public function findById(string|int $id, ?string $entityClassName = null): ?object
public function findBy(Qb $qb, ?string $entityClassName = null): ?object
public function findAllBy(?Qb $qb = null, ?string $entityClassName = null): arrayShorthands for the common case — the condition goes straight into the call, without a
separate where().
| Method | What it does | When nothing matched |
|---|---|---|
findById($id) |
looks up by primary key | null |
findBy($qb) |
the first row matching a condition | null |
findAllBy($qb) |
all rows matching; null means the whole table |
[] |
UserRepository::instance()->findById(1)?->email; // 'a@x'
UserRepository::instance()->findById(999); // null
UserRepository::instance()->findAllBy(); // every rowThe key column’s name comes from the entity — the same one
mapIdentifierColumnName() reports.
findByIdOrThrow(), findByOrThrow()
public function findByIdOrThrow(
string|int $id,
?string $entityClassName = null,
string $message = 'Entity not found',
HttpCode $httpCode = HttpCode::NOT_FOUND,
): object| Argument | Type | Default | What it does |
|---|---|---|---|
$id / $qb |
string|int / Qb |
— | what to look for |
$entityClassName |
?string |
null |
the hydration class |
$message |
string |
'Entity not found' |
the exception’s text |
$httpCode |
HttpCode |
NOT_FOUND |
the status, should the exception reach the HTTP layer |
Returns an object — never null. Throws EntityException when there is no row:
FlytachiWinterPpaEntityEntityException: Entity not found// instead of three lines with a check
$user = UserRepository::instance()->findByIdOrThrow($id, message: 'No such user');The point is not brevity but that the exception carries an HTTP status: the error handler
turns it into a 404 without a single if in the controller.
count()
public function count(): intReturns the number of rows matching the accumulated conditions.
UserRepository::instance()->where(Qb::gt('age', 25))->count(); // 2This is a separate COUNT query, not the length of a selection: no rows are read and none
are hydrated.
exists()
public function exists(): boolReturns true when at least one row matches.
if (UserRepository::instance()->where(Qb::eq('email', $email))->exists()) {
throw new Conflict('That address is taken');
}Cheaper than count() > 0 and more honest than find() !== null: the row never crosses
the network.
findColumn()
public function findColumn(int $column = 0): mixed| Argument | Type | Default | What it does |
|---|---|---|---|
$column |
int |
0 |
the column’s position in the SELECT list, counting from zero |
Returns a single value from the first row — no hydration, no objects created.
UserRepository::instance()->select('email')->findColumn(); // 'a@x'
UserRepository::instance()->select('COUNT(*)')->findColumn(); // '4'For aggregates and single values this is the shortest path: the result never becomes an entity.
rawFetch()
public function rawFetch(string $sql, array $binds = [], ?string $entityClassName = null): array| Argument | Type | Default | What it does |
|---|---|---|---|
$sql |
string |
— | the whole query, as it is |
$binds |
array |
[] |
values for the placeholders |
$entityClassName |
?string |
null |
the hydration class |
Returns an array of objects.
$rows = UserRepository::instance()->rawFetch(
'SELECT email FROM users WHERE age > :age ORDER BY id LIMIT 2',
['age' => 18],
);For what the builder cannot express: window functions, dialect specifics, a heavy report. The connection and the hydration still come from the repository — only the assembly is given up.
`$binds` is the only safe way in
Pass values through $binds, never by interpolating them into $sql. Interpolation here
is exactly the SQL injection the rest of the layer exists to prevent.
Writing
insert()
public function insert(object|array $entity): mixed| Argument | Type | What it does |
|---|---|---|
$entity |
object|array |
an entity, or an associative “column => value” array |
Returns the identifier of the inserted row. On PostgreSQL and MariaDB through
RETURNING on the first column, elsewhere through lastInsertId.
UserRepository::instance()->insert(['email' => 'a@x', 'age' => 30]); // "1"
$user = new User();
$user->email = 'b@x';
UserRepository::instance()->insert($user); // "2"Properties whose value is null are not sent — which is why ?int $id = null on the
entity lets the database assign the key itself.
The primary key must be the entity's first property
RETURNING takes the first column, and the column order is the order of the properties
in the entity class. If the key is not first, what comes back is not the identifier:
$id = $repo->insert(['name' => 'Alice', 'id' => null]); // returns 'Alice'The rule: the key is the entity’s first property, and the first key in the array.
insertBatch()
public function insertBatch(Traversable|object|array ...$entities): voidInserts many rows in batches, one query per batch.
UserRepository::instance()->insertBatch(
['email' => 'c@x', 'age' => 20],
['email' => 'd@x', 'age' => 25],
);
// a generator: one batch lives in memory at a time, whatever the total
UserRepository::instance()->insertBatch((function () {
foreach ($hugeSource as $row) {
yield ['email' => $row->email, 'age' => $row->age];
}
})());Returns nothing — a batch has no identifiers. If you need them, insert one at a time
with insert().
update()
public function update(object|array $entity, Qb $qb): string|int| Argument | Type | What it does |
|---|---|---|
$entity |
object|array |
what to write |
$qb |
Qb |
which rows to change — required |
Returns the number of rows changed: 0 when the condition matched nothing.
UserRepository::instance()->update(['age' => 31], Qb::eq('email', 'a@x')); // 1
UserRepository::instance()->update(['age' => 99], Qb::eq('email', 'nobody')); // 0The condition is a required argument rather than a default: updating a whole table has to
be written out (Qb::raw('1=1')), not arrived at through a forgotten parameter.
delete()
public function delete(Qb $qb): string|intReturns the number of rows deleted.
UserRepository::instance()->delete(Qb::eq('email', 'd@x')); // 1
UserRepository::instance()->delete(Qb::lt('created_at', $cutoff));The condition is required for the same reason as in update().
upsert()
public function upsert(
object|array $entity,
array $conflictColumns,
?array $updateColumns = null,
): mixed“Insert, and if such a row already exists, update it.”
| Argument | Type | Default | What it does |
|---|---|---|---|
$entity |
object|array |
— | what to insert |
$conflictColumns |
array |
— | which columns decide “the same row” |
$updateColumns |
?array |
null |
a “column => expression” map: what to do on a conflict |
$updateColumns is a map, not a list of columns. Two placeholders are available inside
an expression: :new — the incoming value, :current — what is already stored.
// overwrite with the incoming value
$repo->upsert(['email' => 'a@x', 'age' => 30], ['email'], ['age' => ':new']);
// add to what is stored
$repo->upsert(['sku' => 'ABC', 'qty' => 5], ['sku'], ['qty' => ':current + :new']);A list instead of a map does not pass silently — CDO answers with the corrected call:
updateColumns expects a column => expression map, got a plain list at position 0.
Did you mean ['age' => ':new']? Use ':new' for the incoming value and ':current' for the
stored one; pass an empty array to ignore conflicts.`null` means do nothing, not update everything
null and [] mean the same thing: the conflict is ignored and the stored row is left
as it was. Measured: upsert(['email' => 'a@x', 'age' => 20], ['email']) against an
existing a@x leaves age at 10.
For anything to be updated, the columns have to be named.
Returns an identifier, like insert().
upsertBatch()
public function upsertBatch(
iterable $entities,
array $conflictColumns,
?array $updateColumns = null,
): voidThe same for many rows, in batches. $entities is any iterable, a generator included.
UserRepository::instance()->upsertBatch(
[['email' => 'g1@x', 'age' => 1], ['email' => 'z@x', 'age' => 7]],
['email'],
['age' => ':current + :new'],
);
// the existing g1@x went from age 111 to 112, z@x was insertedReturns nothing.
Transactions
A transaction lives on a connection, and a unit of work has one — so every repository inside a request joins the same transaction automatically.
$db = UserRepository::instance()->db();
$db->beginTransaction();
try {
$id = UserRepository::instance()->insert(['email' => $email]);
OrderRepository::instance()->insert(['user_id' => $id, 'total' => 0]);
$db->commit();
} catch (Throwable $e) {
$db->rollBack();
throw $e;
}Two different repositories write inside one transaction here, although neither knows about
it: db() on both returns the same connection of the current unit of work.
A transaction lives on the connection, not on the object
Two consequences. Do not begin a transaction in one coroutine expecting to close it in another — there will be a different connection there. And do not leave one open: the connection returns to the pool with an unfinished transaction and travels on to the next request.
Debugging
The query reaches the database only when something is read or written, so until then it can be inspected.
buildSql()
public function buildSql(array $ignoreParts = []): string| Argument | Type | Default | What it does |
|---|---|---|---|
$ignoreParts |
array |
[] |
which parts to leave out of the assembly |
Returns the assembled SQL with placeholders — exactly as it will reach the database.
echo UserRepository::instance('u')->where(Qb::gt('u.age', 20))->orderBy('u.id DESC')->limit(5, 10)->buildSql();
// SELECT u.id, u.email, u.age FROM users u WHERE u.age > :iqb11 ORDER BY u.id DESC LIMIT 5 OFFSET 10The values are not here — look at them separately through getSql('binds').
getSql()
public function getSql(?string $param = null): mixed| Argument | Type | Default | What it does |
|---|---|---|---|
$param |
?string |
null |
which part to return: 'where', 'limit', 'binds'; null is the whole query |
$q = UserRepository::instance('u')->where(Qb::eq('u.id', 1))->limit(3);
$q->getSql(); // "SELECT u.id, u.email FROM users u WHERE u.id = :iqb7 LIMIT 3"
$q->getSql('where'); // "WHERE u.id = :iqb7"
$q->getSql('binds'); // the values that will be boundsqlPartsCount(), cleanCache()
public function sqlPartsCount(): int
public function cleanCache(?string $param = null): voidsqlPartsCount() reports how many parts have accumulated — a quick way to check that an
instance really is clean. cleanCache() drops one part, or the whole query:
$repo->cleanCache('where'); // drop the conditions
$repo->cleanCache(); // reset everything; sqlPartsCount() → 0The assembled SQL is useful beyond debugging
Write operations are also logged at debug level: with LOG_LEVEL=debug the assembled
INSERT, UPDATE and DELETE appear in the output. Selections do not — those are read
through buildSql().
It also hands the query to places PPA has no part in: an EXPLAIN, a report easier to run
raw, or a test that checks the expected SQL.
Utility
| Method | What it returns |
|---|---|
db() |
the CDO connection of the current unit of work |
originTable() |
the table name — "users" |
getSchema() |
the schema, or null |
mapIdentifierColumnName() |
the primary key column — "id" |
getDbConfigClassName() |
the database config class |
getEntityClassName() |
the hydration class; stdClass under a custom select() |
binding(?array $binds) |
merges your own CDOBinds into the accumulated ones |
state() |
protected — the query state of the current coroutine |
useBind($stmt) |
protected — binds the values onto a prepared statement |
binding() is for when part of a condition is assembled as a string while its values must
still travel as parameters:
use Flytachi\Winter\Cdo\CDOBind;
UserRepository::instance('u')
->where(Qb::raw('u.created_at > :since'))
->binding([new CDOBind('since', $date)]);Why a query has per-coroutine state
A repository may be a singleton in the container, while where(), join() and the rest
accumulate parts on the object. Under Swoole one instance serves several requests at
once, and without isolation they would build each other’s conditions.
state() returns the state of the current coroutine: under Swoole an object of its own in
the coroutine context, outside a coroutine the repository itself — the previous behaviour
exactly, at no cost. A subclass needs this when it adds query parts of its own.
Errors
| Exception | When |
|---|---|
EntityException |
an *OrThrow found no row; it carries an HTTP status |
RepositoryException |
an execution failure: a constraint violation, invalid SQL, a driver refusal |
RepositoryException wraps a CDOException, and the original is available through
getPrevious() — that is where the SQLSTATE lives, the thing that tells “duplicate key”
from “connection lost”.
Next
- Entities — what the result is hydrated into, and where the columns come from
- Pagination — pages instead of a hand-written
limit() - Migrations — how the described schema reaches the database