Database abstraction layer built on Doctrine DBAL
Requires
- php: >=8.2
- doctrine/dbal: ^4.0
Requires (Dev)
- phpunit/phpunit: ^11.5
Suggests
- robmorgan/phinx: To write migrations
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-27 21:33:23 UTC
README
Small database abstraction layer built on top of Doctrine DBAL.
Install
composer require objectiveweb/db doctrine/dbal
Getting started
use Objectiveweb\DB; $db = new DB('mysql:dbname=app;host=127.0.0.1', 'user', 'secret', [ 'prefix' => 'myprefix_', ]);
use Objectiveweb\DB; // Raw query $db->query('CREATE TABLE users (id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(255))')->exec(); // Low-level insert returns the scalar generated ID (string|null) $insertId = $db->insert('users', ['name' => 'Alice']); // '1' // Update (table, values, conditions) $affectedRows = $db->update('users', ['name' => 'Alice Smith'], ['id' => 1]); // Select all rows $rows = $db->select('users')->all(); // Select with IN $rows = $db->select('users', ['id' => [1, 2, 3]])->all(); // Select with LIKE $rows = $db->select('users', ['name' => 'Ali%'])->all(); // Select with SQL functions $stats = $db->select('users', [], [ 'fields' => [ 'total' => 'COUNT(*)', 'avg_age' => 'AVG(age)', 'max_age' => 'MAX(age)', ], ])->fetch(); // Map by field $byId = $db->select('users')->map('id'); // Fetch row by row $query = $db->select('users'); while ($row = $query->fetch()) { // process } // Delete $affectedRows = $db->delete('users', ['id' => 1]); // Transaction $db->transaction(function (DB $db) { $id = $db->insert('users', ['name' => 'Bob']); $db->update('users', ['name' => 'Bobby'], ['id' => $id]); return $id; });
DB::insert(string $table, array $data) is the low-level API and returns the generated ID as a scalar string, or null when no ID can be determined. Table::insert() has a different return contract, described below.
Query execution
Query::exec(?array $bindings = null): int preserves Doctrine DBAL's native row-count semantics:
- Result-set queries such as
SELECT,SHOW,DESCRIBE,PRAGMA, andWITHare executed withConnection::executeQuery().exec()returns the resultingDoctrine\\DBAL\\Result::rowCount(). - Non-result-set statements are executed with
Connection::executeStatement().exec()returns the affected-row count reported by DBAL. - For result-set queries, the exact meaning and reliability of
rowCount()is driver-dependent. Objectiveweb DB intentionally exposes the value provided by Doctrine DBAL rather than normalizing or recalculating it.
Use fetch(), all(), or map() to consume rows from result-set queries.
CRUD operations
use Objectiveweb\DB; $db = new DB('mysql:dbname=app;host=127.0.0.1', 'user', 'secret'); $table = $db->table('users', [ 'pk' => 'id', 'join' => [], 'model' => null, // optional: class-string to map rows into objects ]); // Insert (returns the primary-key map, or null when no ID can be determined) $inserted = $table->insert(['name' => 'Alice']); // ['id' => '1'] $id = $inserted['id']; // Select all rows (returns Objectiveweb\DB\Collection) $data = $table->select(); // Filter/sort/range $data = $table->select([], [ 'filter' => ['name' => 'Alice'], 'sort' => [ ['last_name', 'asc'], ['id', 'desc'], ], 'range' => [0, 9], ]); $count = count($data); $total = $data->total(); $contentRange = $data->contentRange(); foreach ($data as $item) { $item['name']; } // Update by filter $updated = $table->update(['name' => 'Alice'], ['name' => 'Alice Smith']); // Update by ID $updated = $table->update(1, ['name' => 'Alice Smith']);
Table::insert(array|Model $data) returns the inserted primary key as an associative array keyed by the table's configured pk (for example ['id' => '1']). If the primary key cannot be determined, it returns null. This applies to regular tables and table inheritance; a custom pk is used as the return-array key.
Other Table CRUD contracts:
Table::get()with no key returns aCollection;Table::get(array $filter)also returns a filteredCollection.Table::get(int|string $id)returns one row as an array, or the configuredModelinstance when model mapping is enabled. It throwsNotFoundExceptionwhen no row matches.Table::update(int|string|array $key, array|Model $data)returns['updated' => int], where the value is the affected-row count.Table::delete(int|string|array $key)returns the affected-row count as anint.
The low-level DB::update() and DB::delete() methods return the affected-row count directly as an int.
select($filter, $params) rule: when $params['filter'] is provided, it is merged with $filter (array_merge($filter, $params['filter'])), so keys in $params['filter'] win on conflicts.
Model mapping (optional)
use Objectiveweb\DB\Model; final class UserModel extends Model { protected static array $validFields = ['name', 'age']; protected static array $creationRules = [ 'name' => [ 'required' => true, 'filter' => FILTER_UNSAFE_RAW, 'validate' => [self::class, 'validateName'], ], 'age' => [ 'required' => true, 'filter' => FILTER_VALIDATE_INT, 'validate' => [self::class, 'validateAge'], ], ]; public static function validateName(mixed $value): bool|string { return is_string($value) && strlen($value) >= 2; } public static function validateAge(mixed $value): bool|string { return is_int($value) && $value >= 0; } } $table = $db->table('users', ['model' => UserModel::class]); $users = $table->select(); // Collection<UserModel> $one = $table->get(1); // UserModel // create/update payload is auto-filtered/validated against the model $table->insert(['name' => 'Alice', 'age' => 31, 'ignored' => 'x']); // "ignored" is dropped
Extending DB\\Table
use Objectiveweb\DB\Table; class UserTable extends Table { protected ?string $table = 'users'; protected ?array $params = [ 'pk' => 'id', 'join' => [], ]; } $table = $db->table(UserTable::class); $table->insert(['name' => 'Alice']);
Table inheritance
Table can map one logical entity into two physical tables (base + child) using extends.
$table = $db->table('things_ext', [ 'pk' => 'id', 'extends' => [ 'table' => 'things_base', 'fields' => ['common_name', 'common_kind'], 'key' => 'id', ], ]);
extends keys:
table: base table namefields: fields that belong to the base tablekey: base table key column used to link records (default:id)
Join rule used by inheritance:
child_table.pk = base_table.key
Insert behavior
On insert(), payload is split by extends.fields:
- base fields are inserted into the base table
- all other fields are inserted into the child table
- both writes run in a single transaction and share the same primary key
- the return value follows the normal
Table::insert()contract:[pk => id], ornullwhen the ID cannot be determined
$table->insert([ 'common_name' => 'shared', 'common_kind' => 'type-a', 'extra_value' => 'child-only', ]);
Update behavior
On update(), payload is split the same way and updated in one transaction:
- base fields update the base table
- non-base fields update the child table
// update by primary key $table->update(10, [ 'common_kind' => 'type-b', 'extra_value' => 'updated-child', ]); // update by child-table filter (matched ids are reused for base update) $table->update(['extra_value' => 'updated-child'], [ 'common_name' => 'renamed', ]); // update base-table field where child-table field matches $table->update(['extra_value' => 'pending'], [ 'common_kind' => 'approved', ]); // update child-table field where base-table field matches $table->update(['common_kind' => 'approved'], [ 'extra_value' => 'done', ]);
Custom inheritance key
$table = $db->table('child_table', [ 'pk' => 'id', 'extends' => [ 'table' => 'base_table', 'fields' => ['base_field'], 'key' => 'child_id', // join becomes child_table.id = base_table.child_id ], ]);
Filter grammar
where arrays support:
- equality:
['id' => 10] - negation:
['!status' => 'archived'] LIKE:['name' => 'Jo%']IN:['id' => [1, 2, 3]]- null checks:
['deleted_at' => null],['!deleted_at' => null]
SQL function fields
You can use SQL functions in params['fields'] and alias them with array keys.
$row = $db->select('orders', ['status' => 'paid'], [ 'fields' => [ 'total_orders' => 'COUNT(*)', 'avg_total' => 'AVG(total)', 'max_total' => 'MAX(total)', ], ])->fetch();
Grouped aggregate example:
$rows = $db->select('orders', null, [ 'fields' => [ 'customer_id', 'orders_count' => 'COUNT(*)', 'avg_total' => 'AVG(total)', ], 'group' => 'customer_id', 'order' => 'customer_id ASC', ])->all();
Joins
select() accepts joins via params['join'].
Legacy join map (backward compatible)
$rows = $db->select('person_links', [], [ 'join' => [ 'inner:people p1' => 'p1.id = person_links.parent_a_id', 'left:people p2' => 'p2.id = person_links.parent_b_id', 'right:people p3' => 'p3.id = person_links.child_id', 'full:people p4' => 'p4.id = person_links.child_id', 'cross:calendar c' => '', 'festival f' => 'f.id = person_links.child_id', // no prefix => plain JOIN (DB default) ], ]);
Structured join list (recommended)
$rows = $db->select('person_links', [], [ 'join' => [ ['type' => 'inner', 'table' => 'people', 'alias' => 'p1', 'on' => 'p1.id = person_links.parent_a_id'], ['type' => 'left', 'table' => 'people', 'alias' => 'p2', 'on' => 'p2.id = person_links.parent_b_id'], ['type' => 'right', 'table' => 'people', 'alias' => 'p3', 'on' => 'p3.id = person_links.child_id'], ['type' => 'full', 'table' => 'people', 'alias' => 'p4', 'on' => 'p4.id = person_links.child_id'], ['type' => 'cross', 'table' => 'calendar', 'alias' => 'c'], ], ]);
Supported type values:
innerleftrightfull(rendered asFULL OUTER JOIN)cross
Dialect note:
RIGHT JOINis not supported by SQLite.FULL OUTER JOINis PostgreSQL-only in this project test matrix.
Row locking
select() supports row-level locks via params['lock'].
$row = $db->select('users', ['id' => 1], [ 'lock' => 'update', // or true 'limit' => 1, ])->fetch();
Supported values:
true,'update','for update'=>FOR UPDATE'share','for share'=>FOR SHAREnull,false,''=> no lock clause
Notes:
- SQLite does not support
FOR UPDATE/FOR SHARE. - Invalid lock values throw
Objectiveweb\DB\Exception\InvalidQueryException.
Notes
- Table and field identifiers are validated before SQL generation.
- Raw string
whereclauses and raw join fragments are intentionally rejected for safety. Collection::render()returns the collection data as an array. UseCollection::contentRange()if you need aContent-Rangeresponse header.
Compatibility
- PHP 8.2, 8.3, and 8.4 are supported and tested.
- Doctrine DBAL 4.x is the supported public dependency range for the 0.8.x series.
- SQLite, MySQL 8.4, and PostgreSQL 16 are tested database backends.
Stability policy
- Semantic Versioning is used. Version 0.8 is still pre-1.0, so future minor releases may contain breaking public API changes.
- The documented 0.8.x public API contract includes
Objectiveweb\DB,Objectiveweb\DB\Table,Objectiveweb\DB\Collection,Objectiveweb\DB\Query,Objectiveweb\DB\Model, andObjectiveweb\DB\Expr. - Patch releases in the 0.8.x series should preserve these documented public contracts.
- Internal/private helpers in
DB(identifier parsing/compilation methods) are not part of the public contract.
Test matrix (SQLite + MySQL + PostgreSQL)
- Default local run (SQLite):
vendor/bin/phpunit --testsuite sqlite - MySQL run:
TEST_DB_DRIVER=mysql MYSQL_TEST_DSN=\"mysql:dbname=objectiveweb_test;host=127.0.0.1;port=3306;charset=utf8mb4\" MYSQL_TEST_USER=root MYSQL_TEST_PASSWORD=root vendor/bin/phpunit --testsuite mysql - PostgreSQL run:
TEST_DB_DRIVER=pgsql PGSQL_TEST_DSN=\"pgsql:dbname=objectiveweb_test;host=127.0.0.1;port=5432\" PGSQL_TEST_USER=postgres PGSQL_TEST_PASSWORD=postgres vendor/bin/phpunit --testsuite pgsql
Run all databases in Docker
./scripts/test-docker.sh