Database¶
Namespace Keel\Database. Keel has no ORM and no query builder: it wires the connection you
already know into the container, keeps it for the life of the worker and watches it from
/health. Pick plain PDO or Doctrine DBAL.
PDO¶
// config/database.php
return static fn(Env $env): PdoConfig => new PdoConfig(
$env->get('DATABASE_DSN') ?? 'mysql:host=127.0.0.1;dbname=app;charset=utf8mb4',
$env->get('DATABASE_USER'),
$env->get('DATABASE_PASSWORD'),
);
// config/services.php
return new ServicesConfig([PdoProvider::class]);
PdoProvider registers \PDO as a shared service created on first use, with exceptions
(ERRMODE_EXCEPTION), associative fetches, native prepares and no stringified fetches unless
PdoConfig::$options says otherwise. It also registers PdoCheck, tagged keel.health, which
runs SELECT 1, reports the latency and flags a transaction left open as degraded. The check
gets the connection lazily (a closure resolving \PDO from the container), so a database that
cannot be reached is a down entry in /health instead of an exception while the middleware is
built; the connection is retried on the next probe and kept once it succeeds. Build your own
checks the same way: new PdoCheck(fn() => $container->get(\PDO::class), 'replica'). The error
message is logged, not shown, unless HealthConfig::$exposeDetails is on
(see Health and metrics).
final class UserRepository
{
public function __construct(private readonly \PDO $pdo) {}
}
Doctrine DBAL¶
composer require doctrine/dbal
// config/database.php
return static fn(Env $env): DbalConfig => DbalConfig::fromUrl($env->get('DATABASE_URL') ?? 'sqlite:///:memory:');
// config/services.php
return new ServicesConfig([DbalProvider::class]);
DbalConfig::fromUrl() understands mysql://, mariadb://, pgsql://, sqlite:///path,
sqlite:///:memory:, sqlsrv:// and oci://; new DbalConfig([...]) takes the parameter array
of DriverManager::getConnection() as is. DbalProvider registers Doctrine\DBAL\Connection
(DBAL connects lazily on the first query) and DbalCheck, tagged keel.health, which runs the
platform's dummy select, reports the latency and flags a transaction left open as degraded.
Worker mode¶
The connection outlives the request. Two rules follow:
- Never leave a transaction open. Wrap transactional work in
try/finallyorConnection::transactional();DbalCheckreports an open transaction as degraded, because the next request on that worker would run inside it. - Expect the server to drop idle connections. DBAL reconnects after
close(); with PDO, catch the "server has gone away" exception in a retry of your own, or lower the worker'sMAX_REQUESTSbelow the server's idle timeout divided by your request rate. The shared\PDOis not recreated by the framework: once the server has dropped it,PdoCheckreports the worker as down until it is restarted, which is what a readiness probe should do.
Per-request state (identity maps, caches of the current user) belongs in Resettable services,
not on the connection.
Migrations and ORMs¶
Doctrine Migrations, Phinx and Doctrine ORM work on top of the DBAL connection: register
their services in a provider that takes Connection from the container. An ORM's entity
manager is per-request state in worker mode; make the service Resettable and call
clear() in reset().