-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture
initorm/database is a thin glue layer over two independent packages. Understanding the dependency direction and the __call chain makes most "where does this method live?" questions answer themselves.
┌─────────────────────────────────────────────────────────┐
│ initorm/database — CRUD + transaction + facade │
│ ├─ Database (concrete) │
│ ├─ DatabaseInterface (@mixin QueryBuilderInterface) │
│ └─ Facade\DB (static) │
└───────┬──────────────────────────┬──────────────────────┘
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────────────────┐
│ initorm/dbal │ │ initorm/query-builder │
│ Connection (PDO) │ │ QueryBuilder (SQL assembly) │
│ DataMapper (rows) │ │ Compilers (string-join) │
└─────────────────────┘ └─────────────────────────────────┘
-
initorm/dbalandinitorm/query-builderare independent — they have no knowledge of each other. -
initorm/databaseis the only place where they're stitched together. -
initorm/orm(a layer above this one) builds active-record models on top of Database.
┌────────────────────────────────────────────────────────┐
│ Database │
│ ├─ ConnectionInterface $connection │ ← from DBAL
│ ├─ QueryBuilderInterface $builder │ ← from QueryBuilder
│ ├─ QueryBuilderFactoryInterface $queryBuilderFactory │
│ └─ ?DataMapperInterface $lastResult │ ← for affectedRows()
└────────────────────────────────────────────────────────┘
The constructor accepts either a credentials array (forwarded to Connection::__construct) or an already-built ConnectionInterface:
new Database(['driver' => 'mysql', 'dsn' => '…']);
new Database($existingConnection);
new Database($connection, $myCustomQueryBuilderFactory); // DI hookThe QueryBuilder driver is picked from $connection->getDriver() so identifier quoting is dialect-correct out of the box.
Method calls walk down through three layers, with each layer re-wrapping the chain so callers always end up holding the outer object.
caller ──► Database::__call($name, $args)
│
├─ method exists on $this->builder?
│ no → throw DatabaseException
│ yes ↓
│
├─ $result = $this->builder->{$name}(...$args)
│
└─ $result instanceof QueryBuilderInterface
true → return $this (re-wrap to Database)
false → return $result (pass through)
The same pattern repeats one layer down inside DBAL: Connection::__call forwards unknowns to PDO, and DataMapper::__call forwards to PDOStatement. The whole stack is fluent end-to-end.
$db->select('id')->where('id', '=', 1)->read('users');-
select('id')doesn't exist on Database →__callforwards to$this->builder->select('id'). The builder returns itself → re-wrapped to$db. -
where('id', '=', 1)→ same path; returns$db. -
read('users')exists on Database directly → compiles SQL, calls$this->connection->query(...), resets builder state, returns theDataMapperInterface.
The DataMapper continues the chain:
->read('users')->asAssoc()->rows();-
asAssoc()on DataMapper → returns the DataMapper. -
rows()→ returnsarray<int, array>.
There are two pieces of state inside the Database:
-
The builder's structure —
select,from,where,set, … buckets. Mutated by builder calls; reset in afinallyblock after every CRUD execution so the next call starts clean. -
The builder's parameter bag — placeholder → value map. Reset in the same
finallyblock.
There is no Database-level mutable state beyond $lastResult (used by affectedRows()), which makes the class safe to use across nested calls in the same script.
Every CRUD helper goes through this guard:
private function executeBuilderQuery(string $sql): DataMapperInterface
{
$parameters = $this->builder->getParameter()->all();
try {
$this->lastResult = $this->connection->query($sql, $parameters);
} finally {
$this->builder->resetStructure();
$this->builder->getParameter()->reset();
}
return $this->lastResult;
}The finally block guarantees state is wiped even if the query throws — no leaked WHERE clauses leaking into the next call. (This was a bug in v2; see Migration from v2 to v3.)
withFreshBuilder() returns a Database that:
-
Shares the same
ConnectionInterface(same PDO, same transaction context, same query log). - Does not share builder state — a brand-new QueryBuilder is constructed.
$base = new Database([...]);
$sub = $base->withFreshBuilder();
assert($base->getConnection() === $sub->getConnection()); // ✅
assert($base !== $sub); // ✅This is the right shape for sub-queries: same live connection, independent SQL assembly.
clone $database follows the same rule — the builder is deep-cloned (via __clone), so cloned instances don't share builder state.
┌────────────────────────────┐
│ DB::createImmutable($cfg) │ ← required, single-shot
└──────────────┬─────────────┘
│
DB::$database
│
▼
┌────────────────────────────┐
│ DB::someMethod(...) │ → __callStatic → DB::$database->someMethod(...)
└────────────────────────────┘
The class is final, the constructor is private, and DB::$database is the only mutable static — set by createImmutable() / replaceImmutable() and never else.
__callStatic is the only forwarding mechanism — there's no __call, so (new DB())->foo() cannot exist (and won't compile thanks to the private constructor).
A common point of confusion: a lot of the surface you'll use lives in other packages.
| You're looking for | Look in |
|---|---|
setHost, setPassword, getCharset
|
initorm/dbal Connection |
asAssoc, row, rows, numRows
|
initorm/dbal DataMapper |
select, where, join, groupBy, … |
initorm/query-builder |
Dialect quoting / RawQuery
|
initorm/query-builder |
| Active-record models, hooks, accessors/mutators |
initorm/orm (separate) |
initorm/database itself is small — under 400 lines of code — because almost everything routes through __call to one of the layers below.
A few non-obvious decisions explained.
Constructor signatures on interfaces are LSP-hostile — they force every implementation to accept the same arguments. The interface only declares behavioural methods; concrete implementations are free to define their own constructor signature.
If a query throws, you almost certainly don't want the next CRUD call to inherit the failed query's WHERE clause. Resetting in finally makes every CRUD call atomic with respect to builder state — either it ran and reset, or it threw and reset.
createImmutable() only succeeds once because silent override is a footgun. If you really need to swap, replaceImmutable() says so — the explicit name is the whole point.
bool true = "executed without error". That's unambiguous. int would have to mean "rows changed" — which conflates "executed successfully" with "had effect", and UPDATE … WHERE id = 1 returning 0 (because the row already had those values) used to be one of the most-reported bugs in v2.
For affected rows, call affectedRows() explicitly. The two questions are different; the API treats them differently.
-
Static Facade — how
DB::…calls actually flow -
CRUD Operations — the helpers built on top of
__call - Migration from v2 to v3 — the bugs this architecture fixed
InitORM Database · MIT · maintained by Muhammet ŞAFAK · part of the InitORM stack
Getting Started
Core Operations
Cross-Cutting
Reference
Upgrading
Project