PDO wrapper for MariaDB with a query builder, CRUD methods, transactions that report their outcome, named locks and event hooks.
- PHP ^8.5 with ext-pdo, ext-pdo_mysql built on mysqlnd, ext-mbstring
- MariaDB 10.11 or later (the test setup runs 10.11, 11.4 and 12.3)
composer require sodaho/pdo-wrapperuse Sodaho\PdoWrapper\Database;
use Sodaho\PdoWrapper\DatabaseInterface;
$db = Database::mariadb(['host' => '127.0.0.1', 'database' => 'app', 'username' => 'app', 'password' => $secret]);
$id = $db->insert('users', ['email' => 'a@example.test', 'name' => 'A']);
$user = $db->table('users')->where('id', $id)->first();
$db->transaction(function (DatabaseInterface $db) use ($id): void {
$db->table('users')->where('id', $id)->lockForUpdate()->first();
$db->update('users', ['name' => 'B'], ['id' => $id]);
});Type against Sodaho\PdoWrapper\DatabaseInterface: its method docs are the full contract, the hook contract is in
Traits\HasHooks. The statement, CRUD and builder methods throw a QueryException when a statement fails, the
transaction methods a TransactionException.
| Signature | Description |
|---|---|
mariadb(array $config): MariaDbDriver |
Connects with $config (see Configuration); reads no environment. |
connect(array $config): MariaDbDriver |
driver must be mariadb; the other keys go to mariadb(). |
fromEnv(array $overrides = []): MariaDbDriver |
Reads DB_* (Configuration); a passed key wins, null too. |
raw(string $value, array $bindings = []): RawExpression |
SQL written as given; $bindings fill its ?. |
value(string $column): RawExpression |
VALUE(`col`): the inserted value, inside the $update of an upsert. |
json(string $column, string $path): JsonExpression |
A value inside a JSON column, as text. |
escapeLike(string $value): string |
Escapes \, % and _ for a LIKE pattern. |
| Signature | Description |
|---|---|
query(string $sql, array $params = []): PDOStatement |
Prepares, binds, executes; fires the statement hooks. |
execute(string $sql, array $params = []): int |
query(), returns the affected rows. |
lastInsertId(?string $name = null): string|false |
PDO's last insert id. |
getPdo(): PDO |
The PDO object; statements sent on it fire no hooks and are not checked. |
table(string $table): QueryBuilder |
A query builder for the table. |
schema(): Schema |
Tables, columns, indexes and constraints from information_schema. |
now(): RawExpression |
NOW(), in the connection's time zone. |
utcNow(): RawExpression |
UTC_TIMESTAMP(), a zoneless value. |
A parameter is null, a scalar or a Stringable; an array, a resource, another object, a RawExpression, INF and
NAN are refused before the statement is sent. Values are bound as text, a bool as '1'/'0', a float as the
shortest text that reads back as the same float.
| Signature | Description |
|---|---|
insert(string $table, array $data): int |
Inserts a row; returns the id, 0 without AUTO_INCREMENT. |
update(string $table, array $data, array $where): int |
$where: equalities, AND, no null; empty throws. |
delete(string $table, array $where): int |
Empty $where throws. |
findOne(string $table, array $where): ?array |
SELECT * ... LIMIT 1; empty $where throws. |
findAll(string $table, array $where = []): array |
The matching rows; without $where all rows. |
insertIgnore(string $table, array $data): int |
1 inserted, 0 on a duplicate key; other failures throw. |
upsert(string $table, array $row, array $update): int |
ON DUPLICATE KEY UPDATE; 1 inserted, 2 updated, 0 same. |
updateMultiple(string $table, array $rows, string $keyColumn = 'id'): int |
An UPDATE per row, by its key. |
insertWhen(string $table, array $data, string $condition, array $bindings = [], array $update = []): int
insertWhenReturning(string $table, array $data, string $condition, array $bindings = [], array $update = [],
array $columns = ['*']): ?array
upsertReturning(string $table, array $row, array $update, array $columns = ['*']): arrayinsertWhen():INSERT ... SELECT ... FROM DUAL WHERE (condition); returns 1 or 0, with$updateMariaDB's count (1 inserted, 2 updated, 0 neither).insertWhenReturning()returns the row,nullwhen the condition was false.upsertReturning(): the inserted row, or the existing one after the update.- Keys of
$data,$row,$whereare plain column names: an integer key or a dot throws. ARawExpressionvalue is written into the SQL, its own bindings bound in place. insertIgnore(),upsert()andinsertWhen()with$updatethrow on a connection opened withPdo\Mysql::ATTR_FOUND_ROWSor on a persistent one: the server's count cannot tell the cases apart there.updateMultiple()checks the rows before the first is sent; a refused row leaves nothing written. Without an open transaction it runs in its own, with the outcomes oftransaction().
| Signature | Description |
|---|---|
beginTransaction(): void |
Begins a transaction; no nesting. |
commit(): void |
Commits (see Transactions). |
rollback(): void |
Rolls back; also ends a library transaction PDO no longer reports, as lost. |
transaction(Closure $callback): mixed |
Commits when the callback returns, rolls back when it throws. |
inTransaction(): bool |
PDO::inTransaction(). |
currentTransaction(): ?int |
Number of the open transaction begun through the driver, else null. |
reconnect(bool $dropNamedLocks = false, bool $dropTransaction = false): void |
New connection, same settings. |
namedLock(string $name, int $timeout = 0): bool |
GET_LOCK(); false when another connection held it. |
releaseNamedLock(string $name): bool |
RELEASE_LOCK(); false when this connection did not hold it. |
isNamedLockHeld(string $name): bool |
Asks the server whether this connection holds it. |
namedLockHolder(string $name): ?int |
Connection id of the holder, null when free. |
heldNamedLocks(): array |
The names the driver holds by its own record, in the order taken. |
on(string $event, callable $callback): static |
Registers a listener; an unknown event throws. |
off(string $event, callable $callback): static |
Removes the callback's registrations (===); unknown throws. |
A named lock belongs to the connection, not to a transaction: COMMIT and ROLLBACK do not release it. The name is
prefixed with the configured database and : (app:login:7, up to 192 bytes with the prefix); with a : in the
database name the lock methods throw. An empty name, a name with a NUL byte and a negative timeout throw a
QueryException; taking a lock this connection holds throws NamedLockReentryException. The constants
TRANSACTION_COMMITTED, TRANSACTION_ROLLED_BACK and TRANSACTION_LOST name the outcomes.
| Signature | Description |
|---|---|
MariaDbDriver::__construct(array $config) |
What Database::mariadb() calls. |
AbstractDriver::__construct(?Closure $connect = null, bool $redactParameters = false) |
Base of a custom driver. |
$connect returns the PDO object, at construction and for reconnect(); without it the driver sets $pdo itself
and cannot reconnect. beginTransaction(), commit() and rollback() are final. A custom driver adapts through
the protected methods failureToRemember(), transactionIsOver(), transactionEndedBy(),
refreshTransactionState(), implicitCommitOf(), namedLockPrefix(), isUniqueViolation(), violatedConstraint(),
quoteIdentifier() and knownEvents() (events of its own, fired with trigger()).
QueryBuilder::__construct(DatabaseInterface $db, string $table) is what table() returns. Clause methods return
the builder; $column of the where methods may be an expression without bindings (Database::json(),
Database::raw('LOWER(email)')).
// Clauses
select(string|array $columns = '*'): self // 'id, name', ['id', 'u.name as n', Database::raw('COUNT(*)')]
distinct(): self // SELECT DISTINCT
where(string|RawExpression|array $column, mixed $operatorOrValue = null, mixed $value = null): self
whereRaw(string $sql, array $bindings = []): self // trusted SQL in parentheses, AND-joined
whereIn(string|RawExpression $column, array $values): self // [] renders 1 = 0; null in it throws
whereNotIn(string|RawExpression $column, array $values): self // [] throws
whereBetween(string|RawExpression $column, array $values): self // two values, no null
whereNotBetween(string|RawExpression $column, array $values): self // as whereBetween()
whereNull(string|RawExpression $column): self // IS NULL
whereNotNull(string|RawExpression $column): self // IS NOT NULL
whereLike(string|RawExpression $column, string $pattern): self // LIKE ? ESCAPE ?, backslash as escape
whereNotLike(string|RawExpression $column, string $pattern): self // NOT LIKE ? ESCAPE ?
join(string $table, string $first, string $operator, string $second): self // INNER JOIN
leftJoin(string $table, string $first, string $operator, string $second): self // LEFT JOIN
rightJoin(string $table, string $first, string $operator, string $second): self // RIGHT JOIN
orderBy(string|RawExpression $column, string $direction = 'ASC'): self // ASC or DESC; a string is a column name
limit(int $limit): self // negative throws
offset(int $offset): self // negative throws
groupBy(string|RawExpression|array $columns): self // a string is split at commas into names
having(string|RawExpression $column, string $operator, mixed $value): self // a string names a column or the
// text of a selected expression
lockForUpdate(): self // FOR UPDATE; a read outside a transaction throws
sharedLock(): self // LOCK IN SHARE MODE; the same
// Execution
get(): array // the rows
first(): ?array // the first row or null
exists(): bool // SELECT 1 ... LIMIT 1, keeping offset() and a lock
count(string $column = '*'): int // with distinct() distinct rows, with groupBy() the groups
sum(string $column): float|string|null // as MariaDB delivers it: integer and DECIMAL sums as string
avg(string $column): float|string|null // as sum()
min(string $column): mixed // in the column's type; null without a value
max(string $column): mixed // as min()
insert(array $data): int // as the driver's insert()
insertWhen(array $data, string $condition, array $bindings = [], array $update = []): int
insertWhenReturning(array $data, string $condition, array $bindings = [], array $update = [],
array $columns = ['*']): ?array
insertIgnore(array $data): int // as the driver's
upsert(array $row, array $update): int // as the driver's
upsertReturning(array $row, array $update, array $columns = ['*']): array // as the driver's
update(array $data): int // needs a where; limit() needs orderBy() and the other way round
delete(): int // as update()
increment(string $column, int|float $by = 1, array $extra = []): int // col = col + CAST(? AS ...)
decrement(string $column, int|float $by = 1, array $extra = []): int // as increment(), subtracting
toSql(): array // [sql, params] without executingwhere('id', 5), where('age', '>', 18), where(['active' => 1]), where('nick', 'IS', $nick). With two arguments
the second is the value, with three the operator: =, !=, <>, <, >, <=, >=, LIKE, NOT LIKE, IS,
IS NOT (null-safe, <=>). Another operator, or one that is no string, throws; a null value needs IS/IS NOT.
- The insert methods throw when a where, join,
groupBy(),having(),orderBy(),limit(),offset(),distinct()or a lock is set on the builder;select()is ignored. update()anddelete()throw foroffset(), a join,groupBy()andhaving().sum(),avg(),min()andmax()throw withgroupBy(). A lock together withdistinct(),groupBy()orhaving()throws.increment()casts an int toSIGNEDand a float toDECIMAL(65,30);$extramust not set the column again.
| Signature | Description |
|---|---|
__construct(DatabaseInterface $db) |
What schema() returns. |
tables(): array |
Base and system-versioned tables of the current database. |
hasTable(string $table): bool |
Compared as the server compares table names. |
columns(string $table): array |
name, type, nullable, default (SQL text or null), extra. |
indexes(string $table): array |
name, columns, unique, primary; the primary key first. |
constraints(string $table): array |
name, type (PRIMARY KEY, UNIQUE, FOREIGN KEY, CHECK). |
columns(), indexes() and constraints() throw a QueryException for an unknown table. What is shown depends on
the user's privileges.
| Signature | Description |
|---|---|
RawExpression::__construct(string $value, array $bindings = []) |
A binding that is a RawExpression throws. |
RawExpression::__toString(): string |
$value; public readonly $value, $bindings. |
JsonExpression::__construct(string $column, string $path, array $fallbacks = []) |
What Database::json() builds. |
JsonExpression::orColumn(string $column): self |
COALESCE(<value>, `column`). |
JsonExpression::as(string $alias): RawExpression |
The expression as a select() entry with an alias. |
A JSON path is $ followed by .name and [n] steps (up to 9 digits); another form throws. The value is text,
compared under utf8mb4_bin; a missing field is NULL, a JSON null the text 'null'. In having() use the
alias. An index: a virtual column with the same expression and COLLATE utf8mb4_bin; from MariaDB 11.8 a where()
on the expression uses it.
Keys of Database::mariadb(), connect(), the fromEnv() overrides and MariaDbDriver::__construct():
| Key | Type | Default | Allowed | Refused |
|---|---|---|---|---|
host |
string | required | not empty | empty, ; or NUL, no string |
database |
string | required | not empty | empty, ; or NUL, no string |
username |
string | required | not empty | empty, no string |
password |
?string | null |
'' is a password |
no string |
port |
int|string|null | 3306 |
1 to 65535, as int or digits | other values |
charset |
?string | utf8mb4 |
a charset name | ; or NUL, no string |
options |
?array | [] |
PDO::ATTR_* => value; replace the defaults |
see below |
pdoClass |
?string | PDO |
an instantiable class that is or extends PDO |
other values |
redactParameters |
bool | false |
true, false |
other values, null included |
driver |
string | none | mariadb (connect(), fromEnv()) |
other names; the key in mariadb() |
- An unknown key throws a
ConnectionExceptionthat names it ingetDebugMessage().nullforport,charset,options,pdoClassandpasswordmeans the default. - Defaults of
options:ATTR_ERRMODEexception,ATTR_DEFAULT_FETCH_MODEassoc,ATTR_EMULATE_PREPARESfalse,ATTR_STRINGIFY_FETCHESfalse,Pdo\Mysql::ATTR_MULTI_STATEMENTSfalse. - Refused
options:ATTR_STRINGIFY_FETCHESorPdo\Mysql::ATTR_MULTI_STATEMENTSwith a value other thanfalseor the integer0('0',nulland0.0are refused),ATTR_STATEMENT_CLASSwith whatever value, keys that are no integers. - Refused when the connection opens (
ConnectionException::$refusal): a client that is not mysqlnd (NotMysqlnd), a server that is not MariaDB (NotMariaDb), MariaDB before 10.11 (MariaDbTooOld),ATTR_ORACLE_NULLSother thanNULL_NATURAL(NullMode). Then the driver setscompletion_typetoNO_CHAIN, and throws if that fails. fromEnv()readsDB_DRIVER,DB_HOST,DB_PORT,DB_DATABASE,DB_USERNAME,DB_PASSWORDfrom$_ENV, then fromgetenv($key, true); a variable that is set but empty counts as not set.charset,options,pdoClassandredactParametershave no variable.- Fetched types on 64-bit PHP: INT, BIGINT, TINYINT(1)
int(BIGINT UNSIGNED abovePHP_INT_MAXstring); FLOAT, DOUBLEfloat; DECIMAL,SUM()/AVG()of integersstring; DATETIME, VARCHAR, TEXT, JSONstring; NULLnull.
| Event | When | Payload |
|---|---|---|
query.before |
At the start of query(), before the library's checks |
sql, params |
query |
After the statement ran | sql, params, duration, rows |
error |
A failed statement (code 0: value refused) |
sql, params, error, code, sqlState, driverCode |
transaction.begin |
After a BEGIN that went through | transaction (int), depth (int) |
transaction.commit |
After a COMMIT that went through | transaction, depth (?int) |
transaction.rollback |
After a ROLLBACK the library confirmed | transaction, depth (?int) |
transaction.end |
Once per transaction the library ends | outcome, error, transaction, depth |
transaction.endruns after the commit or rollback listeners.transactionnumbers the transactions begun through the driver from 1; one begun on raw PDO carriesnull.- A throwing
query.before,query,error,transaction.beginortransaction.rollbacklistener stops the listeners after it, and its exception reaches the caller (aPDOExceptionfromquery.beforeorqueryasQueryException"Query hook failed", fromtransaction.beginortransaction.rollbackasTransactionException). Not so on the automatic rollback oftransaction()/updateMultiple(): a rollback listener's exception is dropped and the exception that ended the transaction reaches the caller. Anerrorlistener that throws while the library only reports a failure (one the caller does not get as the thrown one, below) is ignored. - A failing
transaction.commitortransaction.endlistener does not stop the next one; after a commit their failures arrive together in aCommitHookException. The remaining commit listeners are skipped, and listed as failures, when a commit listener left a raw transaction open that cannot be rolled back, the state cannot be read, or the session chained a new transaction to the COMMIT. transaction.endlistener failures: after a COMMIT they arrive only in theCommitHookException. After a ROLLBACK they are told toerror(sql'',params[],error,code,sqlState,driverCode, thenhook,outcome,exception; one whose codes cannot be read is dropped). After a manualrollback()the first one is also thrown as aTransactionException, unless a rollback listener threw, the session chained a transaction to the ROLLBACK or the state after it cannot be read: that exception wins. On the automatic rollback and onlostthey reach onlyerror. After a ROLLBACK, a chained transaction or unreadable state that is not thrown is told toerrorthe same way, withouthook; after a COMMIT it joins theCommitHookException. Anerrorlistener that throws there is ignored.- Statement listeners 32 levels deep, and
transaction.endlisteners that keep beginning transactions after 32 levels, end in aLogicException. - Listener rule: a
transaction.endlistener may steer transactions; aquery.before,queryorerrorlistener may runtransaction()orupdateMultiple()when no transaction was open as it was entered. Other listeners, andbeginTransaction(),commit()orrollback()from a statement listener, get aListenerTransactionException(details:Traits\HasHooks).
Namespace Sodaho\PdoWrapper\Exception. getMessage() names no SQL and no bound value; details are in
getDebugMessage().
| Class | When | Fields |
|---|---|---|
DatabaseException |
Base class | $sqlState (?string), $driverCode (?int), getDebugMessage(): ?string |
ConnectionException |
Connecting, a refused config, reconnect() |
$refusal (?ConnectionRefusal) |
NamedLocksHeldException |
reconnect() while named locks are held |
$lockNames |
TransactionOpenException |
reconnect() while a library transaction is open |
|
QueryException |
A failed or refused statement or argument | |
UniqueViolationException |
A duplicate key (error 1062) | $constraint (?string) |
ImplicitCommitException |
A statement that would commit the open transaction | $statement |
LockOutsideTransactionException |
A locking read outside a transaction | |
NamedLockReentryException |
namedLock() of a lock this connection holds |
$lockName |
TransactionException |
Begin, commit or rollback failed | |
CommitFailedException |
The commit failed or was refused | $outcome (?string) |
ListenerTransactionException |
Transaction control refused inside a listener | |
CommitHookException |
Committed; a listener failed, or the state unclear | $failures, $connectionInTransaction |
RedactedPdoException |
redactParameters: replaces a statement's or listener's error |
SQLSTATE as getCode() |
- Hierarchy:
ConnectionException,QueryException,TransactionExceptionandCommitHookExceptionextendDatabaseException;NamedLocksHeldExceptionandTransactionOpenExceptionextendConnectionException;UniqueViolationException,ImplicitCommitException,LockOutsideTransactionExceptionandNamedLockReentryExceptionextendQueryException;CommitFailedExceptionandListenerTransactionExceptionextendTransactionException;RedactedPdoExceptionextendsPDOException. getCode()of aDatabaseExceptionis 0; compare$sqlStateas a string.ConnectionRefusalcases:NotMysqlnd,NotMariaDb,MariaDbTooOld,NullMode.- Constructors:
DatabaseException::__construct(string $message = 'Database error', ?Throwable $previous = null, ?string $debugMessage = null, bool $listenerFailure = false, bool $codesOfPrevious = true); the subclasses take message, previous and debug message first, their field last.CommitHookException(Throwable $first, array $failures, bool $connectionInTransaction = false),RedactedPdoException(string $message, ?string $sqlState).
transaction.end outcome |
Meaning | CommitFailedException::$outcome |
|---|---|---|
committed |
PDO::commit() went through; listener failures come as CommitHookException |
|
rolled_back |
The library's ROLLBACK went through: nothing of it is committed (see below) | rolled_back |
lost |
No confirmed rollback (ROLLBACK failed, transaction gone, state unknown): data may be committed | lost |
transaction.endfires once for each transaction the library ends. A manualcommit()orrollback()that fails before the end fires nothing and leaves the transaction to the caller - except a commit after which PDO reports no transaction: it tellslostat once. Arollback()whose ROLLBACK went through may still throw for a listener's failure, after the events. Full contract:DatabaseInterface::commit(),rollback(),transaction()andTraits\HasHooks.rolled_backholds for transactions steered through the library: SQL that steers transactions itself, sent throughquery()(START TRANSACTIONcommits the open one and opens the next), is not seen, and the outcome is then not to be relied on.transaction()returns the callback's value. After a failed commit there it rolls back where PDO still reports the transaction, and$outcomeis the outcome told; a failed manualcommit()leaves itnullunless it toldlost.- No nesting:
beginTransaction()inside an open transaction throws aTransactionException. - After a deadlock (1213) or error 1020 the server has rolled the transaction back: statements are refused,
commit()throws aCommitFailedException,beginTransaction()refuses, until the transaction is ended (rollback(), or a refusedcommit()that tellslost). After another failure inside a library transaction the driver asks the server; found gone, or not to be found out, its end islost, and nothing more is sent until it is ended. - Inside a library transaction a statement that commits implicitly (DDL, LOCK/UNLOCK TABLES, account and maintenance
statements; see
Driver\ImplicitCommit) throwsImplicitCommitExceptionbefore it is sent. Unjudged and refused as well: an executable comment (/*!...*/,/*M!...*/), a byte from 0x80 on or a control character other than tab, LF, VT, FF and CR, standing anywhere before the leading keywords are decided (also between them).BEGIN,COMMIT,ROLLBACK,SET autocommitandXAsent throughquery()are not refused and not tracked; whatCALL,EXECUTEorBEGIN NOT ATOMICrun inside is not looked into. reconnect()opens the new connection first (a failure changes nothing), sends a ROLLBACK on the old one when it reports a transaction, then swaps; an owed end is told aslost. It throwsTransactionOpenExceptionwhile a library transaction is open (unless$dropTransaction),NamedLocksHeldExceptionwhile named locks are held (unless$dropNamedLocks),ConnectionExceptionfor a persistent connection or a driver without$connect. Session state (SET SESSION, temporary tables) stays behind; set it withPdo\Mysql::ATTR_INIT_COMMAND.
A manual transaction ends where the library or PDO still reports one:
$db->beginTransaction();
try {
$work($db);
$db->commit();
} catch (Throwable $e) {
if ($db->currentTransaction() !== null || $db->inTransaction()) {
$db->rollback();
}
throw $e;
}- Values are bound (native prepares by default); names are quoted with backticks, a backtick inside doubled.
- SQL in
Database::raw(),whereRaw(), theinsertWhen()condition and rawselect(),groupBy()andorderBy()entries is used as written: never build it from user input, put user input in$bindings. - Keys of
$dataand$whereare column names, and MariaDB compares them without case: map user input to a fixed list of accepted keys. - Channels that carry bound values: the
query.before,queryanderrorpayloads,getDebugMessage(), the previous exception (MariaDB's message quotes values, e.g. a duplicate entry),$lockName,$lockNames. WithredactParameterspayload values are'[redacted]', debug messages name no value, and aRedactedPdoExceptionwith the codes replaces thePDOExceptionof a failed statement, a failed read after it and a listener;$lockNameand$lockNameskeep the names, a failed connect keeps PDO's exception. - Parameters that take values are marked
#[\SensitiveParameter]. ThePDOExceptionof a failed connect holds the DSN, the username and the options in its trace whilezend.exception_ignore_argsis off. - Multi-statements are off and cannot be switched on.
escapeLike()escapes user input for LIKE; the builder binds the escape character. - Reporting a vulnerability: SECURITY.md.
composer install
docker compose up -d # MariaDB 10.11 on 3306, 11.4 on 3307, 12.3 on 3308
composer test # against 10.11
MARIADB_PORT=3307 composer test # against 11.4
composer analyse && composer cs && composer validate --strictVariables MARIADB_HOST, MARIADB_PORT, MARIADB_DATABASE, MARIADB_USERNAME, MARIADB_PASSWORD (defaults in
phpunit.xml.dist). Suites: Unit (no database), Contract, Driver (composer test:unit, test:contract,
test:driver).
See CHANGELOG.md.
MIT
Parts of this project (refactoring, documentation, code review) were developed with AI assistance (Claude).