Databases
Quiote ships one raw driver out of the box — PDO — plus first-class adapters that hand back a fully-configured ORM: Eloquent, Doctrine ORM, Doctrine DBAL, Cycle ORM, and Propulsion. You declare a connection in config, name an adapter, and Quiote instantiates and wires it up; your code just asks for the connection.
The layer is deliberately thin and unopinionated — it manages connection lifecycle (open, ping, reconnect, reset across worker requests) and otherwise hands you the real ORM object. No ORM is required; the ones above are opt-in plugins.
Where the configuration lives
Section titled “Where the configuration lives”Getting a database working touches three config locations. Keeping them straight up front saves a lot of confusion later:
| What | Where it goes | Covered in |
|---|---|---|
| Turn the database layer on | core.use_database in Config/settings.{php,yaml,xml} | Turning the layer on |
| Your connections (host, credentials, which adapter) | Config/databases.{php,yaml,xml} | Configuring connections |
| The adapter package (so an ORM alias exists) | the plugins config, Config/plugins.{php,yaml,xml} | Enabling an ORM adapter |
The raw PDO adapter needs only the first two — no plugin, since pdo is built in. Each ORM (Eloquent, Doctrine, Cycle, Propulsion) additionally needs its package installed and its plugin registered.
Choosing an adapter
Section titled “Choosing an adapter”Quiote doesn’t pick a favorite ORM — it picks this lineup because each one covers a different, genuinely distinct reason to reach for it:
| Adapter | Style | Pick it when… |
|---|---|---|
| PDO | Raw driver, no ORM | You don’t want an ORM at all — raw SQL, a hand-rolled data-access layer, or a service that only needs prepare()/execute(). Every other adapter here defaults to building its own PDO connection under the hood, so this is also the floor everything else sits on. |
| Eloquent | Active record | You want the single most widely-used PHP ORM by install base and community size — a natural fit if your team already thinks in Laravel/Eloquent conventions, or you’re porting Eloquent models into a Quiote app. |
| Doctrine ORM | Data mapper | You want the de-facto “serious”/enterprise choice — Symfony’s own default ORM — with persistence-ignorant entities and an explicit UnitOfWork tracking changes, a good fit for DDD-influenced codebases. |
| Doctrine DBAL | Query builder, no entity layer | You want vendor-portable SQL and a fluent query builder without committing to entity mapping — reporting queries, admin tooling, or a gradual migration path onto (or off of) the full ORM. |
| Cycle ORM | Data mapper, schema-compiled | You want a data-mapper ORM purpose-built for long-running worker processes (it comes out of the Spiral/RoadRunner ecosystem) — a natural fit for Quiote’s own worker-mode-first design. |
| Propulsion | Code-generated (Propel-style) | You’re migrating from Propel, or want the ORM Quiote’s own maintainers run their production app on — see Propulsion for why it’s a fork the team owns rather than a third-party dependency. |
Active record (Eloquent) and data mapper (Doctrine, Cycle) are the two classic ORM design philosophies in PHP: active-record models know how to save themselves, while data-mapper entities are plain objects a separate manager persists on your behalf. Neither is objectively better — it’s a matter of what shape you want your domain objects to have, and Quiote gives you a first-class adapter for each style rather than forcing one.
Mental model
Section titled “Mental model”Each configured connection is a Quiote\Database\Database instance — a lifecycle wrapper, not the connection itself. You get the underlying object through it:
$db = $this->databases->getDatabase('main'); // the Database wrapper$conn = $db->getConnection(); // the PDO / ORM object$this->databases is a Quiote\Database\DatabaseManager, injected — a class that talks to the database declares it in its constructor:
public function __construct(private readonly DatabaseManager $databases) {}What getConnection() returns depends on the adapter:
| Adapter | getConnection() returns |
|---|---|
| PDO | PDO |
| Eloquent | Illuminate\Database\Capsule\Manager |
| Doctrine ORM | Doctrine\ORM\EntityManagerInterface |
| Doctrine DBAL | Doctrine\DBAL\Connection |
| Cycle ORM | Cycle\ORM\ORMInterface |
| Propulsion | Propulsion\Connection\PropulsionPDO |
The wrapper stays in front so the framework can manage the connection across a long-lived worker’s requests. Each ORM adapter also exposes typed accessors (getEntityManager(), getCapsule(), getOrm(), …) for IDE completion — see each section.
Every adapter also declares getPdo(): \PDO on the Database base class itself, for the one thing that’s useful uniformly across all of them regardless of getConnection()’s native type — a raw PDO handle for hand-written SQL. See Raw PDO access below.
How it fits in a request
Section titled “How it fits in a request”The database layer is not a middleware — nothing in the request pipeline opens a connection on its own. Instead, one manager object stands ready and hands out connections when your code asks.
How the framework finds and registers it. DatabaseManager is a core role, wired by the database_manager entry in your factories config (Config/factories.{php,yaml,xml}), and the resulting instance is bound in the DI container under the role databaseManager and under its class. Type-hint the class and the container hands it over:
public function __construct(private readonly DatabaseManager $databases) {}In a context whose factories config declares no database_manager, that binding is a factory that throws and names what would have declared it, rather than an empty manager with no connections. Where the database layer is genuinely optional, ask with tryGet() instead and handle the null.
DatabaseManager::getDatabase($name) reads your databases config, instantiates the named adapter the first time it’s asked for (connections open lazily), and caches the wrapper.
The path a connection takes:
your action or service calls
$this->databases->getDatabase('main')->getConnection()→DatabaseManager::getDatabase('main')resolves the adapter class from config → the adapter opens its connection on first use → the ORM/PDO object is returned. At the request boundary in a long-lived worker,Context::reset()callsDatabaseManager::recycleConnections(), whichping()s each live connection (reconnecting dead ones); per-request state is cleared separately by the container’s request reset. Either way the connection itself is kept.
See Worker mode and connection lifecycle for the full set of lifecycle hooks.
Turning the layer on
Section titled “Turning the layer on”The whole database layer is gated by one setting. It lives in settings, alongside the other core.use_* subsystem switches:
return [ 'core.use_database' => true,];core.use_database: true<settings> <setting name="use_database">true</setting></settings>With core.use_database => false (the scaffolded default), the manager is not required and no connection is ever opened — nothing binds a DatabaseManager to inject. Set it to true before configuring connections below. See Configuration → settings for the full settings file.
Configuring connections
Section titled “Configuring connections”Connections live in a databases config in your app’s Config/ directory. Each <database> names an adapter via class and passes free-form parameters; default selects the one returned by getDatabase() with no argument.
return [ 'default' => 'main', 'databases' => [ 'main' => [ 'class' => 'pdo', 'parameters' => [ 'dsn' => 'pgsql:host=localhost;dbname=app', 'username' => 'app', 'password' => 'secret', ], ], ],];default: maindatabases: main: class: pdo parameters: dsn: 'pgsql:host=localhost;dbname=app' username: app password: secret<ae:configurations xmlns:ae="http://quiote.dev/quiote/config/global/envelope/1.1" xmlns="http://quiote.dev/quiote/config/parts/databases/1.1"> <ae:configuration> <databases default="main"> <database name="main" class="pdo"> <ae:parameter name="dsn">pgsql:host=localhost;dbname=app</ae:parameter> <ae:parameter name="username">app</ae:parameter> <ae:parameter name="password">secret</ae:parameter> </database> </databases> </ae:configuration></ae:configurations>- Multiple
<database>children (or array entries) define multiple named connections. - Per-environment overrides differ by format: XML repeats
<ae:configuration environment="…">blocks, while a PHP or YAML config branches oncore.environmentinside the file (or names it in aparentpath) — see Environments and contexts. - In XML, array parameters nest
<ae:parameter>(named ones become assoc entries, unnamed ones become list entries).
Driver aliases
Section titled “Driver aliases”class accepts a fully-qualified adapter class or a short alias. Only pdo is built in; the ORM aliases are added by their plugins.
| Alias | Adapter class |
|---|---|
pdo | Quiote\Database\PdoDatabase |
eloquent | Quiote\Database\Adapter\Eloquent\EloquentDatabase |
doctrine | Quiote\Database\Adapter\Doctrine\DoctrineDatabase |
doctrine_dbal | Quiote\Database\Adapter\Doctrine\DoctrineDbalDatabase |
cycle | Quiote\Database\Adapter\Cycle\CycleDatabase |
propulsion | Quiote\Database\Adapter\Propulsion\PropulsionDatabase |
A fully-qualified class name in class always works even without the plugin; the plugin just adds the short alias. Aliases must be valid PHP labels — hence doctrine_dbal.
Enabling an ORM adapter
Section titled “Enabling an ORM adapter”Two steps: install the library (see Installing the libraries), and register the plugin so its alias is available:
return [ ['class' => \Quiote\Database\Adapter\Doctrine\DoctrinePlugin::class, 'enabled' => true], ['class' => \Quiote\Database\Adapter\Eloquent\EloquentPlugin::class, 'enabled' => true], ['class' => \Quiote\Database\Adapter\Cycle\CyclePlugin::class, 'enabled' => true], ['class' => \Quiote\Database\Adapter\Propulsion\PropulsionPlugin::class, 'enabled' => true],];- class: Quiote\Database\Adapter\Doctrine\DoctrinePlugin enabled: true- class: Quiote\Database\Adapter\Eloquent\EloquentPlugin enabled: true- class: Quiote\Database\Adapter\Cycle\CyclePlugin enabled: true- class: Quiote\Database\Adapter\Propulsion\PropulsionPlugin enabled: true<ae:configurations xmlns:ae="http://quiote.dev/quiote/config/global/envelope/1.1" xmlns="http://quiote.dev/quiote/config/parts/plugins/1.1"> <ae:configuration> <plugin class="Quiote\Database\Adapter\Doctrine\DoctrinePlugin" /> <plugin class="Quiote\Database\Adapter\Eloquent\EloquentPlugin" /> <plugin class="Quiote\Database\Adapter\Cycle\CyclePlugin" /> <plugin class="Quiote\Database\Adapter\Propulsion\PropulsionPlugin" /> </ae:configuration></ae:configurations>See Plugins and extensibility for how plugins work.
PHP’s built-in ext-pdo extension is the lowest-level adapter here: no query builder or ORM on top, just a uniform prepared-statement API over whichever driver extension you have compiled in (pdo_pgsql, pdo_mysql, pdo_sqlite, pdo_sqlsrv, pdo_oci).
It’s the floor every other adapter on this page sits on — Eloquent, Doctrine DBAL, and Cycle all default to building their own PDO connection from the same kind of parameters. Quiote’s core deliberately ships no native mysqli/pgsql/oci8 driver of its own; anything more specialized than PDO is reached through an ORM that supports it (e.g. Doctrine DBAL’s own driver choices), not as a separate Quiote adapter.
Reach for it directly when you don’t want an ORM at all — raw SQL, a hand-rolled data-access layer, or a service that only needs prepare()/execute().
- Select with
class="pdo" - Requires
ext-pdo— always available, no package to install getConnection()returns aPDO
| Parameter | Default | Description |
|---|---|---|
dsn | — | PDO DSN (required) |
username / password | — | Credentials |
options | [] | PDO driver options (constant names as strings allowed) |
attributes | [] | PDO attributes set after connect |
init_queries | [] | Queries executed on connect |
warn_mysql_charset | true | Guards against unsafe SET NAMES on MySQL DSNs |
options/attributes keys or values containing :: resolve as constants, so you can write PDO::ATTR_TIMEOUT. PDO::ATTR_ERRMODE defaults to ERRMODE_EXCEPTION.
Eloquent
Section titled “Eloquent”Eloquent is Laravel’s active-record ORM and fluent query builder (illuminate/database), usable standalone outside a full Laravel application via its Capsule Manager. It has by far the largest install base and community of any PHP ORM.
Pick it if your team already thinks in Laravel/Eloquent conventions, or you’re porting existing Eloquent models into a Quiote app — the models themselves don’t need to change.
- Select with
class="eloquent" - Requires
illuminate/database+EloquentPlugin getConnection()returns the CapsuleManager— not the underlying connection. A typed accessor,getEloquentConnection(), reaches the connection itself (see below). Returning the Capsule is what lets the framework’s worker-modeping()/reset()/shutdown()machinery manage it uniformly.
| Parameter | Default | Description |
|---|---|---|
connection | — | Inline config array, or the name of another database to borrow a PDO from (layer mode). Omit for flat params. |
driver | — | mysql | pgsql | sqlite | sqlsrv (required unless supplied via connection) |
host, port | — | Server address |
database | — | Database name (:memory: for sqlite) |
username, password | — | Credentials |
charset, collation, prefix | — | Optional connection options |
connection_name | default | Capsule connection name |
global | false | Call setAsGlobal() (needed for the DB facade) |
boot_eloquent | = global | Call bootEloquent() (needed for Model classes) |
'main' => [ 'class' => 'eloquent', 'parameters' => [ 'driver' => 'pgsql', 'host' => 'localhost', 'port' => 5432, 'database' => 'app', 'username' => 'app', 'password' => 'secret', 'global' => true, ],],main: class: eloquent parameters: driver: pgsql host: localhost port: 5432 database: app username: app password: secret global: true<database name="main" class="eloquent"> <ae:parameter name="driver">pgsql</ae:parameter> <ae:parameter name="host">localhost</ae:parameter> <ae:parameter name="port">5432</ae:parameter> <ae:parameter name="database">app</ae:parameter> <ae:parameter name="username">app</ae:parameter> <ae:parameter name="password">secret</ae:parameter> <ae:parameter name="global">true</ae:parameter></database>/** @var Quiote\Database\Adapter\Eloquent\EloquentDatabase $db */$db = $this->databases->getDatabase('main');$capsule = $db->getCapsule(); // Illuminate\Database\Capsule\Manager$conn = $db->getEloquentConnection(); // Illuminate\Database\Connection$conn->table('users')->where('active', true)->get();Doctrine ORM
Section titled “Doctrine ORM”Doctrine is the classic PHP data-mapper ORM — the opposite design from Eloquent’s active record. Entities are plain PHP objects with no knowledge of persistence, mapped to the schema via attributes or XML, and a UnitOfWork/EntityManager tracks changes and flushes them together.
It’s the de-facto “serious” choice for enterprise and DDD-influenced codebases (it’s Symfony’s own default ORM), trading some of Eloquent’s convenience for persistence-ignorant domain objects and explicit change tracking. This adapter targets modern Doctrine ORM 3 on DBAL 4.
- Select with
class="doctrine" - Requires
doctrine/orm+DoctrinePlugin getConnection()returns anEntityManagerInterface
| Parameter | Default | Description |
|---|---|---|
connection | — | Name of a doctrine_dbal database to reuse, or an inline DBAL params array. |
url | — | DBAL DSN URL (alternative to flat params) |
driver | — | pdo_mysql | pdo_pgsql | pdo_sqlite | … |
host, port, dbname | — | Server + database |
user / username, password | — | Credentials (user preferred) |
path, memory, charset | — | sqlite file / :memory: / charset |
entity_paths | [] | Directories/files holding mapping metadata |
metadata | attribute | attribute | xml |
dev_mode | = core.debug | Proxy auto-generation etc. |
proxy_dir | system temp | Generated proxy directory |
native_lazy_objects | true (PHP 8.4+) | PHP native lazy objects for proxies |
'main' => [ 'class' => 'doctrine', 'parameters' => [ 'driver' => 'pdo_pgsql', 'host' => 'localhost', 'dbname' => 'app', 'user' => 'app', 'password' => 'secret', 'entity_paths' => ['/srv/app/src/Entity'], ],],main: class: doctrine parameters: driver: pdo_pgsql host: localhost dbname: app user: app password: secret entity_paths: - /srv/app/src/Entity<database name="main" class="doctrine"> <ae:parameter name="driver">pdo_pgsql</ae:parameter> <ae:parameter name="host">localhost</ae:parameter> <ae:parameter name="dbname">app</ae:parameter> <ae:parameter name="user">app</ae:parameter> <ae:parameter name="password">secret</ae:parameter> <ae:parameter name="entity_paths"> <ae:parameter>/srv/app/src/Entity</ae:parameter> </ae:parameter></database>/** @var Quiote\Database\Adapter\Doctrine\DoctrineDatabase $db */$em = $db->getEntityManager(); // Doctrine\ORM\EntityManagerInterface$repo = $db->getRepository(User::class); // Doctrine\ORM\EntityRepositoryDoctrine DBAL
Section titled “Doctrine DBAL”DBAL is Doctrine’s connection abstraction and SQL query builder without the entity/unit-of-work layer above it. Reach for it when you want vendor-portable SQL and a fluent query builder but don’t want to commit to entity mapping — reporting queries, admin tooling, or a gradual migration path onto (or off of) the full ORM.
The same DoctrinePlugin registers both the doctrine and doctrine_dbal aliases, since they share one underlying package (doctrine/dbal is itself a dependency of doctrine/orm) — install db-doctrine once and use either or both.
- Select with
class="doctrine_dbal" - Requires
doctrine/dbal+DoctrinePlugin getConnection()returns a DBALConnection- Parameters are the connection subset of Doctrine ORM (
driver,host,port,dbname,user/username,password,path,memory,charset, orurl)
'reporting' => [ 'class' => 'doctrine_dbal', 'parameters' => [ 'driver' => 'pdo_pgsql', 'host' => 'localhost', 'dbname' => 'app', 'user' => 'app', 'password' => 'secret', ],],reporting: class: doctrine_dbal parameters: driver: pdo_pgsql host: localhost dbname: app user: app password: secret<database name="reporting" class="doctrine_dbal"> <ae:parameter name="driver">pdo_pgsql</ae:parameter> <ae:parameter name="host">localhost</ae:parameter> <ae:parameter name="dbname">app</ae:parameter> <ae:parameter name="user">app</ae:parameter> <ae:parameter name="password">secret</ae:parameter></database>/** @var Quiote\Database\Adapter\Doctrine\DoctrineDbalDatabase $db */$conn = $db->getDbalConnection(); // Doctrine\DBAL\Connection$qb = $db->getQueryBuilder(); // Doctrine\DBAL\Query\QueryBuilderCycle ORM
Section titled “Cycle ORM”Cycle (v2) comes out of the Spiral/RoadRunner ecosystem — a data-mapper ORM like Doctrine, but purpose-built to be stateless and schema-first so it behaves correctly across thousands of requests in one long-lived process, rather than assuming PHP’s traditional request-per-process model.
Schema compilation from annotated entities is a separate, cacheable build step done ahead of time — an app/console-time concern, not the adapter’s job — so the adapter consumes an already-compiled schema, not annotations, at request time. That design makes Cycle a natural fit for Quiote’s own worker-mode-first architecture.
Reach for it when you want a data-mapper ORM and are running under FrankenPHP or another persistent worker.
- Select with
class="cycle" - Requires
cycle/orm+cycle/database+CyclePlugin getConnection()returns anORMInterface- Config format:
databases.phponly — Cycle owns its driver configuration via PHP config objects, which XML and YAML can’t express
| Parameter | Description |
|---|---|
cycle | A native Cycle DatabaseConfig array (default, databases, connections). Required. |
schema | A precompiled Cycle schema array (or Cycle\ORM\Schema). |
schema_provider | A callable(self): (Schema|array) returning the schema. |
Schema compilation from annotated entities is an app/console concern — supply a compiled, cached schema here rather than recompiling on every boot.
<?phpuse Cycle\Database\Config\PostgresDriverConfig;use Cycle\Database\Config\Postgres\TcpConnectionConfig;
return [ 'default' => 'main', 'databases' => [ 'main' => [ 'class' => 'cycle', 'parameters' => [ 'cycle' => [ 'default' => 'default', 'databases' => ['default' => ['connection' => 'pg']], 'connections' => [ 'pg' => new PostgresDriverConfig( connection: new TcpConnectionConfig( database: 'app', host: 'localhost', port: 5432, user: 'app', password: 'secret', ), ), ], ], 'schema' => require __DIR__ . '/cycle-schema.php', ], ], ],];/** @var Quiote\Database\Adapter\Cycle\CycleDatabase $db */$orm = $db->getOrm(); // Cycle\ORM\ORMInterface$repo = $db->getRepository('user'); // Cycle\ORM\RepositoryInterfacePropulsion
Section titled “Propulsion”Propulsion is the framework team’s own PHP 8.5-targeted fork of Propel 1 (namespaced Propulsion\) — not a third-party dependency merely wrapped here, but an ORM the Quiote maintainers own and maintain themselves, because upstream Propel is effectively abandoned and this is the ORM their own production app (Jakamo) already runs on. It’s included as a first-class, actively-maintained option rather than a legacy leftover.
Like Propel, Propulsion is code-generation-first, in the classic Hibernate/Propel-tools style — closer in spirit to Cycle’s compiled-schema approach than to Eloquent or Doctrine’s runtime mapping, but pushed further. You author a schema, run bin/propulsion model:build to generate Object Model and Query classes ahead of time, autoload them via Composer, and your application code calls those generated classes directly.
That’s a heavier build-time footprint than the other adapters, but no runtime reflection or annotation parsing at request time. The adapter itself just wires the runtime connection those generated classes use.
- Select with
class="propulsion" - Requires the
quioteframework/propulsionruntime +quioteframework/db-propulsion’sPropulsionPlugin getConnection()returns aPropulsion\Connection\PropulsionPDO
Unlike the other adapters, Propulsion owns its own connection factory — there’s no inline DSN/credentials and no layer mode. You point it at a Propulsion runtime config file instead:
| Parameter | Default | Description |
|---|---|---|
config | — | Path to a Propulsion runtime config file (a PHP file returning an array). Required. |
datasource | resolved from config | Which datasource in the config file to use. |
overrides | [] | Key/value pairs applied to the PropulsionConfiguration after it loads. |
init_queries | [] | Extra on-connect queries, appended to the datasource’s connection settings. |
enable_instance_pooling | — | Boolean; forces Propulsion::enableInstancePooling() / disableInstancePooling(). |
'main' => [ 'class' => 'propulsion', 'parameters' => [ 'config' => __DIR__ . '/propulsion-runtime.php', 'datasource' => 'main', ],],main: class: propulsion parameters: config: /path/to/propulsion-runtime.php datasource: main<database name="main" class="propulsion"> <ae:parameter name="config">/path/to/propulsion-runtime.php</ae:parameter> <ae:parameter name="datasource">main</ae:parameter></database>The runtime config file is Propulsion’s own format — a plain PHP array naming each datasource’s adapter and connection:
<?phpreturn [ 'datasources' => [ 'default' => 'main', 'main' => [ 'adapter' => 'pgsql', 'connection' => [ 'dsn' => 'pgsql:host=localhost;dbname=app', 'user' => 'app', 'password' => 'secret', ], ], ],];/** @var Quiote\Database\Adapter\Propulsion\PropulsionDatabase $db */$conn = $db->getPropulsionConnection(); // Propulsion\Connection\PropulsionPDO$ds = $db->getDatasource(); // string, e.g. 'main'
// Generated query + model classes (autoloaded via Composer):$books = \App\Model\BookQuery::create()->filterByPublished(true)->find();Propel-style ORMs keep an instance pool per model; reset() clears it (via Propulsion::getSession()->reset()) at the worker request boundary so identity state never leaks between requests, while the connection is kept and recycled like every other adapter. Process-wide state (the service container) and per-request state (the session) are separate — reached via the Propulsion facade’s own getServiceContainer()/getSession(), not through the Quiote adapter.
Generating model classes
Section titled “Generating model classes”Author a schema and run the Propulsion CLI (ships with the quioteframework/propulsion runtime, independent of bin/quiote) to generate the model:
php bin/propulsion model:build schema.xml \ --output-dir=src/Model \ --target-platform=php84 \ --database=pgsqlTreat generated classes like Cycle’s compiled schema: commit or cache them, and regenerate deliberately rather than on every boot. PostgreSQL is Propulsion’s default/recommended target; MySQL, SQLite, Oracle, and MSSQL are also supported.
Layer mode vs standalone mode
Section titled “Layer mode vs standalone mode”ORM adapters resolve their connection two ways:
- Standalone — the adapter builds its own connection from the parameters you give it.
- Layer mode — set
connectionto the name of another configured database; the ORM reuses that connection. Credentials live in one place and the underlying ping/reconnect is shared.
| Adapter | Layer mode |
|---|---|
| Eloquent | ✅ borrows the referenced PDO (driver still required) |
| Doctrine DBAL | ✅ via inline params (DBAL 4 can’t wrap a raw PDO) |
| Doctrine ORM | ✅ but only against a doctrine_dbal database, not a raw pdo |
| Cycle | Uses its own cycle driver config |
| Propulsion | Uses its own config runtime file — no layer mode |
Using a connection in application code
Section titled “Using a connection in application code”// The wrapper (lifecycle):$db = $this->databases->getDatabase('main');
// The underlying PDO / ORM object (generic):$conn = $db->getConnection();
// Typed, per adapter — cast the wrapper, then use its accessor:/** @var Quiote\Database\Adapter\Doctrine\DoctrineDatabase $db */$em = $db->getEntityManager();Omit the name to use the default connection. $this->databases is an injected Quiote\Database\DatabaseManager — put query logic in a service that declares it, and keep actions thin.
Raw PDO access
Section titled “Raw PDO access”Database::getConnection() deliberately returns each adapter’s native object — a \PDO for the plain PDO adapter, but an Eloquent Capsule, a Doctrine EntityManager/Connection, or a Cycle ORM for the ORM adapters (see Mental model). That’s intentional: getConnection() returns the thing you configured, and the typed accessors (getCapsule(), getEntityManager(), getOrm(), …) are how you reach ORM-specific functionality. Overloading getConnection()’s return type per adapter would make it untypeable and unpredictable at call sites.
getPdo(): \PDO is a separate, uniform low-level path for the one thing that’s useful across every adapter regardless of which ORM it wraps: a raw PDO handle for hand-written SQL — a custom query, a driver-specific optimization the ORM’s query builder can’t express, or code shared with something that already speaks PDO. Call it on the Database wrapper itself:
$pdo = $this->databases->getDatabase('main')->getPdo();$stmt = $pdo->prepare('SELECT * FROM orders WHERE status = ?');$stmt->execute(['shipped']);Because getPdo(): \PDO is declared on the Database base class itself — not an interface implemented conditionally — PHPStan and IDEs know the return type with no @var annotation or instanceof narrowing at the call site. Every adapter either returns a real \PDO or throws a DatabaseException explaining why it can’t:
| Adapter | getPdo() |
|---|---|
| PDO | Always works — getConnection() already is the \PDO. |
| Propulsion | Always works — returns the PropulsionPDO (extends \PDO) instance. |
| Eloquent | Always works — Illuminate’s connectors always build a \PDO internally, in both standalone and layer mode. |
| Doctrine DBAL | Works only if the configured driver is a pdo_* one (pdo_mysql, pdo_pgsql, pdo_sqlite, …). Throws for native drivers (mysqli, pgsql, sqlite3, …). |
| Doctrine ORM | Same rule as Doctrine DBAL — it unwraps the ORM’s underlying DBAL connection. |
| Cycle | Always throws. Cycle never exposes a raw PDO publicly (see below). |
Doctrine DBAL 4: native drivers vs. PDO drivers
Section titled “Doctrine DBAL 4: native drivers vs. PDO drivers”DBAL 4 dropped its historical PDO-only design. It now ships both native extension drivers (mysqli, pgsql) and PDO-wrapping drivers (pdo_mysql, pdo_pgsql, pdo_sqlite, pdo_sqlsrv, pdo_oci) side by side, selected via the driver connection parameter. Doctrine\DBAL\Connection::getNativeConnection() returns whichever one you picked — a \PDO for pdo_* drivers, or a \mysqli/\PgSql\Connection/etc. for native ones. There’s no way to force a native connection to become a PDO after the fact.
If you need getPdo() to work on a Doctrine-backed database, configure it with a pdo_* driver. This is also why Doctrine ORM’s layer mode (reusing another database’s connection) requires referencing a doctrine_dbal database by name rather than a plain pdo one — DBAL 4 cannot wrap a pre-existing PDO instance, only build its own from connection parameters (see Layer mode vs standalone mode).
If you’ve configured a native driver deliberately (e.g. for mysqli-specific features, or to avoid PDO’s overhead) and need custom SQL, use DBAL’s own raw SQL API instead of getPdo():
$dbal = $db->getDbalConnection();$rows = $dbal->fetchAllAssociative('SELECT * FROM orders WHERE status = ?', ['shipped']);$dbal->executeStatement('UPDATE orders SET status = ? WHERE id = ?', ['cancelled', $id]);Cycle ORM: there is no raw PDO, by design
Section titled “Cycle ORM: there is no raw PDO, by design”Cycle’s Driver class deliberately keeps its connection internal: Driver::getPDO() is protected, and its own return type is \PDO|PDOInterface — Cycle allows a driver to be backed by something other than PDO entirely (e.g. a pooled/proxy connection), so there is no public, type-safe way to reach through to a \PDO even when one happens to be there under the hood. Reflecting into a third-party library’s protected internals to work around that would be fragile and break silently on a Cycle upgrade, so CycleDatabase::getPdo() always throws a DatabaseException pointing at the alternative.
For custom or optimized SQL with Cycle, use Cycle’s own low-level SQL paths instead of dropping to PDO:
Cycle\Database\Injection\Fragment/Expression— inject a raw SQL snippet into an otherwise query-builder-driven call, still parameterized. Use this when most of the query is builder-generated and only one expression needs to be database-specific:$select->where('created_at', '>', new Fragment('NOW() - INTERVAL 1 DAY'));DatabaseInterface::query()/execute()— fully hand-written, parameterized SQL through Cycle’s own driver (still gets Cycle’s parameter binding, logging, and transaction handling):$database = $db->getCycleDatabaseManager()->database();$rows = $database->query('SELECT * FROM orders WHERE status = ?', ['shipped'])->fetchAll();$affected = $database->execute('UPDATE orders SET status = ? WHERE id = ?', ['cancelled', $id]);
This is the equivalent of PDO::prepare()->execute() for Cycle, and is the intended way to write custom SQL against a Cycle-backed database — not a workaround for a missing feature.
Worker mode and connection lifecycle
Section titled “Worker mode and connection lifecycle”Quiote runs long-lived workers (FrankenPHP). Connections are opened lazily and kept across requests; per-request state is cleared. Each adapter implements:
| Hook | When | Behaviour |
|---|---|---|
ping() | connection recycling | Runs a lightweight SELECT 1; nulls a dead connection so the next use reconnects. |
reset() | request boundary | Clears per-request state — Doctrine EntityManager::clear(), Cycle Heap::clean(), Propel instance pools. |
shutdown() | teardown | Rolls back any dangling transaction and closes the connection. |
The rule: expensive, stateless artifacts survive across requests (connections, compiled metadata/schema); per-request mutable state is cleared (identity maps, open transactions). You don’t call these — the framework does, via DatabaseManager::recycleConnections() and the container’s request reset.
Installing the libraries
Section titled “Installing the libraries”Each ORM adapter lives in its own optional package that carries the underlying library — a bare install pulls none of them. Install the package for the ORM you want (then register its plugin, above):
composer require quioteframework/db-eloquent # Eloquent (illuminate/database)composer require quioteframework/db-doctrine # Doctrine ORM + DBALcomposer require quioteframework/db-cycle # Cycle (cycle/orm + cycle/database)composer require quioteframework/db-propulsion # Propulsion (quioteframework/propulsion runtime)You also need the PDO driver for your database compiled into PHP (pdo_pgsql, pdo_mysql, pdo_sqlite, …). The database servers aren’t needed locally for tests (see below); only the client drivers.
Recording a request’s queries
Section titled “Recording a request’s queries”An optional replay-* package per adapter records every query a request runs into that request’s cassette, so a recorded production failure can be replayed — and, for Doctrine and Propulsion, replayed in isolation, served from the recorded rows rather than the real database:
| Adapter | Package | Records rows | Can replay in isolation |
|---|---|---|---|
| Propulsion | replay-propulsion | Yes | Yes |
| Doctrine ORM / DBAL | replay-doctrine | Yes | Yes |
| Eloquent | replay-eloquent | No | No |
| Cycle | replay-cycle | No | No |
raw pdo | — | — | — |
Each registers its recording subclass under the same driver alias the adapter’s own plugin registers, so there is no databases.* change — list the replay plugin after the adapter’s. Why two of the four can only watch is Eloquent and Cycle can only watch.
Integration testing
Section titled “Integration testing”Real-database integration tests live in the adapter packages themselves (packages/db-eloquent/tests, packages/db-doctrine/tests, packages/db-cycle/tests), tagged #[Group('integration')] and excluded from the default composer test. They spin up real MySQL and PostgreSQL via Testcontainers and run CRUD round-trips through the Eloquent, Doctrine (ORM and DBAL), and Cycle adapters (the Propulsion and raw-PDO adapters have unit coverage but no container-backed integration suite):
composer test:integrationRequires Docker. Tests skip cleanly when Docker or the relevant PDO driver is unavailable.
Writing a custom adapter
Section titled “Writing a custom adapter”Extend Quiote\Database\AbstractOrmDatabase, build your ORM into $this->connection in connect(), and expose typed accessors:
use Quiote\Database\AbstractOrmDatabase;
class MyOrmDatabase extends AbstractOrmDatabase{ protected function connect() { $this->requireLibrary(\My\Orm::class, 'vendor/my-orm'); $pdo = $this->resolveUnderlyingPdo(); // layer mode, if referencing another db $this->connection = new \My\Orm($pdo /* ... */); }
public function getMyOrm(): \My\Orm { return $this->getConnection(); }
#[\Override] public function ping(): bool { /* SELECT 1, null on failure */ }}Ship it as a plugin to get a short alias:
use Quiote\Plugin\{PluginInterface, PluginRegistrar};use Quiote\Plugin\Attribute\Plugin;
#[Plugin(name: 'vendor/my-orm')]final class MyOrmPlugin implements PluginInterface{ public function register(PluginRegistrar $registrar): void { $registrar->databaseDriver('myorm', MyOrmDatabase::class); }}AbstractOrmDatabase gives you resolveUnderlyingConnection() / resolveUnderlyingPdo() (layer/standalone resolution), requireLibrary() (a friendly “install this package” guard), and a default worker-safe shutdown().