iviphp / database
Database connections and query building for the IviPHP ecosystem.
Requires
- php: >=8.2
- ext-pdo: *
- iviphp/support: ^0.1
This package is not auto-updated.
Last update: 2026-07-22 01:03:05 UTC
README
Ivi Database
Database connections, transactions and safe query building for the IviPHP ecosystem.
Overview
iviphp/database provides a lightweight database layer built on top of PHP PDO.
It supports:
- SQLite, MySQL and PostgreSQL;
- named database connections;
- lazy PDO connection creation;
- prepared statements;
- safe parameter binding;
- transactions and nested transactions;
- transaction retries for transient failures;
- CRUD query building;
- strict identifier validation;
- protection against accidental full-table updates and deletes;
- structured database and query exceptions.
The package does not include an ORM, model system, migrations or schema builder. These features can be added by higher-level IviPHP packages.
Installation
composer require iviphp/database
Requirements
- PHP 8.2 or later
- Composer
- PHP PDO extension
- the PDO driver required by your database
iviphp/support
Common PDO drivers include:
pdo_sqlite
pdo_mysql
pdo_pgsql
Check installed drivers:
php -r 'print_r(PDO::getAvailableDrivers());'
Supported databases
The initial package supports:
- SQLite
- MySQL
- PostgreSQL
Custom PDO DSNs can also be used when the driver is available.
Basic configuration
<?php declare(strict_types=1); use Ivi\Database\Database; $database = Database::fromConfig([ 'default' => 'sqlite', 'connections' => [ 'sqlite' => [ 'driver' => 'sqlite', 'database' => __DIR__ . '/storage/database.sqlite', ], ], ]);
Access the default connection:
$connection = $database->connection();
Access a named connection:
$connection = $database->connection('sqlite');
SQLite configuration
$database = Database::fromConfig([ 'default' => 'sqlite', 'connections' => [ 'sqlite' => [ 'driver' => 'sqlite', 'database' => __DIR__ . '/storage/database.sqlite', 'foreign_keys' => true, 'busy_timeout' => 5000, ], ], ]);
Use an in-memory SQLite database:
$database = Database::fromConfig([ 'default' => 'sqlite', 'connections' => [ 'sqlite' => [ 'driver' => 'sqlite', 'database' => ':memory:', ], ], ]);
SQLite foreign-key enforcement is enabled by default.
The default busy timeout is 5000 milliseconds.
MySQL configuration
$database = Database::fromConfig([ 'default' => 'mysql', 'connections' => [ 'mysql' => [ 'driver' => 'mysql', 'host' => '127.0.0.1', 'port' => 3306, 'database' => 'ivi', 'username' => 'root', 'password' => 'secret', 'charset' => 'utf8mb4', ], ], ]);
Connect through a Unix socket:
$database = Database::fromConfig([ 'default' => 'mysql', 'connections' => [ 'mysql' => [ 'driver' => 'mysql', 'unix_socket' => '/var/run/mysqld/mysqld.sock', 'database' => 'ivi', 'username' => 'root', 'password' => 'secret', 'charset' => 'utf8mb4', ], ], ]);
Native prepared statements are enabled by default.
PostgreSQL configuration
$database = Database::fromConfig([ 'default' => 'pgsql', 'connections' => [ 'pgsql' => [ 'driver' => 'pgsql', 'host' => '127.0.0.1', 'port' => 5432, 'database' => 'ivi', 'username' => 'postgres', 'password' => 'secret', 'sslmode' => 'prefer', ], ], ]);
Custom PDO DSN
$database = Database::fromConfig([ 'default' => 'custom', 'connections' => [ 'custom' => [ 'driver' => 'mysql', 'dsn' => 'mysql:host=127.0.0.1;dbname=ivi;charset=utf8mb4', 'username' => 'root', 'password' => 'secret', ], ], ]);
When dsn is provided, the package uses it directly.
PDO options
Custom PDO options can be provided:
use PDO; $database = Database::fromConfig([ 'default' => 'mysql', 'connections' => [ 'mysql' => [ 'driver' => 'mysql', 'host' => '127.0.0.1', 'database' => 'ivi', 'username' => 'root', 'password' => 'secret', 'options' => [ PDO::ATTR_TIMEOUT => 5, ], ], ], ]);
Default options include:
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION; PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC; PDO::ATTR_STRINGIFY_FETCHES => false;
Native prepared statements are used for MySQL and PostgreSQL.
Connection initialization
Driver-specific SQL statements can run after connection creation:
$database = Database::fromConfig([ 'default' => 'sqlite', 'connections' => [ 'sqlite' => [ 'driver' => 'sqlite', 'database' => __DIR__ . '/storage/database.sqlite', 'init' => [ 'PRAGMA journal_mode = WAL', 'PRAGMA synchronous = NORMAL', ], ], ], ]);
Initialization statements must be trusted application configuration.
Never place untrusted input inside initialization SQL.
Multiple connections
$database = Database::fromConfig([ 'default' => 'primary', 'connections' => [ 'primary' => [ 'driver' => 'mysql', 'host' => '127.0.0.1', 'database' => 'application', 'username' => 'root', 'password' => 'secret', ], 'analytics' => [ 'driver' => 'pgsql', 'host' => '127.0.0.1', 'database' => 'analytics', 'username' => 'postgres', 'password' => 'secret', ], 'local' => [ 'driver' => 'sqlite', 'database' => __DIR__ . '/storage/local.sqlite', ], ], ]);
Resolve a named connection:
$analytics = $database->connection('analytics');
Create a query builder on a named connection:
$events = $database->table( 'events', 'analytics' );
Direct connection map
A direct map of connections is also accepted:
$database = Database::fromConfig([ 'default' => [ 'driver' => 'sqlite', 'database' => __DIR__ . '/storage/database.sqlite', ], ]);
In this form, the connection named default becomes the default connection.
Connection manager
Create a manager directly:
use Ivi\Database\ConnectionManager; $manager = new ConnectionManager( configurations: [ 'sqlite' => [ 'driver' => 'sqlite', 'database' => ':memory:', ], ], defaultConnection: 'sqlite' );
Resolve a connection:
$connection = $manager->connection();
Equivalent alias:
$connection = $manager->get();
Registering connections at runtime
$database->addConnection( 'secondary', [ 'driver' => 'sqlite', 'database' => __DIR__ . '/storage/secondary.sqlite', ] );
Replace an existing connection:
$database->replaceConnection( 'secondary', [ 'driver' => 'sqlite', 'database' => __DIR__ . '/storage/new-secondary.sqlite', ] );
Replacing a connection disconnects and discards its existing PDO instance.
Remove a connection:
$database->removeConnection('secondary');
Default connection
Return the current default name:
$name = $database->defaultConnectionName();
Change the default connection:
$database->setDefaultConnection('analytics');
The connection must already be registered.
Connection state
PDO connections are created lazily.
$connection = $database->connection(); $connection->isConnected();
Resolving a Connection object does not immediately open a database connection.
The connection opens when PDO is first required:
$pdo = $connection->pdo();
Reconnect:
$connection->reconnect();
Disconnect one connection:
$database->disconnect('analytics');
Disconnect the default connection:
$database->disconnect();
Disconnect all active connections:
$database->disconnectAll();
Direct SQL queries
Execute a prepared query:
$statement = $database->query( 'SELECT * FROM users WHERE email = :email', [ 'email' => 'user@example.com', ] );
Positional bindings are supported:
$statement = $database->query( 'SELECT * FROM users WHERE status = ? AND age >= ?', [ 'active', 18, ] );
Selecting rows
Return all rows:
$users = $database->select( 'SELECT * FROM users WHERE status = :status', [ 'status' => 'active', ] );
Return the first row:
$user = $database->selectOne( 'SELECT * FROM users WHERE id = :id', [ 'id' => 42, ] );
When no row exists, selectOne() returns null.
Scalar values
$count = $database->scalar( 'SELECT COUNT(*) FROM users', default: 0 );
Another example:
$email = $database->scalar( 'SELECT email FROM users WHERE id = :id', [ 'id' => 42, ] );
Executing statements
$affected = $database->execute( 'UPDATE users SET active = :active WHERE id = :id', [ 'active' => true, 'id' => 42, ] );
execute() returns the number of affected rows reported by PDO.
Direct inserts
$id = $database->insert( 'INSERT INTO users (name, email) VALUES (:name, :email)', [ 'name' => 'Gaspard', 'email' => 'gaspard@example.com', ] );
The generated identifier is returned as a string.
PostgreSQL sequences can be supplied:
$id = $database->insert( sql: 'INSERT INTO users (name) VALUES (:name)', bindings: [ 'name' => 'Gaspard', ], sequence: 'users_id_seq', connection: 'pgsql' );
Safe binding types
The connection accepts these binding values:
null;- booleans;
- integers;
- finite floating-point values;
- strings;
- objects implementing
Stringable.
Unsupported values such as arrays and arbitrary objects produce a QueryException.
$database->query( 'SELECT * FROM users WHERE id = :id', [ 'id' => 42, ] );
Bindings are passed separately from SQL and are never interpolated into the query string.
Query builder
Create a query builder:
$query = $database->table('users');
The builder validates table and column identifiers before generating SQL.
Selecting data
$users = $database ->table('users') ->select([ 'id', 'name', 'email', ]) ->get();
Select one column:
$users = $database ->table('users') ->select('email') ->get();
Append columns:
$query = $database ->table('users') ->select('id') ->addSelect([ 'name', 'email', ]);
Select distinct values:
$countries = $database ->table('users') ->select('country') ->distinct() ->get();
Column aliases
Safe aliases are supported:
$users = $database ->table('users') ->select([ 'id', 'name AS display_name', ]) ->get();
Only simple validated identifiers are accepted.
Arbitrary SQL expressions are not accepted by the initial query builder API.
WHERE conditions
Equality is assumed when two arguments are supplied:
$user = $database ->table('users') ->where('email', 'user@example.com') ->first();
Use an explicit operator:
$users = $database ->table('users') ->where('age', '>=', 18) ->get();
Supported operators include:
=
!=
<>
<
<=
>
>=
LIKE
NOT LIKE
PostgreSQL also supports:
ILIKE
NOT ILIKE
Multiple WHERE conditions
$users = $database ->table('users') ->where('active', true) ->where('age', '>=', 18) ->get();
This produces conditions joined with AND.
OR conditions
$users = $database ->table('users') ->where('role', 'admin') ->orWhere('role', 'editor') ->get();
Conditions are compiled in declaration order.
Grouped nested conditions are not included in the initial API.
NULL conditions
$users = $database ->table('users') ->whereNull('deleted_at') ->get();
Not null:
$users = $database ->table('users') ->whereNotNull('email_verified_at') ->get();
OR variants:
$query ->orWhereNull('deleted_at') ->orWhereNotNull('email_verified_at');
Passing null to an equality condition is converted automatically:
$query->where('deleted_at', null);
Equivalent SQL:
WHERE "deleted_at" IS NULL
IN conditions
$users = $database ->table('users') ->whereIn( 'role', [ 'admin', 'editor', ] ) ->get();
Not in:
$users = $database ->table('users') ->whereNotIn( 'status', [ 'blocked', 'deleted', ] ) ->get();
OR variants:
$query->orWhereIn('role', ['admin', 'editor']); $query->orWhereNotIn('status', ['blocked']);
An empty whereIn() list always produces a false condition.
An empty whereNotIn() list always produces a true condition.
BETWEEN conditions
$products = $database ->table('products') ->whereBetween( 'price', [ 10, 100, ] ) ->get();
Not between:
$products = $database ->table('products') ->whereNotBetween( 'price', [ 10, 100, ] ) ->get();
Exactly two values are required.
Comparing columns
$items = $database ->table('inventory') ->whereColumn( 'reserved_quantity', '<=', 'available_quantity' ) ->get();
Column identifiers are validated and are not treated as bound values.
Sorting
$users = $database ->table('users') ->orderBy('name') ->get();
Descending order:
$users = $database ->table('users') ->orderByDesc('created_at') ->get();
Multiple sort clauses are supported:
$users = $database ->table('users') ->orderBy('role') ->orderByDesc('created_at') ->get();
Only ASC and DESC are accepted.
Limits and offsets
$users = $database ->table('users') ->limit(20) ->offset(40) ->get();
Aliases are available:
$users = $database ->table('users') ->take(20) ->skip(40) ->get();
Negative limits and offsets are rejected.
First row
$user = $database ->table('users') ->where('id', 42) ->first();
Select specific columns:
$user = $database ->table('users') ->where('id', 42) ->first([ 'id', 'name', 'email', ]);
When no row exists, first() returns null.
Single value
$email = $database ->table('users') ->where('id', 42) ->value('email');
Provide a default:
$email = $database ->table('users') ->where('id', 42) ->value( 'email', 'unknown@example.com' );
Plucking values
$emails = $database ->table('users') ->where('active', true) ->pluck('email');
The result is a flat array.
Counting rows
$count = $database ->table('users') ->where('active', true) ->count();
Count one column:
$count = $database ->table('users') ->count('email');
Checking existence
$exists = $database ->table('users') ->where('email', 'user@example.com') ->exists();
The query stops after the first matching row.
Inserting one row
$affected = $database ->table('users') ->insert([ 'name' => 'Gaspard', 'email' => 'gaspard@example.com', 'active' => true, ]);
The number of affected rows is returned.
Inserting multiple rows
$affected = $database ->table('users') ->insertMany([ [ 'name' => 'Alice', 'email' => 'alice@example.com', ], [ 'name' => 'Bob', 'email' => 'bob@example.com', ], ]);
Every row must contain the same columns in the same order.
Insert and return identifier
$id = $database ->table('users') ->insertGetId([ 'name' => 'Gaspard', 'email' => 'gaspard@example.com', ]);
The identifier is returned as a string.
PostgreSQL sequence:
$id = $database ->table('users') ->insertGetId( values: [ 'name' => 'Gaspard', ], sequence: 'users_id_seq' );
Updating rows
$affected = $database ->table('users') ->where('id', 42) ->update([ 'name' => 'Updated name', 'active' => true, ]);
A WHERE condition is required.
This protects applications from accidental full-table updates.
The following call throws a LogicException:
$database ->table('users') ->update([ 'active' => false, ]);
For an intentional full-table update:
$database ->table('users') ->updateAll([ 'active' => false, ]);
The explicit method makes the destructive intent visible.
Deleting rows
$affected = $database ->table('users') ->where('id', 42) ->delete();
A WHERE condition is required.
This call throws a LogicException:
$database ->table('users') ->delete();
For an intentional full-table delete:
$database ->table('users') ->deleteAll();
Compiled SQL and bindings
Inspect the generated SELECT SQL:
$query = $database ->table('users') ->where('active', true) ->orderByDesc('created_at') ->limit(20); $sql = $query->toSql(); $bindings = $query->bindings();
Example SQL:
SELECT * FROM "users" WHERE "active" = :w1 ORDER BY "created_at" DESC LIMIT 20
Bindings:
[
'w1' => true,
]
MySQL identifiers are wrapped with backticks.
SQLite and PostgreSQL identifiers are wrapped with double quotes.
Resetting a query builder
$query = $database ->table('users') ->where('active', true) ->orderBy('name'); $query->reset();
reset() clears:
- selected columns;
- distinct mode;
- where conditions;
- bindings;
- ordering;
- limit;
- offset;
- parameter counters.
The table and database connection remain unchanged.
Transactions
$result = $database->transaction( function ($connection): int { $connection ->table('accounts') ->where('id', 1) ->update([ 'balance' => 900, ]); $connection ->table('accounts') ->where('id', 2) ->update([ 'balance' => 1100, ]); return 1; } );
The callback receives the selected Connection.
When the callback succeeds, the transaction is committed.
When it throws, the transaction is rolled back and the original exception is rethrown.
Named connection transactions
$result = $database->transaction( callback: function ($connection): void { $connection ->table('events') ->insert([ 'name' => 'application.started', ]); }, attempts: 1, connection: 'analytics' );
Transaction retries
Transient transaction failures can be retried:
$result = $database->transaction( callback: function ($connection): void { $connection ->table('accounts') ->where('id', 42) ->update([ 'balance' => 500, ]); }, attempts: 3 );
Retryable conditions include common:
- deadlocks;
- serialization failures;
- lock timeouts;
- SQLite database lock failures.
Only transient database failures are retried.
Application exceptions are not retried.
Manual transactions
$connection = $database->connection(); $connection->beginTransaction(); try { $connection ->table('users') ->insert([ 'name' => 'Gaspard', ]); $connection->commit(); } catch (Throwable $exception) { $connection->rollBack(); throw $exception; }
Check transaction state:
$connection->inTransaction(); $connection->transactionLevel();
Nested transactions
Nested transactions are implemented through savepoints.
$connection->beginTransaction(); try { $connection ->table('users') ->insert([ 'name' => 'First', ]); $connection->beginTransaction(); try { $connection ->table('users') ->insert([ 'name' => 'Second', ]); $connection->commit(); } catch (Throwable $exception) { $connection->rollBack(); } $connection->commit(); } catch (Throwable $exception) { $connection->rollBack(); throw $exception; }
Savepoints are supported for:
- SQLite;
- MySQL;
- PostgreSQL.
Database exceptions
DatabaseException
Represents connection, configuration and transaction failures.
use Ivi\Database\Exceptions\DatabaseException; try { $database->connection('missing'); } catch (DatabaseException $exception) { $message = $exception->getMessage(); $context = $exception->context(); }
Factory methods include:
DatabaseException::unsupportedDriver($driver); DatabaseException::missingDriver($driver); DatabaseException::invalidConfiguration( $connection, $reason ); DatabaseException::connectionNotFound( $connection ); DatabaseException::connectionFailed( $connection, $driver, $previous ); DatabaseException::transactionFailed( $connection, $operation, $previous );
Query exceptions
QueryException
Represents query preparation, binding and execution failures.
use Ivi\Database\Exceptions\QueryException; try { $database->query( 'SELECT * FROM missing_table' ); } catch (QueryException $exception) { $sql = $exception->sql(); $connection = $exception->connection(); $types = $exception->bindingTypes(); }
Factory methods include:
QueryException::executionFailed( $sql, $bindings, $connection, $previous ); QueryException::preparationFailed( $sql, $connection, $previous ); QueryException::invalidBindings( $sql, $bindings, $connection, $reason, $previous );
Sensitive binding data
Query bindings may contain:
- passwords;
- authentication tokens;
- personal data;
- payment information;
- private application data.
Do not log raw bindings without filtering.
Safe binding types:
$types = $exception->bindingTypes();
Redacted bindings:
$bindings = $exception->redactedBindings();
Example:
[
'email' => '[string:19 bytes]',
'password' => '[string:60 bytes]',
'active' => true,
]
The original bindings remain available:
$raw = $exception->bindings();
Use them carefully and never expose them directly to clients.
Safe configuration output
Connection configuration can be inspected without returning common secrets.
$config = $database ->connection() ->configuration();
Manager configuration:
$config = $database ->manager() ->configuration('mysql');
All configurations:
$configurations = $database ->manager() ->configurations();
Common sensitive keys such as passwords, secrets and tokens are removed.
Available classes
Database
Database::fromConfig($configuration); $database->manager(); $database->defaultConnectionName(); $database->setDefaultConnection($name); $database->hasConnection($name); $database->connectionNames(); $database->connection($name); $database->addConnection( $name, $configuration, $replace ); $database->replaceConnection( $name, $configuration ); $database->removeConnection($name); $database->table( $table, $connection ); $database->query( $sql, $bindings, $connection ); $database->select( $sql, $bindings, $connection ); $database->selectOne( $sql, $bindings, $connection ); $database->scalar( $sql, $bindings, $default, $connection ); $database->execute( $sql, $bindings, $connection ); $database->insert( $sql, $bindings, $sequence, $connection ); $database->transaction( $callback, $attempts, $connection ); $database->reconnect($name); $database->disconnect($name); $database->disconnectAll();
ConnectionManager
$manager->add( $name, $configuration, $replace ); $manager->replace( $name, $configuration ); $manager->has($name); $manager->names(); $manager->defaultName(); $manager->setDefault($name); $manager->configuration($name); $manager->configurations(); $manager->connection($name); $manager->get($name); $manager->isConnected($name); $manager->reconnect($name); $manager->disconnect($name); $manager->disconnectAll(); $manager->remove($name); $manager->table( $table, $connection ); $manager->transaction( $callback, $attempts, $connection ); $manager->flush();
Connection
$connection->name(); $connection->driver(); $connection->configuration(); $connection->pdo(); $connection->isConnected(); $connection->disconnect(); $connection->reconnect(); $connection->query($sql, $bindings); $connection->select($sql, $bindings); $connection->selectOne($sql, $bindings); $connection->scalar($sql, $bindings, $default); $connection->execute($sql, $bindings); $connection->insert($sql, $bindings, $sequence); $connection->transaction( $callback, $attempts ); $connection->beginTransaction(); $connection->commit(); $connection->rollBack(); $connection->inTransaction(); $connection->transactionLevel(); $connection->table($table);
QueryBuilder
$query->connection(); $query->tableName(); $query->select($columns); $query->addSelect($columns); $query->distinct($enabled); $query->where( $column, $operatorOrValue, $value ); $query->orWhere( $column, $operatorOrValue, $value ); $query->whereColumn( $first, $operator, $second, $boolean ); $query->whereNull($column, $boolean); $query->orWhereNull($column); $query->whereNotNull($column, $boolean); $query->orWhereNotNull($column); $query->whereIn( $column, $values, $boolean ); $query->orWhereIn( $column, $values ); $query->whereNotIn( $column, $values, $boolean ); $query->orWhereNotIn( $column, $values ); $query->whereBetween( $column, $values, $boolean ); $query->whereNotBetween( $column, $values, $boolean ); $query->orderBy( $column, $direction ); $query->orderByDesc($column); $query->limit($limit); $query->take($limit); $query->offset($offset); $query->skip($offset); $query->get($columns); $query->first($columns); $query->value($column, $default); $query->pluck($column); $query->count($column); $query->exists(); $query->insert($values); $query->insertMany($rows); $query->insertGetId( $values, $sequence ); $query->update($values); $query->updateAll($values); $query->delete(); $query->deleteAll(); $query->toSql(); $query->bindings(); $query->reset();
DatabaseException
$exception->getMessage(); $exception->context(); $exception->getPrevious();
QueryException
$exception->sql(); $exception->bindings(); $exception->bindingTypes(); $exception->redactedBindings(); $exception->connection(); $exception->databaseError(); $exception->context();
Security principles
The package is designed around several safety rules.
Prepared statements
Values are always passed separately from SQL.
$database->query( 'SELECT * FROM users WHERE email = :email', [ 'email' => $email, ] );
Do not build SQL through string concatenation:
$sql = "SELECT * FROM users WHERE email = '{$email}'";
Identifier validation
Table and column identifiers are validated before query generation.
Untrusted input must never be used as a table or column name.
$database->table($_GET['table']);
Even with identifier validation, exposing dynamic table selection to users is not recommended.
Use an explicit allowlist instead.
Protected destructive operations
update() and delete() require a WHERE condition.
Intentional full-table operations use explicit methods:
$query->updateAll($values); $query->deleteAll();
Secret redaction
Connection configuration removes common secret fields before exposure.
Query exceptions provide binding metadata and redacted values.
No automatic raw SQL expressions
The initial query-builder API does not accept arbitrary expressions for columns, conditions or sorting.
Direct trusted SQL remains available through query(), select() and execute().
Query builder boundaries
The initial query builder focuses on common safe CRUD operations.
It currently does not provide:
- joins;
- nested condition groups;
- subqueries;
- grouping;
- HAVING clauses;
- unions;
- raw SQL expressions;
- database-specific upserts;
- schema creation;
- migrations;
- ORM models;
- relationships;
- eager loading.
These features can be added later without weakening the safety of the initial API.
Error handling
use Ivi\Database\Exceptions\DatabaseException; use Ivi\Database\Exceptions\QueryException; try { $users = $database ->table('users') ->where('active', true) ->get(); } catch (QueryException $exception) { error_log( json_encode([ 'message' => $exception->getMessage(), 'connection' => $exception->connection(), 'sql' => $exception->sql(), 'bindings' => $exception->redactedBindings(), ]) ); } catch (DatabaseException $exception) { error_log( json_encode([ 'message' => $exception->getMessage(), 'context' => $exception->context(), ]) ); }
Database error details should not be returned directly to application users.
Complete example
<?php declare(strict_types=1); use Ivi\Database\Database; use Ivi\Database\Exceptions\DatabaseException; use Ivi\Database\Exceptions\QueryException; $database = Database::fromConfig([ 'default' => 'sqlite', 'connections' => [ 'sqlite' => [ 'driver' => 'sqlite', 'database' => __DIR__ . '/storage/database.sqlite', 'foreign_keys' => true, 'busy_timeout' => 5000, ], ], ]); try { $userId = $database->transaction( function ($connection): string { $id = $connection ->table('users') ->insertGetId([ 'name' => 'Gaspard', 'email' => 'gaspard@example.com', 'active' => true, ]); $connection ->table('profiles') ->insert([ 'user_id' => (int) $id, 'bio' => null, ]); return $id; }, attempts: 3 ); $user = $database ->table('users') ->select([ 'id', 'name', 'email', ]) ->where('id', (int) $userId) ->first(); if ($user === null) { throw new RuntimeException( 'The created user could not be found.' ); } } catch (QueryException $exception) { error_log( json_encode([ 'message' => $exception->getMessage(), 'connection' => $exception->connection(), 'sql' => $exception->sql(), 'bindings' => $exception->redactedBindings(), ]) ); throw $exception; } catch (DatabaseException $exception) { error_log( json_encode([ 'message' => $exception->getMessage(), 'context' => $exception->context(), ]) ); throw $exception; }
Design principles
- PDO as the low-level database foundation
- Prepared statements for values
- Native prepared statements where supported
- Lazy connection creation
- Named connection management
- Explicit transaction control
- Nested transactions through savepoints
- Safe transaction retries
- Strict identifier validation
- Protected destructive operations
- No global database state
- No hidden query execution
- No automatic ORM behavior
- Safe diagnostic context
- Framework-independent implementation
Package boundaries
This package is responsible for:
- database connection configuration;
- PDO connection management;
- prepared query execution;
- transaction handling;
- safe CRUD query construction;
- database and query exceptions.
It is not responsible for:
- schema migrations;
- table creation abstractions;
- ORM models;
- model relationships;
- data validation;
- authentication;
- authorization;
- caching;
- HTTP request handling;
- application bootstrapping.
These responsibilities belong to other IviPHP packages.
Ecosystem
This package is part of the IviPHP ecosystem:
iviphp/contractsiviphp/supportiviphp/configiviphp/containeriviphp/httpiviphp/validationiviphp/cacheiviphp/viewiviphp/authiviphp/framework
Contributing
Contributions should preserve the safety and focused scope of the package.
Changes should:
- preserve parameterized value binding;
- reject invalid identifiers;
- avoid implicit raw SQL;
- preserve full-table update and delete protections;
- avoid logging sensitive bindings;
- remain independent from the complete framework;
- provide consistent behavior across supported PDO drivers;
- include clear failures for unsupported operations.
Security
Database validation does not replace application security controls such as:
- authorization;
- input validation;
- output escaping;
- CSRF protection;
- secure credential storage;
- least-privilege database accounts;
- encrypted transport;
- database backups;
- audit logging.
Use a database account with only the permissions required by the application.
Store passwords and connection secrets outside version control.
Please report security issues privately to the Softadastra maintainers instead of opening a public issue.
License
This project is licensed under the MIT License.
Maintainer
Created and maintained by Softadastra.