Database¶
naf/database provides a configured PDO connection and a migration runner. Query results,
prepared statements and transactions use PDO directly. The optional ORM adds
entity mapping and repositories.
Configure a connection in app/config.php and enable the PDO driver for your database.
Migration commands additionally require naf/cli.
Required packages
In an existing NAF application:
composer require naf/database
Getting the connection¶
use function Naf\Database\database;
$rows = database()->query('SELECT * FROM users')->fetchAll();
database() returns the configured PDO connection. Its query and transaction APIs are
standard PDO methods.
It is typed ?PDO and returns null when database configuration is absent.
Since naf/database 0.2.4, the plugin also registers the configured connection as
PDO::class in the container for constructor injection. Resolving that binding without
database configuration throws DatabaseException; the database() helper remains nullable.
PDO defaults¶
The connection is created with:
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
Errors throw. A failed query raises PDOException instead of returning false, so
if (!$stmt) is a branch that never runs. Wrap what you want to handle and let the rest
reach the error handler.
Rows arrive as associative arrays. No PDO::FETCH_ASSOC on every call, and no numeric
duplicates of every column.
$stmt = database()->prepare('SELECT * FROM users WHERE id = :id');
$stmt->execute(['id' => 1]); // throws on failure; its bool return is not the row
$user = $stmt->fetch();
Prepared statements¶
$stmt = database()->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch();
Named placeholders or ? — PDO accepts both, but not mixed in one statement. Bind values through prepared statements so user input is not concatenated into SQL.
Placeholders represent values, not table names or SQL syntax; allow-list dynamic identifiers.
Do not interpolate untrusted values into a query:
database()->query("SELECT * FROM users WHERE email = '$email'"); // no
Transactions¶
$pdo = database();
$pdo->beginTransaction();
try {
$pdo->prepare('UPDATE accounts SET balance = balance - :n WHERE id = :from')
->execute(['n' => 100, 'from' => 1]);
$pdo->prepare('UPDATE accounts SET balance = balance + :n WHERE id = :to')
->execute(['n' => 100, 'to' => 2]);
$pdo->commit();
} catch (\Throwable $e) {
$pdo->rollBack();
throw $e;
}
PDO exceptions enter the catch block, which rolls back and rethrows. Production transfer
logic must also validate amounts, authorization and affected rows; a transaction alone does
not enforce those business rules.
Migrations¶
A migration is a class with up() and down() methods that receive the PDO connection.
Application migrations may live in app/Migrations or src/Migrations; both directories
are discovered when present. The commands require the optional CLI package:
composer require naf/cli
Create a migration¶
vendor/bin/naf db:migration:create
The command takes no name. It writes app/Migrations/Migration<timestamp>.php (always
under app/, even in a src/ layout) with this skeleton:
<?php
declare(strict_types=1);
namespace App\Migrations;
use Naf\Database\Core\AbstractMigration;
use PDO;
class Migration1760000000 extends AbstractMigration
{
public function up(PDO $connection): void
{
}
public function down(PDO $connection): void
{
}
}
Fill in both directions. down() must undo exactly what up() created:
public function up(PDO $connection): void
{
$connection->exec('CREATE TABLE products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(200) NOT NULL,
price_cents INTEGER NOT NULL
)');
}
public function down(PDO $connection): void
{
$connection->exec('DROP TABLE products');
}
You may rename the class and its file to something descriptive, such as
CreateProductsTable; keep the class name, file name and namespace consistent. Migrations
are tracked by fully qualified class name and run in file-name order, so keep a sortable
prefix (for example the timestamp) when you rename them. The SQL above uses SQLite syntax;
write the DDL your database expects. The JSON API tutorial
shows a complete, tested migration.
Run and roll back¶
vendor/bin/naf db:migrate up # apply every migration that has not run yet
vendor/bin/naf db:migrate up --name=CreateProductsTable
vendor/bin/naf db:migrate down --name=CreateProductsTable
Pass the direction as the positional argument up or down. --name (short -n) selects
exactly one migration by class name or file name without .php, or by fully qualified class
name; a name that matches nothing or several migrations fails without running anything.
db:migrate down without --name reverts every applied migration
Without --name, down runs down() for all applied migrations in reverse order,
including the tables that plugins contributed (sessions, OAuth, RBAC and others). This
usually deletes data. Roll back a single migration with --name, and take a backup
before running down against a database that matters.
Plugins bring their own¶
app/Migrations is not the only source. A plugin that needs a table registers its own
directory, so naf/session with database storage, naf/oauth-client and naf/oauth-server
all contribute migrations that db:migrate up runs alongside yours.
That is why a fresh install of one of those packages usually ends with a migration step, and why you do not have to copy anybody's schema into your own migrations folder.
To inspect what would run without applying it, use database 0.2.4+ with an existing migration tracker:
use Naf\Database\Core\MigrationRunner;
use Naf\Database\Support\MigrationRegistry;
use function Naf\Database\database;
$pdo = database() ?? throw new RuntimeException('Database is not configured.');
$pending = (new MigrationRunner($pdo))->pending(MigrationRegistry::getPaths());
$pending is a list of fully qualified migration class names in the runner's global order.
Inspection uses the same shouldRun() rules as execution. It does not create the tracker,
run migrations or rewrite legacy names; a missing tracker or database error is reported.
Migrations and transactions¶
Migrations run in the order every registered path agrees on, are tracked by class name, and roll back in the reverse of the order they were applied.
How much of that is transactional depends on the engine, and the difference matters when one fails halfway:
| Engine | Behaviour |
|---|---|
| PostgreSQL, SQLite | schema changes and the tracking happen in one transaction — a failure leaves nothing behind |
| MySQL, MariaDB | DDL commits implicitly, so a failed migration can leave part of its work applied |
Write MySQL migrations so that running them again after a failure is safe. There is no transaction to undo the statements that already committed.
Do not run migrations inside your own transaction
The runner manages its own, and nesting one inside an application transaction leaves the tracking and the schema able to disagree with each other.
Configuration¶
The database array in app/config.php selects the driver and connection:
| Key | Type | Default | Effect |
|---|---|---|---|
database:driver |
string | mysql |
mysql, pgsql or sqlite; any other value throws DatabaseException |
database:host |
string | 127.0.0.1 |
Server host (MySQL, PostgreSQL) |
database:port |
int | 3306 / 5432 |
Server port; the default depends on the driver |
database:database |
string | ''; SQLite :memory: |
Database name, or the SQLite file path |
database:username |
string | null |
Login name |
database:password |
string | null |
Password; use an ENV: reference |
database:charset |
string | utf8mb4 |
MySQL connection character set |
database:migrationPaths |
list of directories | [] |
Additional directories scanned for migrations |
Without a database array, database() returns null and migration commands report
Database connection not found.
'database' => [
'driver' => 'mysql',
'host' => '127.0.0.1',
'database' => 'naf',
'username' => 'naf',
'password' => 'ENV:DB_PASSWORD',
'charset' => 'utf8mb4',
],
'database' => [
'driver' => 'pgsql',
'host' => '127.0.0.1',
'port' => 5432,
'database' => 'naf',
'username' => 'naf',
'password' => 'ENV:DB_PASSWORD',
],
'database' => [
'driver' => 'sqlite',
'database' => BASE_PATH . '/storage/app.sqlite', // or ':memory:'
],
Enable the matching PDO extension (pdo_mysql, pdo_pgsql or pdo_sqlite). SQLite takes a
file path instead of a host and defaults to an in-memory database when you give none; its
parent directory must be writable.
An in-memory database lasts only for the connection. Use a persistent file path for data that must survive requests or process restarts.
Keep credentials in protected environment values. Use ENV:DB_PASSWORD in configuration
with framework 0.2.8+ when values may come from the process environment. Direct $_ENV reads
only see values in that array. env() returns the application environment name.
Connection errors¶
A connection that cannot be made throws Naf\Database\Exceptions\DatabaseException,
wrapping the original PDOException message. It happens while the container builds the
connection. The failure appears when a consumer first resolves it; eager resolution during
boot can therefore fail before the first query.
If the result is different¶
| What you see | What to check |
|---|---|
database() returns null, or Database connection not found. |
app/config.php returns a database array |
Database connection failed: could not find driver |
Enable the PDO extension for the driver (pdo_mysql, pdo_pgsql, pdo_sqlite) |
Database connection failed: … with another message |
Host, port, database name and credentials; the message comes from PDO |
Unsupported PDO driver: … |
database:driver is mysql, pgsql or sqlite |
Migration name must select exactly one migration |
The --name value matches one class or file name |
Applied migration source is missing |
A migration that has run was deleted or renamed; restore it before rolling back |
Run migrations outside application transactions. |
Do not call the runner inside your own transaction |