tereta / dbal
SQL query builder with fluent interface, supporting multiple database drivers and prepared statements. Designed for ease of use and flexibility in building complex SQL queries.
Requires
- php: >=8.2
- ext-pdo: *
Requires (Dev)
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
- squizlabs/php_codesniffer: ^3.0
Suggests
- ext-pdo_mysql: To use the wrapper with MySQL/MariaDB (DSN mysql:) and its disconnect-code detection
- ext-pdo_pgsql: To use the wrapper with PostgreSQL (DSN pgsql:) and its SQLSTATE disconnect detection
- ext-pdo_sqlite: To use the wrapper with SQLite (DSN sqlite:)
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-09-19 14:28:39 UTC
README
π English | Π ΡΡΡΠΊΠΈΠΉ | Π£ΠΊΡΠ°ΡΠ½ΡΡΠΊΠ°
Introduction
The main goal of Tereta/DBAL is to provide a convenient and powerful tool for working with database queries. Tereta/DBAL is:
- Tereta\Dbal\Builder - a query builder that generates SQL queries which can be executed on various database engines.
- Tereta\Dbal\Schema - a unified schema of tables and the relationships between them
Drivers supported out of the box:
- SQLite
- PostgreSQL
- MySQL
Getting started
The constructor accepts either a driver code (sqlite, pgsql, mysql) or a PDO instance (the driver is detected automatically):
use Tereta\Dbal\Builder;
$builder = new Builder($pdo); // the driver is detected from the connection, the builder can run the query via execute()
$builder = new Builder('mysql'); // or an explicit driver code
In the examples below $driverCode is any of the supported driver codes; a PDO instance may be passed instead.
If you pass a PDO instance, the Builder not only generates the SQL and its bind parameters, but also executes the query through the given PDO. For SELECT/INSERT/UPDATE/DELETE the execute() method returns a PDOStatement, while for DDL queries (CREATE/DROP/ALTER/INDEX) it returns nothing.
Set PDO::ATTR_ERRMODE to PDO::ERRMODE_EXCEPTION on your connection. The builder relies on PDO to report failures, and with the default silent mode a failed statement is not reported at all:
$pdo = new PDO($dsn, $user, $password, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
Every example below is written against these two tables:
use Tereta\Dbal\Interfaces\Create\Schema as SchemaInterface;
$builder->create('fixture_users')->schema(function (SchemaInterface $schema): void {
$schema->field('id')->integer()->primary()->autoIncrement();
$schema->field('site_id')->integer();
$schema->field('name')->varchar(64)->nullable(false);
$schema->field('age')->tinyint()->unsigned();
$schema->field('email')->varchar(320);
})->execute();
$builder->create('fixture_users_address')->schema(function (SchemaInterface $schema): void {
$schema->field('id')->integer()->primary()->autoIncrement();
$schema->field('user_id')->integer();
$schema->field('city')->varchar(64);
})->execute();
DQL (Data Query Language)
SELECT
A builder created from a driver code only generates SQL. You run it yourself:
$query = (new Builder($driverCode))->select('fixture_users')
->where('id', 1)
->order('name')
->limit(10);
$statement = $pdo->prepare($query->getQuery());
$statement->execute($query->getBindParams());
$users = $statement->fetchAll(PDO::FETCH_ASSOC);
A builder created from a PDO instance can run the query itself:
$query = (new Builder($pdo))->select('fixture_users')
->where('id', 1)
->order('name')
->limit(10);
$users = $query->execute()->fetchAll(PDO::FETCH_ASSOC);
- ->select($table) lets you specify the table to query. An array
['fixture_users' => 'u']sets a table alias. - ->where(...$args) lets you specify the query conditions.
- ->order($column, $direction) lets you specify the sort order of the results. The direction is either ASC or DESC.
- ->limit($limit) lets you limit the number of returned rows.
- ->offset($offset) lets you skip the given number of rows.
- ->getQuery() returns the generated SQL query as a string.
- ->getBindParams() returns the array of parameters to bind to the prepared statement.
- ->execute() returns a PDOStatement if a PDO is set in the builder.
- ->reset($section) clears one section of the query (
from,columns,where,groupBy,orderBy,limit,offset,join,having,union) or the whole query when called without arguments.
The generated SQL for the example above is:
SELECT * FROM `fixture_users` WHERE `id` = :bnd1i1 ORDER BY `name` ASC LIMIT 10
Columns, joins and subqueries
use Tereta\Dbal\Builder;
use Tereta\Dbal\Interfaces\Where as WhereInterface;
$query = (new Builder($pdo))->select('fixture_users')
->columns(['fixture_users.id' => 'fixture_user_id'], 'fixture_users.name')
->where(function (WhereInterface $where, Builder $builder): void {
$where->and(
'fixture_users.id',
$builder->select('fixture_users')->columns('id')->where('age', null, 'IS NOT')
);
})
->leftJoin(
'fixture_users_address',
'fixture_users.id = fixture_users_address.user_id',
[
'fixture_users_address.city',
['fixture_users_address.user_id' => 'address_user_id'],
]
);
$query->getQuery();
$query->getBindParams();
$query->execute();
- ->columns lets you specify the columns to query, as well as aliases for them via the arrow array ['fixture_users.id' => 'fixture_user_id'], where the left-hand side is the column and the right-hand side is its alias.
- ->where lets you specify the query conditions. In this case a nested builder is used to express a condition that contains a subquery. The closure receives the condition builder and the parent Builder, so you can create a subquery inside it.
- ->leftJoin lets you specify a LEFT JOIN to combine tables. In this case the tables fixture_users and fixture_users_address are joined on the condition fixture_users.id = fixture_users_address.user_id. You can also specify columns to select from the joined table, as well as aliases for those columns via the arrow array ['fixture_users_address.user_id' => 'address_user_id'].
- ->innerJoin, analogous to ->leftJoin (same interface), lets you specify an INNER JOIN to combine tables.
- ->crossJoin, analogous to ->leftJoin but without a join condition, lets you specify a CROSS JOIN.
The generated SQL is:
SELECT `fixture_users`.`id` AS `fixture_user_id`, `fixture_users`.`name`, `fixture_users_address`.`city`, `fixture_users_address`.`user_id` AS `address_user_id`
FROM `fixture_users`
LEFT JOIN `fixture_users_address` ON `fixture_users`.`id` = `fixture_users_address`.`user_id`
WHERE `fixture_users`.`id` IN (SELECT `id` FROM `fixture_users` WHERE `age` IS NOT NULL)
Conditions
Every value is passed as a bind parameter, never inlined into the SQL. Identifiers, operators and sort directions are validated, so a value that is not a valid identifier is rejected with an InvalidArgumentException.
$query = (new Builder($pdo))->select('fixture_users')
->where('age', [20, 30, 40]) // IN (:bnd, :bnd, :bnd)
->where('name', 'A%', 'LIKE') // LIKE :bnd
->where('site_id', null) // IS NULL
->where('id', [1, 100], 'BETWEEN'); // BETWEEN :bnd AND :bnd
- The supported operators are
=,!=,<>,<,<=,>,>=,LIKE,NOT LIKE,IN,NOT IN,IS,IS NOT,BETWEEN,NOT BETWEEN. - An array value turns
=intoINand!=intoNOT INautomatically. An empty array is rendered as an always-false condition forINand an always-true condition forNOT IN. - A
nullvalue turns=intoIS NULLand!=intoIS NOT NULL. ISandIS NOTaccept onlytrue,falseandnull.- A
Selectpassed as a value becomes a subquery, and=turns intoIN.
Repeated where() calls are joined with AND. To mix AND and OR, group the conditions in a closure:
use Tereta\Dbal\Interfaces\Where as WhereInterface;
$query = (new Builder($pdo))->select('fixture_users')
->where('site_id', 1)
->where(function (WhereInterface $where): void {
$where->and('age', 30)->or('age', 40);
});
SELECT * FROM `fixture_users` WHERE `site_id` = :bnd1i1 AND (`age` = :bnd2i1 OR `age` = :bnd2i2)
Aggregates and expressions
$builder->field() builds a column with an aggregate function and an optional alias:
$builder = new Builder($pdo);
$query = $builder->select('fixture_users')
->columns(
'site_id',
$builder->field('id')->count()->as('total'),
$builder->field('age')->max()->as('max_age')
)
->group('site_id')
->having('total', 0, '>')
->order('site_id', 'DESC');
SELECT `site_id`, COUNT(`id`) AS `total`, MAX(`age`) AS `max_age`
FROM `fixture_users` GROUP BY `site_id` HAVING `total` > :bnd1i1 ORDER BY `site_id` DESC
- Available aggregates:
count(),max(),min(),sum(),avg(),distinct(),now(). - ->group($column, ...) lets you specify GROUP BY columns.
- ->having($column, $value, $operator) lets you filter grouped rows. The value is bound as a parameter.
When you need a piece of SQL the builder cannot express, use Expression. Its content is inserted as is, so never build it from user input:
use Tereta\Dbal\Data\Expression;
$builder->update('fixture_users')
->set('age', new Expression('age + 1'))
->where('id', 1)
->execute();
Tereta\Dbal\Data\Now is a ready-made expression for the current timestamp of the active driver. It is meant for column defaults, see the CREATE TABLE section.
UNION
$query = (new Builder($pdo))->select('fixture_users')->columns('id');
$query->union(
(new Builder($pdo))->select('fixture_users_address')->columns('user_id')
);
SELECT `id` FROM `fixture_users` UNION SELECT `user_id` FROM `fixture_users_address`
Known limitation: order(), limit() and offset() are rendered before the UNION keyword, which is not valid SQL. Do not combine them with union() for now.
DML (Data Manipulation Language)
INSERT
INSERT operations are performed with the insert method, which lets you specify the table and the values to insert.
- getQuery() returns the generated SQL query as a string.
- getBindParams() returns the array of parameters to bind to the prepared statement.
- execute() returns a PDOStatement if a PDO is set in the builder.
$query = (new Builder($pdo))->insert('fixture_users')
->value('name', 'John')->value('age', 20);
$query->getQuery();
$query->getBindParams();
$query->execute();
You can use the values and value methods to provide multiple rows to insert.
$query = (new Builder($pdo))
->insert('fixture_users')
->value('name', 'Alex')->value('age', 30)->value('email', 'tereta.alexander@gmail.com')
->value('name', 'Max')->value('age', 22)->value('email', 'support@tereta.dev');
$query->execute();
In this case the builder determines the boundaries between different rows to insert when keys repeat. The example above specifies two sets of values to insert. The builder automatically determines that the values for name, age and email belong to a single row; however, when the name key repeats, the builder determines that this is the start of a new row to insert.
The same result with the values method, which takes a whole row at once:
$query = (new Builder($pdo))
->insert('fixture_users')
->values(['name' => 'Alex', 'age' => 34])
->values(['name' => 'Max', 'age' => 30])
->values(['name' => 'Ann', 'age' => 29]);
$query->execute();
Rows can also be taken from another table. The select method returns a nested SELECT builder that you configure as usual:
$query = (new Builder($pdo))->insert('fixture_users_archive');
$query->select('fixture_users')->columns('name', 'age')->where('age', 60, '>');
$query->execute();
INSERT INTO `fixture_users_archive` (`name`, `age`) SELECT `name`, `age` FROM `fixture_users` WHERE `age` > :bnd1i1
UPSERT
Tereta/DBAL supports UPSERT operations, which let you insert data into a table or update existing records on conflict.
use Tereta\Dbal\Data\Expression;
use Tereta\Dbal\Interfaces\Insert\Update as UpdateInterface;
$query = (new Builder($pdo))
->insert('fixture_users')
->value('id', 2)
->value('name', 'New')
->update(function (UpdateInterface $update): void {
$update
->conflict('id')
->set('name', 'name')
->set('age', new Expression('fixture_users.age + 1'));
});
$query->execute();
- ->update lets you specify the actions to take on conflict, for example updating existing records.
- ->conflict($column, ...) lets you specify the columns that trigger the conflict.
- ->set($column, $value) lets you specify the update to apply. A string value is the name of the column taken from the row that was being inserted; an
Expressionis inserted as is. - ->setValues($values) does the same for several columns at once.
The example generates the following query in MySQL:
INSERT INTO `fixture_users` (`id`, `name`) VALUES (:bnd1i1, :bnd1i2) AS new ON DUPLICATE KEY UPDATE `name` = new.`name`, `age` = fixture_users.age + 1
and the following one in SQLite and PostgreSQL:
INSERT INTO "fixture_users" ("id", "name") VALUES (:bnd1i1, :bnd1i2) ON CONFLICT ("id") DO UPDATE SET "name" = excluded."name", "age" = fixture_users.age + 1
UPDATE
UPDATE operations are constructed with the update method, which lets you specify the table, the new values and the conditions for the update.
$query = (new Builder($pdo))->update('fixture_users')
->set('name', 'Alex')
->where('id', 1);
$query->getQuery();
$query->getBindParams();
$query->execute();
- ->set($column, $value) lets you specify a single new value.
- ->setValues(['name' => 'Alex', 'age' => 34]) lets you specify several new values at once.
- ->table($table) lets you change the table after the builder was created.
- ->where($column, $value, $operator) lets you specify the conditions of the update.
DELETE
DELETE operations are constructed with the delete method, which lets you specify the table and the conditions for deleting data.
$query = (new Builder($pdo))->delete('fixture_users')->where('id', 5);
$query->getQuery();
$query->getBindParams();
$query->execute();
Transactions
A transaction is taken from the builder and reused, so nested calls share one connection state:
$transaction = $builder->transaction();
$transaction->begin();
$builder->insert('fixture_users')->value('name', 'John')->execute();
$transaction->commit();
- ->begin() starts a transaction. If a transaction is already running, a SAVEPOINT is created instead, so blocks can be nested.
- ->commit() commits the transaction, or releases the innermost savepoint.
- ->rollBack() rolls the transaction back, or rolls back to the innermost savepoint.
- ->inTransaction() tells whether a transaction is currently running.
The run method wraps a closure: it commits when the closure returns and rolls back when the closure throws. The exception is rethrown, and the return value of the closure is returned to the caller:
use Tereta\Dbal\Interfaces\Transaction as TransactionInterface;
$userId = $builder->transaction()->run(function (TransactionInterface $transaction) use ($builder): int {
$builder->insert('fixture_users')->value('name', 'John')->execute();
return (int) $builder->select('fixture_users')->columns('id')
->where('name', 'John')->execute()->fetchColumn();
});
DDL (Data Definition Language)
Note (MySQL): In MySQL you cannot run several DDL operations atomically: each DDL statement (CREATE/ALTER/DROP) triggers an implicit commit of the current transaction, so rolling back a series of DDL statements as a single unit is not possible. This is a limitation of the MySQL server, not of the InnoDB engine. On SQLite and PostgreSQL DDL is transactional and such operations can be rolled back.
CREATE TABLE
The Tereta/DBAL builder includes tools for constructing tables, indexes and foreign keys.
use Tereta\Dbal\Data\Now;
use Tereta\Dbal\Interfaces\Create\Schema as SchemaInterface;
$query = (new Builder($pdo))
->create('fixture_users')
->ifNotExists()
->schema(function (SchemaInterface $schema): void {
$schema->field('id')->integer()->primary()->autoIncrement();
$schema->field('site_id')->integer();
$schema->field('name')->varchar(64)->nullable(false);
$schema->field('age')->tinyint()->unsigned();
$schema->field('balance')->tinyint()->unsigned(false);
$schema->field('email')->varchar(320);
$schema->field('description')->string();
$schema->field('created_at')->datetime()->default(new Now())->index();
$schema->field('created_at_timestamp')->timestamp()->default(new Now())->index();
$schema->unique('site_id', 'email');
});
$query->execute();
- ->ifNotExists() adds an IF NOT EXISTS guard so creating an existing table is not an error. The indexes declared in the schema do not get that guard, so running the same CREATE again on an existing table fails on the index creation.
- ->schema(\Closure) describes the table. The closure receives the schema object.
- $schema->field($name) adds a column, see the list of column methods in the ALTER TABLE section.
- $schema->unique($column, ...) adds a unique index over the listed columns.
- $schema->index($column, ...) adds a regular index over the listed columns.
Because a CREATE TABLE can consist of several SQL statements (the table itself plus its indexes), you can also take those statements instead of running them:
foreach ($query->getQueries() as $sql) {
$pdo->exec($sql);
}
DROP TABLE
$query = (new Builder($pdo))->drop('fixture_users')->ifExists();
$query->getQuery();
$query->execute();
- ->ifExists() adds an IF EXISTS guard so dropping a missing table is not an error.
ALTER TABLE
use Tereta\Dbal\Interfaces\Alter\Column as ColumnInterface;
$query = (new Builder($pdo))
->alter('fixture_users')
->column('newAge', function (ColumnInterface $column): void {
$column->add()->tinyint()->unsigned()->nullable(false)->default(0)->index();
})->column('uniqueLeft', function (ColumnInterface $column): void {
$column->add()->tinyint()->unsigned()->nullable(true);
})->column('uniqueRight', function (ColumnInterface $column): void {
$column->add()->varchar(10)->nullable(false)->default('test');
})->unique('uniqueLeft', 'uniqueRight');
$query->getQueries();
$query->execute();
- ->alter('fixture_users') indicates that an ALTER TABLE query should be built for the given table.
- ->column('name', \Closure) lets you specify changes for a column. The column configuration happens inside the closure.
- ->unique('uniqueLeft', 'uniqueRight') indicates that the columns uniqueLeft and uniqueRight must be unique in combination with each other.
- ->getQueries() returns every statement the change requires. On SQLite an ALTER TABLE is emulated by rebuilding the table, so a single change can produce several statements. ->execute() runs them inside one transaction.
Inside the column closure you first choose the operation:
->column('uniqueRight', function (ColumnInterface $column): void {
$column->add()->varchar(10)->nullable(false)->default('test');
})
- ->add() - indicates that the column is being added
- ->remove() - indicates that the column is being removed
- ->modify() - indicates that the column is being modified
- ->rename('newName') - renames the column
The add and modify operations return the column definition, which accepts:
- ->integer(), ->bigint(), ->tinyint(), ->decimal($precision, $scale), ->float() - numeric types
- ->varchar($length), ->string(), ->text(), ->blob(), ->json() - string and binary types
- ->datetime(), ->timestamp() - date and time types
- ->boolean() - a boolean type
- ->type('CUSTOM TYPE') - any type written by hand
- ->primary() - indicates that the column is a primary key
- ->autoIncrement() - indicates that the column is auto-increment
- ->unsigned() - indicates that the column is unsigned
- ->nullable(false) - indicates that the column cannot be NULL
- ->index() - indicates that an index must be created for the column
- ->default('test') - indicates that the column default value must be set to 'test'
- ->foreign($table, $column, $on) - creates a foreign key to the given table and column
A unique index is set not at the column level but at the builder level: ->unique('uniqueLeft', 'uniqueRight') (ALTER) or $schema->unique('site_id', 'email') (CREATE).
Indexes
Indexes can also be managed on their own, without CREATE or ALTER:
$builder->index('idx_users_name')
->on('fixture_users')
->columns('name')
->create()
->execute();
$builder->index('idx_users_name')
->on('fixture_users')
->ifExists(true)
->remove()
->execute();
- ->index($name) starts an index query with the given name.
- ->on($table) sets the table the index belongs to.
- ->columns($column, ...) sets the indexed columns.
- ->unique() marks the index as unique.
- ->create() and ->remove() choose the operation.
- ->ifNotExists(true) and ->ifExists(true) add the matching guards.
Schema
Tereta\Dbal\Schema introspects an existing table and returns its structure in a single, driver-independent format. It is a plain, dependency-injection-friendly object, create it with new and reuse it; there is no singleton or global state.
use Tereta\Dbal\Schema;
$schema = new Schema($pdo);
$table = $schema->table('fixture_users');
new Schema(PDO $pdo)creates the introspector around a connection (the driver is detected from the PDO instance). An optionalTereta\Dbal\Factories\Schemamay be injected as the second argument to override how driver strategies are resolved.->table(string $table)reads the table from the connection and returns aTereta\Dbal\Data\Schemadescribing its columns, indexes and foreign keys.->exists(string $table)tells whether the table exists.->tables()returns the names of all tables in the current database.
Columns
foreach ($table->getColumns() as $column) {
$column->getName(); // string
$column->getType(); // normalized type, e.g. INT, VARCHAR
$column->isUnsigned(); // bool
$column->isNullable(); // bool
$column->hasDefault(); // bool
$column->getDefault(); // Expression|int|string|null
$column->isPrimary(); // bool, true if part of the primary key
$column->isAutoincrement(); // bool
$column->isGenerated(); // bool
}
$table->hasColumn('email'); // bool
$table->getColumn('email'); // Tereta\Dbal\Data\Schema\Column (throws if missing)
Indexes
The hasIndex and getIndex methods work by index name; getIndex throws if the index is missing.
foreach ($table->getIndexes() as $index) {
$index->getName(); // string
$index->isUnique(); // bool
$index->getColumns(); // string[]
}
if ($table->hasIndex('idx_users_name')) {
$table->getIndex('idx_users_name'); // Tereta\Dbal\Data\Schema\Index
}
Foreign keys
foreach ($table->getForeignKeys() as $foreignKey) {
$foreignKey->getColumns(); // string[], local columns
$foreignKey->getForeignTable(); // string
$foreignKey->getForeignColumns(); // string[]
$foreignKey->getOnUpdate(); // string, referential action
$foreignKey->getOnDelete(); // string, referential action
}
Extensibility
Each builder can be extended with a new driver via the addDriver(string $code, string $class): static method. The method is called on a factory instance, and the registered driver stays available for every builder created afterwards:
use Tereta\Dbal\Factories\Builders\Select as SelectFactory;
(new SelectFactory())->addDriver($driverIdentifier, $driverClassName);
The class you pass must implement the interface of the matching builder, otherwise an InvalidArgumentException is thrown. The same applies to every factory:
Tereta\Dbal\Factories\Builders\Alter
Tereta\Dbal\Factories\Builders\Create
Tereta\Dbal\Factories\Builders\Delete
Tereta\Dbal\Factories\Builders\Drop
Tereta\Dbal\Factories\Builders\Field
Tereta\Dbal\Factories\Builders\Index
Tereta\Dbal\Factories\Builders\Insert
Tereta\Dbal\Factories\Builders\Select
Tereta\Dbal\Factories\Builders\Transaction
Tereta\Dbal\Factories\Builders\Update
Tereta\Dbal\Factories\Builders\Where
A prepared factory can also be passed to the Builder constructor, which is the way to replace a driver for one builder only instead of registering it globally.
License and author
Tereta Alexander tereta.alexander@gmail.com
Web: https://tereta.dev
Copyright Β©2008-2026 Tereta Alexander
Apache License 2.0 https://www.apache.org/licenses/LICENSE-2.0
www.ββββββββββββββββββββββββ βββββββββββββββββ ββββββ
ββββββββββββββββββββββββββββββββββββββββββββββββββ
βββ ββββββ ββββββββββββββ βββ ββββββββ
βββ ββββββ ββββββββββββββ βββ ββββββββ
βββ βββββββββββ βββββββββββ βββ βββ βββ
βββ βββββββββββ βββββββββββ βββ βββ βββ
.dev