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

FAQ

Answers to questions that come up repeatedly. Roughly grouped by topic.

Getting started

Do I have to use the static facade?

No. The facade is one of two equally first-class styles. Skip it entirely if you prefer DI:

$db = new Database($cfg);
$repo = new UserRepository($db);

See Static Facade and Multiple Connections for the trade-offs.

Why does createImmutable() throw on second call?

Because silent global overrides are a bug magnet. If you genuinely need to swap (typically in tests), DB::replaceImmutable($new) says so — the explicit name is the whole point. See Static Facade.

Why does the package require PHP 8.1+ when DBAL only needs 8.0+?

Because initorm/query-builder ^2.0 requires PHP 8.1. The Database package can't be more permissive than its dependencies. If you're on PHP 8.0, pin to initorm/database ^2.0 (the old major).

CRUD and return values

Why does update() return true even when no rows changed?

Because execution success and rows affected are different questions. true means "the SQL ran without error". To know how many rows changed, call affectedRows():

DB::update('users', ['active' => 0], ['id' => 1]);
echo DB::affectedRows(); // 0 if the row already had active=0, 1 if it was 1 before

This was a recurring bug in v2 — the old behaviour returned false for "no rows changed", which conflated execution failure with idempotent updates.

Why does delete() without a WHERE compile to WHERE 1?

Intentional. The QueryBuilder doesn't refuse to compile a DELETE without WHERE — it just inserts a literal 1 to make the SQL syntactically complete. You are responsible for gating destructive operations. If you want to forbid them in your codebase, wrap the call:

function safeDelete(string $table, array $conditions): void
{
    if (empty($conditions)) {
        throw new \LogicException('Refusing to DELETE without conditions.');
    }
    DB::delete($table, $conditions);
}

What does insertId() return?

string|false. PDO's lastInsertId() returns the value as a string (because some drivers return non-integer keys), and false on drivers without an auto-incremented column. SQLite returns the rowid as a string; MySQL returns the AUTO_INCREMENT value as a string.

DB::create('users', $data);
$id = DB::insertId();         // "42"
$intId = (int) DB::insertId(); // 42

Why does affectedRows() return 0 for SELECT?

Driver-dependent: PDOStatement::rowCount() is only reliable for INSERT / UPDATE / DELETE. For SELECT on unbuffered MySQL it returns 0; on SQLite for SELECT it also returns 0. For SELECT row counts, prefer count($result->rows()) or numRows() after a buffered fetch.

Builder and chaining

Why does my second read() return zero rows?

You probably hit the v2 state-pollution bug. In v3 this is fixed — every CRUD call resets the builder state in a finally block.

If you're on v3 and still seeing it, check that you're not sharing one Database across threads / coroutines (Database isn't thread-safe; spin up siblings with withFreshBuilder() per logical task).

How do I see the SQL the builder will run?

$db->select('id')->from('users')->where('active', '=', 1);

echo $db->generateSelectQuery();    // SELECT id FROM users WHERE active = :active
print_r($db->getParameter()->all()); // [':active' => 1]

generate*Query() does not reset the builder — only CRUD execution does. After inspecting, call $db->resetStructure() or a CRUD method to clear.

Why is select() claimed to support string[] in DocBlocks but my IDE complains?

It doesn't. That was an outdated @method annotation in v2's facade. v3's facade is regenerated against the actual QueryBuilderInterface; the real signature is select(string|RawQuery ...$columns). If your IDE still autocompletes the old shape, restart it or clear its index.

Can I pass ?> user input through select()?

Column names? Yes — they're identifier-quoted by the driver. But don't pass raw user input as a column unless you've validated against an allow-list. The builder will safely quote id or users.created_at, but it can't tell whether id; DROP TABLE users; was supposed to be a column. See Raw Queries for the rule.

Transactions

Why doesn't my DDL roll back?

Because MySQL (and most other databases) implicit-commit when they encounter a DDL statement like CREATE TABLE or ALTER TABLE. After that, rollBack() has nothing to roll back. Avoid mixing DDL into a retried transaction. See Transactions for the full caveat list.

Can I nest transactions?

Not natively — PDO doesn't support it. Starting a transaction inside another throws. Use savepoints (raw SQL) if you need per-section partial rollback:

$db->transaction(function ($db) {
    $db->query('SAVEPOINT sp_audit');
    try {
        $db->create('audit', [...]);
    } catch (\Throwable $e) {
        $db->query('ROLLBACK TO sp_audit');
    }
    $db->query('RELEASE SAVEPOINT sp_audit');
});

What isolation level does the transaction use?

Whatever your database defaults to. The helper doesn't change it. To override, run SET TRANSACTION ISOLATION LEVEL … at the top of the closure, or configure it via options[PDO::MYSQL_ATTR_INIT_COMMAND].

Why is my exception turning into DatabaseException?

By design. transaction() wraps the original throwable so callers always catch a single, predictable exception type. The original is reachable via getPrevious():

try {
    $db->transaction(function () { throw new \RuntimeException('boom'); });
} catch (DatabaseException $e) {
    $original = $e->getPrevious(); // \RuntimeException 'boom'
}

In v2 the original was silently swallowed. The wrap-and-rethrow is a fix.

Logging and profiling

What's the difference between the failure log and the query profiler?

Failure log (log) Query profiler (queryLogs)
When On error only On every query
Where External sink In-process memory
Cost Negligible Per-query memory growth
Use case Production error tracking Dev profiling, test assertions

They're independent — enable both, either, or neither. See Logging and Debug and Query Profiler.

Why is the timer field 0.0?

Because the underlying call didn't pass a $startTime. The DBAL Connection always passes one when it executes through query(); if you see 0.0, you may be looking at an entry added manually via addQueryLog() (which is uncommon but allowed).

Can I get the SQL with parameters interpolated?

No — and intentionally. The whole point of prepared statements is that values never get spliced into the SQL string; they're sent to the server separately. The closest equivalent for debugging is logging query + args side-by-side; that's exactly what the profiler does.

How do I integrate with Monolog?

Pass it directly — Monolog implements PSR-3's LoggerInterface, which has the critical(string $message) method the failure log expects:

DB::createImmutable([
    'dsn' => '',
    'log' => $monolog,
]);

See Logging and Debug for more PSR-3-friendly setups.

Architecture and internals

Why does DatabaseInterface not declare __construct?

Because interface constructors are LSP-hostile: they force every implementation to accept the same arguments. The interface only declares behavioural methods. Implementations are free to choose their own constructor shape.

Where does select, where, join, … live? They're not on Database.

They're on QueryBuilderInterface. The Database forwards unknown method calls to its inner builder via __call. The @mixin QueryBuilderInterface annotation on DatabaseInterface tells IDEs / PHPStan to autocomplete the full builder surface. See Architecture.

How is the Database different from the ORM?

initorm/database is a fluent SQL gateway. You query tables and get back arrays / objects.

initorm/orm builds on top of it: active-record models with $schema, $schemaId, $readable / $writable flags, hooks, accessors / mutators, soft-delete, timestamps, etc. Use the ORM when you want model classes; use Database directly when you want SQL.

Can I pass my own QueryBuilderFactory?

Yes — the constructor takes one as the second argument:

new Database($cfg, $myFactory);

This is the official DI hook. Useful for tests, custom dialect drivers, and decorator patterns.

Connection lifecycle

When is the PDO connection actually opened?

Lazily — on the first call that needs it (a query(), getPDO(), or any setter that walks through getPDO()). Constructing a Database does not connect; you can hold an idle Database safely.

Why does setHost() throw after I've used the database?

By design. Once PDO is open, mutating the credentials would be a silent inconsistency. Either build a fresh Database for the new credentials, or call disconnect() first:

$db->getConnection()->disconnect();
$db->getConnection()->setDatabase('analytics');

Does this package pool connections?

No. Neither this package nor initorm/dbal pool connections — PDO is created on demand and held for the lifetime of the Connection object. For pooling:

  • PHP-FPM keeps connections alive within a request (good enough for most).
  • Set PDO::ATTR_PERSISTENT => true in options for cross-request persistence.
  • For Swoole / RoadRunner / ReactPHP, manage Database instances per worker.

See also

Clone this wiki locally