Skip to content

Architecture

Muhammet Şafak edited this page May 24, 2026 · 1 revision

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.

Package layout

┌─────────────────────────────────────────────────────────┐
│ 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/dbal and initorm/query-builder are independent — they have no knowledge of each other.
  • initorm/database is the only place where they're stitched together.
  • initorm/orm (a layer above this one) builds active-record models on top of Database.

Inside a 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 hook

The QueryBuilder driver is picked from $connection->getDriver() so identifier quoting is dialect-correct out of the box.

The __call chain

Method calls walk down through three layers, with each layer re-wrapping the chain so callers always end up holding the outer object.

How it works

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.

Walkthrough

$db->select('id')->where('id', '=', 1)->read('users');
  1. select('id') doesn't exist on Database → __call forwards to $this->builder->select('id'). The builder returns itself → re-wrapped to $db.
  2. where('id', '=', 1) → same path; returns $db.
  3. read('users') exists on Database directly → compiles SQL, calls $this->connection->query(...), resets builder state, returns the DataMapperInterface.

The DataMapper continues the chain:

->read('users')->asAssoc()->rows();
  1. asAssoc() on DataMapper → returns the DataMapper.
  2. rows() → returns array<int, array>.

State management

There are two pieces of state inside the Database:

  1. The builder's structureselect, from, where, set, … buckets. Mutated by builder calls; reset in a finally block after every CRUD execution so the next call starts clean.
  2. The builder's parameter bag — placeholder → value map. Reset in the same finally block.

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.

Reset on execution

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.)

What is not shared with siblings

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.

Static facade lifecycle

┌────────────────────────────┐
│ 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).

What is not in this package

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.

Design principles

A few non-obvious decisions explained.

Why DatabaseInterface doesn't declare __construct

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.

Why builder state resets in finally, not after success

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.

Why createImmutable() is single-shot

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.

Why CRUD methods return bool instead of int

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.

See also

Clone this wiki locally