tereta / orm
ORM (Object-Relational Mapping) library for PHP, providing a simple and efficient way to interact with databases using an object-oriented approach.
Requires
- php: >=8.2
- ext-pdo: *
- psr/cache: ^3.0
- tereta/dbal: ^2.0
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:)
This package is not auto-updated.
Last update: 2026-08-09 11:38:22 UTC
README
π English | Π ΡΡΡΠΊΠΈΠΉ | Π£ΠΊΡΠ°ΡΠ½ΡΡΠΊΠ°
Table of contents
Requirements
PHP 8.2+, the ext-pdo extension, the tereta/dbal ^2.0 package and the PSR-6 interface (psr/cache ^3.0) - installed together with the package.
The PDO driver is installed for the database you use: ext-pdo_mysql for MySQL/MariaDB (DSN mysql:),
ext-pdo_pgsql for PostgreSQL (DSN pgsql:), ext-pdo_sqlite for SQLite (DSN sqlite:).
Installation
composer require tereta/orm
Quick start
The package is built around the Table Gateway and Row Data Gateway patterns. It lets you work with database tables and rows without describing models - a schema-first approach where the single source of truth about table schemas is the database itself.
The entry point is Tereta\Orm\Connection: a facade over a ready PDO that hands out gateways to tables.
No separate model description is required - the table structure is read from the database schema.
use PDO;
use Tereta\Orm\Connection;
$connection = new Connection(new PDO('sqlite::memory:'));
$table = $connection->table('users');
The main unit of work is the row, Tereta\Orm\Gateways\Row.
The table gateway is only there to get one: it knows nothing about your code and is rarely used directly in everyday work.
The row, on the other hand, holds the data together with the write methods, so the table name and the primary key value do not have to be passed around - the row knows them itself.
Load
Loading by primary key returns a row gateway; if there is no such record, an empty unloaded gateway is returned:
$row = $table->load(2);
if ($row->isLoaded()) {
echo $row->get('email');
$row->set('age', 35)->update();
}
The row remembers the values it was loaded with: set() and fill() only change the state of the object in memory, changes() shows the columns changed since loading, and update() sends exactly those to the database - unchanged columns never reach the query:
$row = $table->load(2);
$row->set('age', 35)->set('email', 'support@tereta.dev');
$row->changes(); // ['age' => 35] - email has not changed
$row->all(); // all data of the row
$row->id(); // the primary key value
$row->table(); // 'users'
$row->update(); // UPDATE of the changed columns only
$row->delete(); // DELETE by primary key
Update and delete
update() and delete() only work on a loaded row that has a primary key value - otherwise Tereta\Orm\Exceptions\Gateway is thrown.
After delete() the row is no longer considered loaded.
Select and single
Select lets you provide your own select for the query.
The conditions are set by a closure that configures Tereta\Dbal\Interfaces\Select;
select() returns a generator - the rows are read as you iterate instead of being loaded into memory as a whole,
single() returns one row:
use Tereta\Dbal\Interfaces\Select as SelectInterface;
foreach ($table->select(fn (SelectInterface $select) => $select->where('age', 32, '<')->order('id', 'DESC')) as $row) {
$row->set('age', $row->get('age') + 1)->update();
}
$row = $table->single(fn (SelectInterface $select) => $select->where('email', 'support@tereta.dev'));
Insert, update, delete
insert() returns the primary key value, update() and delete() - the number of affected rows:
$id = $table->insert(['email' => 'alexander@tereta.dev', 'age' => 34]);
$table->update(['age' => 35], $id);
$table->delete($id);
Connection
Tereta\Orm\Connection does not open the connection itself - it accepts a ready PDO.
Configuring the driver, DSN, credentials and attributes stays with the application, the package only uses the connection it was given:
use PDO;
use Tereta\Orm\Connection;
$pdo = new PDO('mysql:host=127.0.0.1;dbname=shop;charset=utf8mb4', 'user', 'password', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$connection = new Connection($pdo);
The DSN depends on the driver: mysql:host=...;dbname=... for MySQL/MariaDB, pgsql:host=...;dbname=... for PostgreSQL,
sqlite:/path/to/database.sqlite or sqlite::memory: for SQLite.
The table() method returns a table gateway and remembers it: calling it again with the same name returns the same object,
so gateways can be obtained on the spot instead of being passed through the application:
$users = $connection->table('users');
$connection->table('users') === $users; // true
The connection itself is available as $connection->pdo - transactions and arbitrary queries that the gateways do not cover go through it.
Context
The shared state of the connection lives in Tereta\Orm\Contexts\Connection - every table gateway receives it.
The context holds the PDO, the Tereta\Dbal\Builder query builder and the Tereta\Dbal\Schema schema reader;
by default it is created automatically from the PDO you passed in:
$connection->context->pdo; // PDO
$connection->context->builder; // Tereta\Dbal\Builder - building SQL
$connection->context->schema; // Tereta\Dbal\Schema - table structure
It is from schema that the package learns the primary key of a table, which is why no models have to be described.
If the builder or the schema reader has to be replaced - with your own implementation, a logging or caching wrapper -
the context is assembled by hand and passed as the second argument:
use Tereta\Orm\Contexts\Connection as Context;
$connection = new Connection($pdo, new Context($pdo, $builder, $schema));
Lazy connection
PDO opens the connection as soon as the object is created. For a lazy connection use the tereta/pdo package from the Tereta ecosystem:
Tereta\Pdo\Connection extends PDO, so it is passed into Tereta\Orm\Connection as an ordinary connection,
but it connects to the given DSN only on the first access to the database.
It also brings automatic reconnect on a lost connection, which matters for long-living processes.
use Tereta\Orm\Connection;
use Tereta\Pdo\Connection as LazyPdo;
$pdo = new LazyPdo('mysql:host=127.0.0.1;dbname=shop;charset=utf8mb4', 'user', 'password');
$connection = new Connection($pdo); // there is no connection to shop yet
$connection->table('users')->load(1); // this is where it is opened
Table Gateway
Tereta\Orm\Gateways\Table is a gateway to a single table. It is created not directly but through Connection::table(),
and the table name is available as the public property $table->table.
The Table Gateway keeps no row state of its own: it builds queries and loads Row Gateway row gateways as the result.
id
id() returns the name of the primary key column, read from the database schema.
If the primary key is composite or missing altogether, null is returned:
$table->id(); // 'id'
Operations that need a primary key (load(), update() with an identifier, delete())
throw Tereta\Orm\Exceptions\Gateway on a table without a single primary key.
create
create() returns an empty row gateway without touching the database. The data can be passed right away:
$row = $table->create(['email' => 'alexander@tereta.dev', 'age' => 34]);
Such a row is considered unloaded (isLoaded() returns false) until it is stored with a Row::insert() call.
load
load() reads a row by its primary key value - always with LIMIT 1.
If there is no such record, an empty gateway in the unloaded state is returned (Special Case, Fowler) rather than null, so the check goes through isLoaded():
$row = $table->load(2);
$row->isLoaded(); // false if there is no record with id = 2
Such a gateway is safe to read - get() returns null for any column, but update() and delete() on it throw Tereta\Orm\Exceptions\Gateway.
By its state it is no different from the result of create(): in both cases it is a Tereta\Orm\Gateways\Row with isLoaded() === false.
select and single
The conditions are set by a closure that receives Tereta\Dbal\Interfaces\Select - both as the first argument and as $this.
The closure may return the builder or simply configure the one it was handed:
use Tereta\Dbal\Interfaces\Select as SelectInterface;
$rows = $table->select(fn (SelectInterface $select) => $select->where('age', 32, '<')->order('id', 'DESC'));
$rows = $table->select(function (SelectInterface $select): void {
$select->where('age', 32, '<');
});
select() returns a Generator of row gateways and does nothing until the first iteration: neither the closure with the conditions
nor the query itself run until the generator is read. After that the rows are fetched one by one instead of being loaded into memory as a whole.
single() is the same select but adds LIMIT 1 to the query and returns a single row - or an empty unloaded gateway if there is no record:
foreach ($table->select($conditions) as $row) {
// ...
}
$row = $table->single(fn (SelectInterface $select) => $select->where('email', 'support@tereta.dev'));
Instead of a closure you can pass an already assembled Select - handy when the query is built elsewhere:
use Tereta\Dbal\Builder;
$select = (new Builder($pdo))->select('users')->where('age', 30, '>=')->order('age', 'ASC')->limit(2);
$rows = $table->select($select);
insert
insert() performs the insert and returns the primary key value: the one passed explicitly in the data as is,
otherwise the lastInsertId() issued by the database (or null if the driver did not report it):
$id = $table->insert(['email' => 'alexander@tereta.dev', 'age' => 34]);
update
update() returns the number of affected rows. There is no third state: if the data is empty, no query runs and 0 is returned.
$table->update(['age' => 35], $id); // UPDATE ... WHERE id = ?
$table->update(['active' => 0]); // UPDATE ... without WHERE - the whole table
The second argument is optional, and without it no condition is applied - all rows of the table are updated.
delete
delete() deletes a row by its primary key and returns the number of deleted rows:
$table->delete($id);
Row Gateway
Tereta\Orm\Gateways\Row is a gateway to a single table row: the record data and the methods that store it in one object.
It is not created directly - it is handed out by load(), single(), select() and create() of the table gateway.
The row remembers which table it came from, so the table name and the primary key do not have to be passed into it.
table and id
table() returns the table name, id() - the value of the current row's primary key
(unlike Table::id(), which returns the column name). If there is no key or it is not filled in, null is returned:
$row->table(); // 'users'
$row->id(); // 2
get, set, fill and all
get('column')returns the column value ornullif it is not in the row data.set('column', 'value')changes a single column and returns the row itself, so the calls can be chained:
$row->get('email');
$row->set('age', 35)->set('email', 'support@tereta.dev');
fill()replaces the row data as a whole - the array you pass becomes its new content, and the columns missing from it disappear from the row.all()returns all the data as an array:
$row->fill(['email' => 'alexander@tereta.dev', 'age' => 34]);
$row->all(); // ['email' => 'alexander@tereta.dev', 'age' => 34]
Neither set() nor fill() writes anything to the database - they only change the state of the object in memory.
Use update() to write the changes, and insert() to store a new row.
isLoaded and changes
isLoaded() shows whether the row was read from the database. A row obtained through create()
or returned in place of a missing record is not considered loaded.
A loaded row remembers the values it was read with, and changes() returns the columns
that have changed since - exactly those that will go into the UPDATE:
$row = $table->load(2); // ['id' => 2, 'email' => 'support@tereta.dev', 'age' => 30]
$row->set('age', 35)->set('email', 'support@tereta.dev');
$row->changes(); // ['age' => 35]
The values are compared strictly, type included. Drivers return numbers differently: SQLite and MySQL return them
as numbers, PostgreSQL as strings. That is why set('age', 35) over a fetched '35' ends up in changes() -
write values of the same type the driver returns.
insert
insert() stores a row assembled through create(). The primary key generated by the database is put back
into the row, so right after the insert it can be changed and stored with update():
$row = $table->create(['email' => 'alexander@tereta.dev', 'age' => 34])->insert();
$row->id(); // the identifier issued by the database
$row->isLoaded(); // true
$row->changes(); // [] - the row matches what was written to the database
If the key is set explicitly in the data, it stays. A repeated insert() on an already stored row
throws Tereta\Orm\Exceptions\Gateway - to change an existing record there is update().
save
save() picks the right operation by the state of the row: a new one is stored with insert(),
one read from the database is updated with update(). Handy when the code does not know where the row came from:
$row = $table->create(['email' => 'alexander@tereta.dev', 'age' => 34]);
$row->save(); // INSERT
$row->set('age', 35);
$row->save(); // UPDATE of the changed columns only
The restrictions and exceptions are the same as for insert() and update().
update
update() sends only the changed columns to the database; the primary key is excluded from the set.
If there are no changes, no query runs. After a successful update the current data becomes the original one,
so a second update() in a row does nothing:
$row->set('age', 35)->update();
The row must be loaded and have a primary key value - otherwise Tereta\Orm\Exceptions\Gateway is thrown.
delete
delete() deletes the row by its primary key. The requirements are the same as for update(): a loaded row that must have the key filled in, otherwise it raises a Tereta\Orm\Exceptions\Gateway exception.
$row->delete();
$row->isLoaded(); // false
The data stays in the object after the deletion, but the row is no longer considered loaded,
so repeated update() or delete() calls on it will not go through.
reload
reload() re-reads the row from the database by its primary key: unsaved changes are lost,
and the object shows what is stored in the table right now - including edits made by another process:
$row = $table->load(2);
$row->set('age', 99);
$row->reload();
$row->get('age'); // the value from the database
$row->changes(); // []
The key is taken from the state at load time, so a set() on the primary key column
does not affect which record is re-read.
The row must be loaded, otherwise Tereta\Orm\Exceptions\Gateway is thrown.
The same exception is raised if the record is no longer in the database - deleted while the row was in memory.
Error handling
The package has a single exception of its own - Tereta\Orm\Exceptions\Gateway, a descendant of RuntimeException.
It means the gateway cannot be built or used the way it was asked to:
The exception is raised in the following cases:
- the table has no single primary key column - a composite key or none at all
(
Table::load(),Table::update()with an identifier,Table::delete()); Row::update()orRow::delete()was called on a row that was never read from the database;- a loaded row has no primary key value;
Row::insert()was called on a row that is already stored;Row::reload()was called on an unloaded row, or the record has disappeared from the database.
use Tereta\Orm\Exceptions\Gateway as GatewayException;
try {
$row->update();
} catch (GatewayException $e) {
// the row is not loaded, has no primary key, or the table has none
}
A missing record is not an error: load() and single() return an empty unloaded gateway, select() - an empty generator.
Whether the row is loaded is checked with isLoaded(), not by catching an exception.
Errors of the database itself - an unreachable server, a uniqueness violation, an unknown column - are neither caught nor wrapped by the package: they come from PDO as a PDOException.
Since PHP 8 this behaviour is on by default, but when the connection is configured explicitly it is better to set the mode yourself:
$pdo = new PDO($dsn, $user, $password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
A malformed query is rejected before the database is reached at all - the tereta/dbal builder throws standard SPL exceptions (InvalidArgumentException, LogicException, OutOfRangeException).
These are errors in the code rather than in the input data, and there is usually no need to handle them at runtime.
License and author
Tereta Alexander tereta.alexander@gmail.com Web: https://tereta.dev Copyright Β©2008-2026 Tereta Alexander License https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
www.ββββββββββββββββββββββββ βββββββββββββββββ ββββββ
ββββββββββββββββββββββββββββββββββββββββββββββββββ
βββ ββββββ ββββββββββββββ βββ ββββββββ
βββ ββββββ ββββββββββββββ βββ ββββββββ
βββ βββββββββββ βββββββββββ βββ βββ βββ
βββ βββββββββββ βββββββββββ βββ βββ βββ
.dev