tereta/orm

ORM (Object-Relational Mapping) library for PHP, providing a simple and efficient way to interact with databases using an object-oriented approach.

Maintainers

Package info

gitlab.com/tereta/library/orm

Homepage

Issues

pkg:composer/tereta/orm

Statistics

Installs: 8

Dependents: 0

Suggesters: 0

Stars: 0

v0.1.3 2026-03-08 18:34 UTC

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 collections
  • Tereta\Orm\Records\Model β€” Implements Active Record models
  • Tereta\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.

ORMPatternSource of truthModel definitionRelations
Tereta/ORMActive Record + Table GatewayExisting DBNot needed (schema reflection)Auto, from FOREIGN KEY
Laravel EloquentActive RecordCodeColumns auto, model by handManual (relation methods)
Doctrine ORMData MapperCode (attributes/XML/YAML)Manual (mapping)Manual
CakePHP ORMTable Gateway + EntityDB (by convention)Not needed / by conventionAuto by convention
Yii 2 ActiveRecordActive RecordDB + codeColumns auto, model by handManual
PropelActive Recordschema.xmlClass generation from XMLFrom 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 $column column of the current table. Returns the model or null.
  • references(string $column): array β€” has-many (one-to-many). Finds all records in other tables whose FOREIGN KEY references the $column column 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:

LibraryComposer packageBackends
Symfony Cachesymfony/cacheRedis, 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
Stashtedivm/stashFileSystem, SQLite, APC, Memcached, Redis, in-memory
Scrapbookmatthiasmullie/scrapbookMemcached, Redis, APC, MySQL, files, in-memory
Doctrine Cachedoctrine/cacheRedis, Memcached, APCu, files, in-memory (legacy)
Laminas Cachelaminas/laminas-cacheRedis, 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');
});