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: ^1.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-06-18 15:38:22 UTC
README
π English | Π ΡΡΡΠΊΠΈΠΉ | Π£ΠΊΡΠ°ΡΠ½ΡΡΠΊΠ°
Introduction
Tereta/ORM is an ORM whose main advantage is a single source of truth β the database. There is no longer any need to configure models; the ORM reads the DB schema and tables itself and builds relations from FOREIGN KEY Key features:
- Automatic detection of tables, their schemas, foreign keys, and relations between tables
- Implements the Active Record pattern in PHP
- Uses the Query Builder and schema handling via Tereta/DBAL.
Entry points:
Tereta\Orm\Connectionβ a facade providing access to the database Table Gateway (PoEAA).Tereta\Orm\Gateways\Tableβ Table Gateway (PoEAA) providing access to models and collectionsTereta\Orm\Records\Modelβ Implements Active Record modelsTereta\Orm\Iterators\Collectionβ Implements an Iterator collection, works in Lazy and Eager Loading modes
Supported drivers out of the box:
- SQLite
- PostgreSQL
- MySQL
Comparison with alternatives
The main difference of Tereta/ORM is its schema-first approach: the schema, columns, and relations are read
directly from the live DB (including FOREIGN KEY), so there is no need to describe models and mapping manually.
Most popular PHP ORMs work the other way around β code-first, where the source of truth
is the code or configuration, and the schema is derived from them.
| ORM | Pattern | Source of truth | Model definition | Relations |
|---|---|---|---|---|
| Tereta/ORM | Active Record + Table Gateway | Existing DB | Not needed (schema reflection) | Auto, from FOREIGN KEY |
| Laravel Eloquent | Active Record | Code | Columns auto, model by hand | Manual (relation methods) |
| Doctrine ORM | Data Mapper | Code (attributes/XML/YAML) | Manual (mapping) | Manual |
| CakePHP ORM | Table Gateway + Entity | DB (by convention) | Not needed / by convention | Auto by convention |
| Yii 2 ActiveRecord | Active Record | DB + code | Columns auto, model by hand | Manual |
| Propel | Active Record | schema.xml | Class generation from XML | From the XML schema |
The closest in philosophy is CakePHP ORM (the same set of PoEAA patterns and schema inspection), but Tereta/ORM works strictly in read mode against an existing schema and derives relations automatically from foreign keys, requiring neither naming conventions nor manual association definitions.
Getting started
To install, use composer:
composer require tereta/orm
Tereta\Orm\Connection
This is the facade of the entire ORM; its instance can be passed into your application context
$connection = new Connection($pdo);
$connection->table('table_user'); # Get the Table Gateway
$connection->model('table_user', ...$conditions); # Load or create an Active Record model from the 'table_user' table
$connection->collection('table_user', ...$conditions); # Load or create a collection on the 'table_user' table
For the Tereta\Orm\Connection interface, see Tereta\Orm\Interfaces
namespace Tereta\Orm\Interfaces;
use PDO;
use Tereta\Orm\Connection\Schema as Schema;
use Tereta\Orm\Factories\Gateway as GatewayFactory;
use Tereta\Orm\Interfaces\Table\Collection as CollectionInterface;
use Tereta\Orm\Interfaces\Table\Gateway as GatewayInterface;
use Tereta\Orm\Interfaces\Table\Model as ModelInterface;
interface Connection
{
public function __construct(PDO $pdo, ?Schema $schema = null, ?GatewayFactory $factory = null);
public function table(string $table): GatewayInterface;
public function model(string $table, mixed ...$conditions): ?ModelInterface;
public function collection(string $table, mixed ...$conditions): CollectionInterface;
}
You create the $connection = new Connection($pdo); object from the Tereta\Orm\Connection facade by passing the PDO you need
Then you use the methods:
- ->table($table) β to get the Table Gateway (Tereta\Orm\Gateways\Table)
- ->model($table, ...$conditions) β to fetch a model directly (Tereta\Orm\Records\Model)
- ->collection($table, ...$conditions) β to fetch a collection directly (Tereta\Orm\Iterators\Collection)
Tereta\Orm\Gateways\Table
Table Gateway (PoEAA) for a single table β the access point to its models and collections.
You get it via $connection->table($table), and then use the methods:
->model($table, ...$conditions)β fetch or create an Active Record model (Tereta\Orm\Records\Model)->collection($table, ...$conditions)β fetch a collection of records (Tereta\Orm\Iterators\Collection)
Tereta\Orm\Records\Model
$connection = new Connection($pdo);
$connection->table('user')->model(...$conditions)
$connection->table('user')->model() # Create a new model
$connection->table('user')->model(1) # Load by primary key
$connection->table('user')->model('id', 1) # Load by the 'id' field
$connection->table('user')->model(fn() => $this->select()->where('id', 1)) # Load by the 'id' field via select
$model = $connection->table('user')->model() # Create an empty model
$model->select()->where('id', 1); # Build a query via select
$model->get('id'); # Hydrate from the query
...$conditions set the selection condition; with no arguments β an empty model.
->model(12) β a record identifier may be passed; the ORM determines the primary key itself from the DB schema
->model('column', 'my@email.com') β when ($field, $value) β $field is the field, $value is the value;
->model(fn() => $this->select()->where('id', 1)) β a Closure that receives the Select object in $this for arbitrary query customization.
->model()->select()->... β create an empty model and build a query; when $model->get() is called, the model is hydrated.
Relations for model (FOREIGN KEY)
Two loading types are available: belongs-to via foreign(string $column): ?Model and has-many via references(string $column): array
Relations are detected automatically from the FOREIGN KEY of the DB schema β there is no need to describe them manually.
A model has two methods for navigating relations:
foreign(string $column): ?Modelβ belongs-to (one-to-one). Loads the related model by the foreign key from the$columncolumn of the current table. Returns the model ornull.references(string $column): arrayβ has-many (one-to-many). Finds all records in other tables whoseFOREIGN KEYreferences the$columncolumn of the current record. Returns an array of models (array<int, Model>), or an empty array if there are no references.
$connection = new Connection($pdo);
# belongs-to: the address has user_id -> load the owner
$address = $connection->table('user_address')->model(1);
$user = $address->foreign('user_id'); # ?Model (the parent record from the 'user' table)
$user->get('email');
# has-many: for a user, load all of their addresses
$user = $connection->table('user')->model(4);
$addresses = $user->references('id'); # array<Model> (records referencing user.id)
foreach ($addresses as $address) {
$address->get('address');
}
The target table and column are determined from the schema automatically. The results of foreign() and
references() are kept in memory; a repeated call with the same $column returns the already loaded models.
Tereta\Orm\Iterators\Collection
$connection = new Connection($pdo);
$connection->table('user')->collection(...$conditions)
$connection->table('user')->collection() # The whole table
$connection->table('user')->collection('age', 18) # Filter by the 'age' field
$connection->table('user')->collection(fn() => $this->select()->where('age', 18)) # Filter via select
$collection = $connection->table('user')->collection() # Get the collection
$collection->select()->where('age', 18); # Configure the query via select
foreach ($collection as $model) { # Iteration β each element is a Model
$model->get('email');
}
...$conditions set the selection condition; with no arguments β the whole table.
->collection('column', 'my@email.com') β when ($field, $value) β $field is the field, $value is the value;
->collection(fn() => $this->select()->where('age', 18)) β a Closure that receives the Select object in $this for arbitrary query customization.
->collection()->select()->... β get the collection and configure the query; the data is hydrated on the first iteration.
The collection implements Iterator and works in Lazy and Eager Loading modes; each of its elements is an Active Record model (Tereta\Orm\Records\Model).
Eager Loading, (N+1). Relations for models (FOREIGN KEY)
For collections, preloading (Eager Loading) is available so as not to issue a separate query for each
record and to avoid the N+1 problem. Before iteration, mark the relation with the fluent methods ->foreign('user_id') or
->reference('id') β the data is pulled in batches, and inside the loop foreign()/references() already
return the cached models.
$addresses = $connection->table('user_address')->collection()->foreign('user_id');
foreach ($addresses as $address) {
$address->foreign('user_id'); # The Model is already preloaded, without a separate query
}
Cache
The most expensive operation in a schema-first ORM is loading the DB schema: reading the list of tables, their columns, types, primary and foreign keys.
Tereta/ORM does it lazily (on first access) and caches the result so as not to hit INFORMATION_SCHEMA / PRAGMA on every query.
If you use Swoole or Roadrunner β in that case caching is optional.
PSR-6
The cache is implemented via the PSR-6 standard (Caching Interface). The ORM depends only on
psr/cache (^3.0) and accepts any compatible
Psr\Cache\CacheItemPoolInterface β Symfony Cache, Redis, Memcached, a file-based or in-memory
adapter. You choose the concrete implementation yourself; the ORM is not tied to it.
A snapshot of the schema is cached β tables and primary keys (['tables' => ..., 'primaryKey' => ...]),
under the key teretaOrm.schema (plus namespace, if one is set).
use Tereta\Orm\Connection;
$connection = (new Connection($pdo))
->cache($pool); # $pool β any Psr\Cache\CacheItemPoolInterface
# Multiple connections/DBs in one pool are separated via namespace:
$connection = (new Connection($pdo))
->cache($pool, 'shop'); # key -> teretaOrm.schema.shop
->cache(CacheItemPoolInterface $pool, string $namespace = '')β attach a PSR-6 pool. On the first schema read the snapshot is saved to the pool, and on subsequent reads it is taken from it (isHit()), without hitting the DB. It is recommended to specify the $namespace attribute if your worker or process uses multiple DBs.->updateCache(bool $update = true)β drop the cached snapshot and force the ORM to re-read the schema from the DB again (for example, after a migration that changed tables or foreign keys).
Ready-made PSR-6 implementations
Any library that provides Psr\Cache\CacheItemPoolInterface will work as $pool.
The most common ones:
| Library | Composer package | Backends |
|---|---|---|
| Symfony Cache | symfony/cache | Redis, Memcached, APCu, PDO, files, in-memory (array), etc. |
| Cache (PHP-cache.com) | cache/* (e.g. cache/redis-adapter, cache/filesystem-adapter) | Redis, Memcached, files, APCu, PDO, Predis, void/array |
| Stash | tedivm/stash | FileSystem, SQLite, APC, Memcached, Redis, in-memory |
| Scrapbook | matthiasmullie/scrapbook | Memcached, Redis, APC, MySQL, files, in-memory |
| Doctrine Cache | doctrine/cache | Redis, Memcached, APCu, files, in-memory (legacy) |
| Laminas Cache | laminas/laminas-cache | Redis, Memcached, APCu, files, in-memory |
Connection examples:
# Symfony Cache + Redis
use Symfony\Component\Cache\Adapter\RedisAdapter;
$pool = new RedisAdapter(RedisAdapter::createConnection('redis://127.0.0.1'));
# Symfony Cache, in-memory (for Swoole/tests β without external storage)
use Symfony\Component\Cache\Adapter\ArrayAdapter;
$pool = new ArrayAdapter();
# Symfony Cache, file-based
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
$pool = new FilesystemAdapter('tereta-orm', 0, '/tmp/cache');
$connection = (new Connection($pdo))->cache($pool);
# After changing the DB schema β invalidate the cache and re-read:
$connection->cache($pool)->updateCache();
Swoole / long-running processes
The schema is loaded once per Connection instance and is then kept in the process memory (lazy-load with a "loaded" flag).
That is why in long-lived environments β Swoole, RoadRunner, FrankenPHP, workers β an external PSR-6 cache is not required: a schema cache warmed into memory is enough.
After the first request, the schema is already in the worker's memory and is reused by all subsequent requests without repeated reflection and without accessing external storage. An external PSR-6 pool in such a scenario is only needed to survive worker restarts or to share the snapshot between them. In classic FPM, however, where a process lives for a single request, a PSR-6 pool (Redis/file-based, etc.) is the primary way to avoid reloading the schema on every request.
Example: Swoole HTTP Server
The main rule is to create the Connection once per worker (in the workerStart handler), not on every request.
Then the schema will be read from the DB on the first access and will then live in the worker's memory and be reused by all of that worker's requests.
use Swoole\Http\Server;
use Swoole\Http\Request;
use Swoole\Http\Response;
use Tereta\Orm\Connection;
$server = new Server('0.0.0.0', 9501);
$server->set(['worker_num' => 4]);
# One Connection (and PDO) per worker β kept in the process memory
$connections = [];
$server->on('workerStart', function (Server $server, int $workerId) use (&$connections) {
$pdo = new PDO('mysql:host=127.0.0.1;dbname=shop', 'user', 'pass', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
# Without an external PSR-6: a schema cache warmed into memory is enough.
# The schema will be read from the DB on the first request and will stay in the worker's memory.
$connections[$workerId] = new Connection($pdo);
});
$server->on('request', function (Request $request, Response $response) use ($server, &$connections) {
$connection = $connections[$server->worker_id];
# The worker's first request will read the schema from the DB, subsequent ones β take it from memory,
# without repeated INFORMATION_SCHEMA / PRAGMA reflection.
$user = $connection->table('user')->model(1);
$response->header('Content-Type', 'application/json');
$response->end(json_encode(['email' => $user?->get('email')]));
});
$server->start();
If you need the schema not to be re-read from the DB even on the first request of each new/restarted worker, add a shared PSR-6 pool β then the first worker will put the snapshot into storage, and the rest (and workers after a restart) will pick it up from there:
$server->on('workerStart', function (Server $server, int $workerId) use (&$connections, $pool) {
$pdo = new PDO('mysql:host=127.0.0.1;dbname=shop', 'user', 'pass');
# $pool β any Psr\Cache\CacheItemPoolInterface (e.g. a Redis adapter),
# shared across all workers: the schema snapshot survives restarts and is shared between them.
$connections[$workerId] = (new Connection($pdo))->cache($pool, 'shop');
});