Skip to content

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.

Getting a database working touches three config locations. Keeping them straight up front saves a lot of confusion later:

WhatWhere it goesCovered in
Turn the database layer oncore.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.

Quiote doesn’t pick a favorite ORM — it picks this lineup because each one covers a different, genuinely distinct reason to reach for it:

AdapterStylePick it when…
PDORaw driver, no ORMYou 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.
EloquentActive recordYou 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 ORMData mapperYou 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 DBALQuery builder, no entity layerYou 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 ORMData mapper, schema-compiledYou 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.
PropulsionCode-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.

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:

AdaptergetConnection() returns
PDOPDO
EloquentIlluminate\Database\Capsule\Manager
Doctrine ORMDoctrine\ORM\EntityManagerInterface
Doctrine DBALDoctrine\DBAL\Connection
Cycle ORMCycle\ORM\ORMInterface
PropulsionPropulsion\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.

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() calls DatabaseManager::recycleConnections(), which ping()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.

The whole database layer is gated by one setting. It lives in settings, alongside the other core.use_* subsystem switches:

Config/settings.php
return [
'core.use_database' => true,
];

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.

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.

Config/databases.php
return [
'default' => 'main',
'databases' => [
'main' => [
'class' => 'pdo',
'parameters' => [
'dsn' => 'pgsql:host=localhost;dbname=app',
'username' => 'app',
'password' => 'secret',
],
],
],
];
  • 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 on core.environment inside the file (or names it in a parent path) — see Environments and contexts.
  • In XML, array parameters nest <ae:parameter> (named ones become assoc entries, unnamed ones become list entries).

class accepts a fully-qualified adapter class or a short alias. Only pdo is built in; the ORM aliases are added by their plugins.

AliasAdapter class
pdoQuiote\Database\PdoDatabase
eloquentQuiote\Database\Adapter\Eloquent\EloquentDatabase
doctrineQuiote\Database\Adapter\Doctrine\DoctrineDatabase
doctrine_dbalQuiote\Database\Adapter\Doctrine\DoctrineDbalDatabase
cycleQuiote\Database\Adapter\Cycle\CycleDatabase
propulsionQuiote\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.

Two steps: install the library (see Installing the libraries), and register the plugin so its alias is available:

Config/plugins.php
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],
];

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 a PDO
ParameterDefaultDescription
dsnPDO DSN (required)
username / passwordCredentials
options[]PDO driver options (constant names as strings allowed)
attributes[]PDO attributes set after connect
init_queries[]Queries executed on connect
warn_mysql_charsettrueGuards 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 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 Capsule Managernot the underlying connection. A typed accessor, getEloquentConnection(), reaches the connection itself (see below). Returning the Capsule is what lets the framework’s worker-mode ping()/reset()/shutdown() machinery manage it uniformly.
ParameterDefaultDescription
connectionInline config array, or the name of another database to borrow a PDO from (layer mode). Omit for flat params.
drivermysql | pgsql | sqlite | sqlsrv (required unless supplied via connection)
host, portServer address
databaseDatabase name (:memory: for sqlite)
username, passwordCredentials
charset, collation, prefixOptional connection options
connection_namedefaultCapsule connection name
globalfalseCall setAsGlobal() (needed for the DB facade)
boot_eloquent= globalCall bootEloquent() (needed for Model classes)
'main' => [
'class' => 'eloquent',
'parameters' => [
'driver' => 'pgsql', 'host' => 'localhost', 'port' => 5432,
'database' => 'app', 'username' => 'app', 'password' => 'secret',
'global' => true,
],
],
/** @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 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 an EntityManagerInterface
ParameterDefaultDescription
connectionName of a doctrine_dbal database to reuse, or an inline DBAL params array.
urlDBAL DSN URL (alternative to flat params)
driverpdo_mysql | pdo_pgsql | pdo_sqlite | …
host, port, dbnameServer + database
user / username, passwordCredentials (user preferred)
path, memory, charsetsqlite file / :memory: / charset
entity_paths[]Directories/files holding mapping metadata
metadataattributeattribute | xml
dev_mode= core.debugProxy auto-generation etc.
proxy_dirsystem tempGenerated proxy directory
native_lazy_objectstrue (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'],
],
],
/** @var Quiote\Database\Adapter\Doctrine\DoctrineDatabase $db */
$em = $db->getEntityManager(); // Doctrine\ORM\EntityManagerInterface
$repo = $db->getRepository(User::class); // Doctrine\ORM\EntityRepository

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 DBAL Connection
  • Parameters are the connection subset of Doctrine ORM (driver, host, port, dbname, user/username, password, path, memory, charset, or url)
'reporting' => [
'class' => 'doctrine_dbal',
'parameters' => [
'driver' => 'pdo_pgsql', 'host' => 'localhost', 'dbname' => 'app',
'user' => 'app', 'password' => 'secret',
],
],
/** @var Quiote\Database\Adapter\Doctrine\DoctrineDbalDatabase $db */
$conn = $db->getDbalConnection(); // Doctrine\DBAL\Connection
$qb = $db->getQueryBuilder(); // Doctrine\DBAL\Query\QueryBuilder

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 an ORMInterface
  • Config format: databases.php only — Cycle owns its driver configuration via PHP config objects, which XML and YAML can’t express
ParameterDescription
cycleA native Cycle DatabaseConfig array (default, databases, connections). Required.
schemaA precompiled Cycle schema array (or Cycle\ORM\Schema).
schema_providerA 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.

Config/databases.php
<?php
use 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\RepositoryInterface

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/propulsion runtime + quioteframework/db-propulsion’s PropulsionPlugin
  • getConnection() returns a Propulsion\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:

ParameterDefaultDescription
configPath to a Propulsion runtime config file (a PHP file returning an array). Required.
datasourceresolved from configWhich 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_poolingBoolean; forces Propulsion::enableInstancePooling() / disableInstancePooling().
'main' => [
'class' => 'propulsion',
'parameters' => [
'config' => __DIR__ . '/propulsion-runtime.php',
'datasource' => 'main',
],
],

The runtime config file is Propulsion’s own format — a plain PHP array naming each datasource’s adapter and connection:

propulsion-runtime.php
<?php
return [
'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.

Author a schema and run the Propulsion CLI (ships with the quioteframework/propulsion runtime, independent of bin/quiote) to generate the model:

Terminal window
php bin/propulsion model:build schema.xml \
--output-dir=src/Model \
--target-platform=php84 \
--database=pgsql

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

ORM adapters resolve their connection two ways:

  • Standalone — the adapter builds its own connection from the parameters you give it.
  • Layer mode — set connection to the name of another configured database; the ORM reuses that connection. Credentials live in one place and the underlying ping/reconnect is shared.
AdapterLayer 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
CycleUses its own cycle driver config
PropulsionUses its own config runtime file — no layer mode
// 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.

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:

AdaptergetPdo()
PDOAlways works — getConnection() already is the \PDO.
PropulsionAlways works — returns the PropulsionPDO (extends \PDO) instance.
EloquentAlways works — Illuminate’s connectors always build a \PDO internally, in both standalone and layer mode.
Doctrine DBALWorks only if the configured driver is a pdo_* one (pdo_mysql, pdo_pgsql, pdo_sqlite, …). Throws for native drivers (mysqli, pgsql, sqlite3, …).
Doctrine ORMSame rule as Doctrine DBAL — it unwraps the ORM’s underlying DBAL connection.
CycleAlways 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’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.

Quiote runs long-lived workers (FrankenPHP). Connections are opened lazily and kept across requests; per-request state is cleared. Each adapter implements:

HookWhenBehaviour
ping()connection recyclingRuns a lightweight SELECT 1; nulls a dead connection so the next use reconnects.
reset()request boundaryClears per-request state — Doctrine EntityManager::clear(), Cycle Heap::clean(), Propel instance pools.
shutdown()teardownRolls 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.

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

Terminal window
composer require quioteframework/db-eloquent # Eloquent (illuminate/database)
composer require quioteframework/db-doctrine # Doctrine ORM + DBAL
composer 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.

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:

AdapterPackageRecords rowsCan replay in isolation
Propulsionreplay-propulsionYesYes
Doctrine ORM / DBALreplay-doctrineYesYes
Eloquentreplay-eloquentNoNo
Cyclereplay-cycleNoNo
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.

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

Terminal window
composer test:integration

Requires Docker. Tests skip cleanly when Docker or the relevant PDO driver is unavailable.

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