Database
Prepared statements only, typed rows, transactions, and why the connection is a singleton.
Connection wraps PDO and only speaks in prepared statements. There is no method that takes a fully-built query with values in it.
Choosing an engine
SQLite, MySQL/MariaDB and PostgreSQL are supported. Pick one in .env; nothing in your application changes.
# sqlite | mysql | pgsql ("postgres", "postgresql" and "mariadb" also work)
DB_DRIVER=sqlite
# sqlite: a file path, resolved against the project root. ":memory:" works too.
# mysql/pgsql: the database name, and required.
DB_DATABASE=storage/app.sqlite
# Ignored by sqlite. Port defaults to 3306 (mysql) or 5432 (pgsql).
#DB_HOST=127.0.0.1
#DB_PORT=3306
#DB_USERNAME=orbit
#DB_PASSWORD=
#DB_CHARSET=utf8mb4
#DB_SOCKET=/var/run/mysqld/mysqld.sock<?php
use PhpOrbit\Database\Connection;
use PhpOrbit\Database\DatabaseSettings;
// Read and validated once, at boot.
$database = Connection::connect(DatabaseSettings::fromEnvironment($env, $root));
// Or explicitly, without an .env
$database = Connection::connect(DatabaseSettings::postgres('orbit', 'db.internal'));
$database = Connection::connect(DatabaseSettings::mysql('orbit', 'db.internal'));
$database = Connection::sqlite($root . '/storage/app.sqlite');Settings are validated at boot, so a missing database name or an out-of-range port stops the application starting rather than surfacing on whichever request queries first:
$ ./orbit routes
Configuration error: Setting "DB_DRIVER" is not a valid database driver.
Accepted values: sqlite, mysql, pgsql.What the framework smooths over
| Difference between engines | How it is handled |
|---|---|
MySQL reads "x" as a string, not an identifier | Identifiers are quoted with backticks there, double quotes elsewhere. |
OFFSET without LIMIT is a syntax error on two of the three | The builder emits each engine's idiom. |
| Auto-increment keys are spelled three ways | $database->driver()->autoIncrementPrimaryKey(), for migrations. |
PostgreSQL's lastInsertId() is session-wide | insert() uses RETURNING there. |
| MySQL truncates over-long values by default | A strict sql_mode is set per connection. |
| SQLite ignores foreign keys by default | PRAGMA foreign_keys is set per connection. |
Switching MySQL's sql_mode to ANSI_QUOTES would be the other way to make double quotes work, and a worse one: it changes how every other statement on that connection is parsed, including SQL you wrote yourself.
Registered as a singleton at boot, then injected wherever it is needed:
<?php
$app->container->singleton(Connection::class, static fn (): Connection => $database);Reading
<?php
// list<array<string, scalar|null>>
$rows = $database->select(
'SELECT id, title FROM articles WHERE author_id = :author ORDER BY created_at DESC',
['author' => $authorId],
);
// array<string, scalar|null>|null
$article = $database->selectOne(
'SELECT * FROM articles WHERE id = :id',
['id' => $id],
);
// A single scalar
$count = (int) $database->selectValue('SELECT COUNT(*) FROM articles');Writing
<?php
$changed = $database->execute(
'UPDATE articles SET title = :title WHERE id = :id',
['title' => $title, 'id' => $id],
);
$id = (int) $database->lastInsertId();Schema statements
<?php
$database->executeSchema('CREATE TABLE articles (id INTEGER PRIMARY KEY, title TEXT NOT NULL)');Separate from execute() because it binds nothing. Reaching for a method with no parameter binding should be a visible decision, not a convenient shortcut for interpolating a value.
Why values are always bound
<?php
// There is no API that accepts this. Deliberately.
$database->select("SELECT * FROM users WHERE email = '{$email}'"); // no such thing
// This is the only way, and a value can never be parsed as SQL.
$database->select('SELECT * FROM users WHERE email = :email', ['email' => $email]);Emulated prepares are switched off. With emulation on, PDO interpolates values itself before sending the query, which reintroduces exactly the class of bug prepared statements exist to remove.
No driver can bind a table or column name — placeholders only work for values. Anything dynamic there must come from a list your application controls, never from a request. The query builder whitelists identifiers for you.
Transactions
<?php
$articleId = $database->transaction(function (Connection $database) use ($data): int {
$id = (int) $database->query('articles')->insert($data);
$database->query('audit')->insert([
'action' => 'article.created',
'article_id' => $id,
]);
return $id; // committed, and returned
});The closure's return value is passed through. If it throws, the transaction rolls back and the exception propagates.
<?php
$database->inTransaction(); // bool
$database->rollBackIfOpen(); // for cleanup, not control flowUnder a worker the connection outlives the request. A handler that opens a transaction and throws would hand the next request a connection inside someone else's transaction — their writes would commit or roll back with work they never made. TransactionGuard rolls back anything left open and logs it, so the bug is reported rather than silently corrupting an unrelated request.
Per-request SAPIs hide this entirely: the process dies and the driver cleans up. It only appears under a worker, which is why the cleanup is explicit rather than left to the runtime.
Typed rows
PDO returns untyped arrays. Connection narrows them once, at the driver boundary, so nothing downstream deals in mixed:
<?php
$row = $database->selectOne('SELECT id, title, published FROM articles WHERE id = :id', ['id' => $id]);
// array<string, scalar|null> — narrow to what you need
$id = (int) ($row['id'] ?? 0);
$title = (string) ($row['title'] ?? '');
$published = (bool) ($row['published'] ?? false);Errors
<?php
use PhpOrbit\Database\QueryFailed;
try {
$database->execute('INSERT INTO articles (title) VALUES (:title)', ['title' => $title]);
} catch (QueryFailed $e) {
// "Query failed: UNIQUE constraint failed: articles.title
// -- SQL: INSERT INTO articles (title) VALUES (:title)"
}The SQL is written by you and is what makes a failure diagnosable. The bound parameters are user data, and routinely contain passwords, tokens and personal information that would then travel into logs and bug reports.
Bringing your own PDO
Any PDO instance works, provided it is configured the same way and told which engine it is talking to:
<?php
use PhpOrbit\Database\Driver;
$pdo = new PDO($dsn, $user, $password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$database = new Connection($pdo, Driver::PostgreSql);The driver is what the builder consults for quoting and paging, so passing the wrong one produces SQL the server will reject.
Portable migrations
Most DDL is accepted by all three. The primary key is not:
<?php
public function up(Connection $database): void
{
$database->executeSchema(sprintf(
'CREATE TABLE notes (
id %s,
title TEXT NOT NULL,
created_at TEXT NOT NULL
)',
$database->driver()->autoIncrementPrimaryKey(),
));
}Index a VARCHAR, not a TEXT. MySQL cannot build a unique index on a TEXT column without a prefix length, so columns you intend to index should be declared VARCHAR(n).
DDL is not transactional. SQLite and PostgreSQL roll a failed migration back cleanly; MySQL commits implicitly on most schema changes, so a failure there can leave a half-applied migration behind. Keep MySQL migrations small for that reason — $database->driver()->supportsTransactionalDdl() reports which you are on.