Search by

ORM

Package info

github.com/marekskopal/orm

pkg:composer/marekskopal/orm

Statistics

Installs: 1 401

Dependents: 2

Suggesters: 0

Stars: 0

Open Issues: 0

v2.0.3 2026-10-10 19:09 UTC

This package is auto-updated.

Last update: 2026-10-20 13:31:52 UTC


README

A lightweight Object-Relational Mapping (ORM) library for PHP.

Features

  • Simple and intuitive declaration of entities by adding Column Attributes to class properties
  • Supports various property types including Uuid, DateTime and Enums
  • Handles one-to-many, many-to-one, one-to-one, and many-to-many relationships
  • Query provider for database interactions
  • Migration module for creating and updating database schema
  • Very fast in comparison to other ORM libraries - see benchmarks

Supported Databases

  • MySQL
  • PostgreSQL
  • SQLite

Installation

Upgrading from 1.x? See UPGRADE-2.0.md.

Install via Composer:

composer require marekskopal/orm

Basic Usage

//Create DB connection - MySQL (the connection opens on the first query)
$database = new MySqlDatabase('localhost', 'root', 'password', 'database');

//Create DB connection - PostgreSQL
$database = new PostgresDatabase('localhost', 'postgres', 'password', 'database');

//Create DB connection - SQLite
$database = new SqliteDatabase('/path/to/database.sqlite');

//Create schema
$schema = new SchemaBuilder()
    ->addEntityPath(__DIR__ . '/Entity')
    ->build();
    
$orm = new ORM($database, $schema);

//Create new entity
$user = new User(
    'John',
    'Doe',
);
$orm->getRepository(User::class)->persist($user);

//Find entity by id
$user = $orm->getRepository(User::class)
    ->findOne(['id' => 1]);

//Update entity
$user->firstName = 'Jane';
$orm->getRepository(User::class)->persist($user);

//Delete entity
$orm->getRepository(User::class)->delete($user);

Entity Declaration

You can declare entities by adding Entity attribute to class and Column attribute to class properties.

use MarekSkopal\ORM\Attribute\Column;
use MarekSkopal\ORM\Attribute\ColumnEnum;
use MarekSkopal\ORM\Attribute\Entity;
use MarekSkopal\ORM\Enum\Type;

#[Entity]
final class User
{
    #[Column(type: Type::Int, primary: true, autoIncrement: true)]
    public int $id;

    public function __construct(
        #[Column(type: Type::Timestamp)]
        public DateTimeImmutable $createdAt,
        #[Column(type: Type::String)]
        public string $name,
        #[Column(type: Type::String, nullable: true, size: 50)]
        public ?string $email,
        #[Column(type: Type::Boolean)]
        public bool $isActive,
        #[ColumnEnum(enum: UserTypeEnum::class)]
        public UserTypeEnum $type,
    ) {
    }
}

Table and column names are derived from class name and parameters, but can be customized by providing additional parameters to attributes.

Entities are hydrated by calling the constructor with the mapped values, then setting the remaining mapped properties. Constructor parameters must have the same names as their properties. Properties may be private, protected or readonly: the generated hydrator runs in the entity's own scope.

#[Entity(table: 'users')]
final class User
{
    #[Column(type: Type::String, name: 'my_last_name_column')]
    public string $lastName;
}

Relationships

ManyToOne and OneToMany

#[Entity]
final class User
{
    #[ManyToOne(entityClass: Address::class)]
    public Address $address;

    #[OneToMany(entityClass: User::class)]
    public Collection $children;
}

OneToOne

Use #[OneToOne] for a unique relationship between two entities. The owning side holds the foreign key column; the inverse side uses mappedBy pointing to the owning property name.

#[Entity]
final class User
{
    // Owning side — stores profile_id column
    #[OneToOne(entityClass: Profile::class)]
    public Profile $profile;
}

#[Entity]
final class Profile
{
    // Inverse side — no column, loaded via User::$profile FK
    #[OneToOne(entityClass: User::class, mappedBy: 'profile')]
    public ?User $user;
}

ManyToMany

Use #[ManyToMany] for a join-table relationship. The owning side declares the join table and column names; the inverse side uses mappedBy.

Column names default to the entity short name with an Id suffix (e.g. user_id, tag_id) when not specified explicitly.

#[Entity]
final class User
{
    // Owning side — manages the user_tags join table
    #[ManyToMany(
        entityClass: Tag::class,
        joinTable: 'user_tags',
        joinColumn: 'user_id',        // defaults to user_id
        inverseJoinColumn: 'tag_id',  // defaults to tag_id
    )]
    public Collection $tags;
}

#[Entity]
final class Tag
{
    // Inverse side — loaded via User::$tags join table info
    #[ManyToMany(entityClass: User::class, mappedBy: 'tags')]
    public Collection $users;
}

Cascade Operations

Relations support cascade operations via the cascade parameter. Supported values are CascadeEnum::Persist and CascadeEnum::Remove.

use MarekSkopal\ORM\Schema\Enum\CascadeEnum;

#[Entity]
final class Author
{
    #[OneToMany(entityClass: Post::class, cascade: [CascadeEnum::Persist, CascadeEnum::Remove])]
    public Collection $posts;
}

CascadeEnum::Persist — when persist() is called on the owning entity, all related entities are persisted automatically:

$post1 = new Post('First Post', $author);
$post2 = new Post('Second Post', $author);
$author = new Author('John', new Collection([$post1, $post2]));

// Persists author and both posts in the correct order
$orm->getRepository(Author::class)->persist($author);

CascadeEnum::Remove — when delete() is called on the owning entity, all related entities are deleted automatically before the owner (to avoid FK constraint violations):

$author = $orm->getRepository(Author::class)->findOne(['id' => 1]);

// Deletes all posts belonging to the author, then deletes the author
$orm->getRepository(Author::class)->delete($author);

Cascade is supported on OneToMany, ManyToOne, OneToOne, and ManyToMany relations, and it is followed recursively: a persisted post's author is persisted, and so is that author's profile if its relation cascades too. Relations that were never loaded are skipped, since they cannot hold changes. For ManyToMany, cascade persist brings the join table in line with the collection; removing an entity always deletes its join table rows, but never the entities on the other side.

Dates

You can use DateTime or DateTimeImmutable properties in entities. The library will automatically convert datetime or timestamp columns values to those to objects.

#[Entity]
final class User
{
    #[Column(type: Type::Timestamp)]
    public DateTimeImmutable $createdAt;
    
    #[Column(type: Type::DateTime)]
    public DateTime $updatedAt;
}

Enums

You can use native PHP enums in entities. The library will automatically convert enum column values to enum objects.

use MarekSkopal\ORM\Attribute\ColumnEnum;

#[Entity]
final class User
{
    #[ColumnEnum(enum: UserTypeEnum::class)]
    public UserTypeEnum $type;
}

Repository declaration

You can declare you repositories by extending AbstractRepository class and providing repository class in Entity attribute on entity class.

use MarekSkopal\ORM\Repository\AbstractRepository;

/** @extends AbstractRepository<User> */
class UserRepository extends AbstractRepository
{

}
#[Entity(repositoryClass: UserRepository::class)]
final class User
{

}

Persisting and deleting

persist() and delete() on a repository write right away. An entity read from the database (or written earlier) is updated, and only the columns that changed since then are written; an unchanged entity costs no query. A new entity is inserted and becomes the instance findOne() returns for its id. A deleted entity is forgotten, so a later findOne() for its id does not return it.

Primary keys:

  • An auto-increment key is assigned by the database on insert. An entity built by hand with its id already set is treated as an existing row and updated in full.
  • A key that is not auto-increment, such as a UUID, is yours to set before persisting. For an entity with such a key that is not managed (not read from the database, or detached by clearing the identity map), the flush checks whether its row exists, with one query per class, and updates it or inserts it.

Unit of work

To write many entities at once, schedule them on the unit of work and flush:

$unitOfWork = $orm->getUnitOfWork();

foreach ($rows as $row) {
    $unitOfWork->persist(new User($row['name'], $row['email']));
}
$unitOfWork->remove($obsoleteUser);

$unitOfWork->flush();

flush() writes, in this order:

  1. inserts, parents before the children that reference them, with one multi-row INSERT per class and level;
  2. updates of the changed columns only;
  3. join table changes of loaded ManyToMany collections, as the difference between the stored rows and the collection;
  4. deletes, children before parents, with one DELETE ... IN per class and level.

Only the scheduled entities and the entities their cascade relations reach are written. The work runs in a transaction unless it is a single statement or a transaction is already open; if a statement fails, the transaction is rolled back, its in-memory effects (assigned ids, identity map entries, snapshots) are undone, and the work stays scheduled, so flush() can be retried. Repository persist() and delete() flush the unit of work, including anything scheduled on it directly.

refresh($entity) reloads an entity's properties from the database and discards unflushed changes; readonly properties keep their value.

Queries

You can use QueryProvider to create queries.

$queryProvider = $orm->getQueryProvider();

Select

You can create select queries using Select builder.

$user = $queryProvider->select(User::class)
    ->where(['id' => 1])
    ->fetchOne();

Fetching results

fetchAll() returns a plain list of entities, so the result can be counted, indexed and iterated more than once. fetchAssocAll() does the same with raw rows as associative arrays. Repository findAll() returns the same list.

$users = $queryProvider->select(User::class)
    ->where(['isActive' => true])
    ->fetchAll();

count($users);
$firstUser = $users[0] ?? null;

For large result sets, iterate() and iterateAssoc() stream rows one at a time instead of building the whole list in memory. They return generators, which can be consumed only once. When relations are eager-loaded with with(), iterate() buffers the raw rows first so the related entities can be loaded in a single query.

foreach ($queryProvider->select(User::class)->iterate() as $user) {
    // process $user
}

Builder state

Select is a mutable builder: where(), orWhere(), orderBy(), limit(), columns() and the other configuration methods change the builder and return the same instance. Clone it before branching into two different queries.

$active = $queryProvider->select(User::class)->where(['isActive' => true]);
$activeAdmins = (clone $active)->where(['type' => UserTypeEnum::Admin]);

The terminal methods fetchOne(), fetchAll(), iterate(), count() and their Assoc variants do not modify the builder, so the same builder can be executed repeatedly.

Where

You can use where method to filter results.

Multiple AND conditions can be crated either by passing array of conditions or by chaining where method.

//Array of conditions
$user = $queryProvider->select(User::class)
    ->where([
        'id' => 1,
        'isActive' => true
    ])
    ->fetchOne();

//Chained conditions
$user = $queryProvider->select(User::class)
    ->where(['id' => 1])
    ->where(['isActive' => true])
    ->fetchOne();

OR conditions can be created by using orWhere method.

$user = $queryProvider->select(User::class)
    ->where(['id' => 1])
    ->orWhere(['first_name' => 'John'])
    ->fetchOne();

You can also use where method with nested conditions by passing function. The function receives a WhereBuilder to configure in place.

use MarekSkopal\ORM\Query\Where\WhereBuilder;

// Create nested condition: (id = 1 AND (first_name = 'John' OR last_name = 'Doe'))
$user = $queryProvider->select(User::class)
    ->where(['id' => 1])
    ->where(function (WhereBuilder $where) {
        $where->where(['first_name' => 'John'])
            ->orWhere(['last_name' => 'Doe']);
    })
    ->fetchOne();

Conditions can be written as ['column' => value] pairs or as [column, operator, value] triples. Column names may be given as property names or database column names, and relations can be traversed with dots.

$users = $queryProvider->select(User::class)
    ->where([
        ['createdAt', '>=', new DateTimeImmutable('2024-01-01')],
        ['type', 'IN', [UserTypeEnum::Admin, UserTypeEnum::User]],
        ['address.city', 'LIKE', 'Br%'],
        ['middleName', '=', null], // rendered as IS NULL
        ['deletedAt', '!=', null], // rendered as IS NOT NULL (also '<>')
    ])
    ->fetchAll();

You can pass another instance of Select object to where method to create subquery.

$subquery = $queryProvider->select(Address::class)
    ->columns(['user_id'])
    ->where(['city' => 'Brno']);
    
$user = $queryProvider->select(User::class)
    ->where(['address_id', 'IN', $subquery])
    ->fetchOne();

A comparison operator takes a subquery that returns a single value, e.g. ['id', '=', $maxIdSubquery] renders id = (SELECT ...).

Loading relations

Relations are loaded lazily, but never one query per row:

  • A ManyToOne or OneToOne property holds a lazy proxy until it is first read. All entities that reference the same id share one proxy, and that proxy is the instance the identity map returns for the id, so $a->author === $b->author holds. Reading the related entity's primary key does not load it.
  • When one proxy loads, every pending proxy of the same class loads with it in a single WHERE id IN (...) query. Iterating 200 posts and reading $post->author->name costs one query for the posts and one for all their authors.
  • A OneToMany or ManyToMany collection loads on first access with one query; ManyToMany joins the join table. Collection::isInitialized() tells whether it has been loaded.

Eager loading relations (with)

Use with() to load relations together with the result, one query per relation level regardless of the number of rows. It accepts every relation kind and dotted paths.

// 1 query for users + 1 for all their addresses
$users = $queryProvider->select(User::class)
    ->with('address')
    ->fetchAll();

// Collections: 1 query for authors + 1 for all their posts
$authors = $queryProvider->select(Author::class)
    ->with('posts')
    ->fetchAll();

// Several relations at once
$users = $queryProvider->select(User::class)
    ->with('address', 'profile', 'tags')
    ->fetchAll();

// Nested paths: tags, their users, and those users' addresses
$tags = $queryProvider->select(Tag::class)
    ->with('users.address')
    ->fetchAll();

Insert

You can insert entities using Insert builder.

$user = new User(
    'John',
    'Doe',
);

$queryProvider->insert(User::class)
    ->entity($user)
    ->execute();

//created entity will have id set automatically

Insert multiple entities

You can insert multiple entities at once in one insert query.

   
$userA = new User(
    'John',
    'Doe',
);

$userB = new User(
    'Jane',
    'Doe',
);

$queryProvider->insert(User::class)
    ->entity($userA)
    ->entity($userB)
    ->execute();

On PostgreSQL and SQLite the generated primary keys are read back via INSERT ... RETURNING, so the assigned ids are always exact. MySQL has no RETURNING; ids are derived from lastInsertId() plus row offset, which relies on auto-increment values within one statement being consecutive. That is guaranteed with innodb_autoinc_lock_mode 0 or 1, but not with mode 2 (the MySQL 8 default) under concurrent insert load. If you batch-insert on MySQL with lock mode 2 and concurrent writers, either set innodb_autoinc_lock_mode = 1 or persist entities one at a time.

Update

You can update entities using Update builder.

$user = $queryProvider->select(User::class)
    ->where(['id' => 1])
    ->fetchOne();

$user->firstName = 'Jane';

$queryProvider->update(User::class)
    ->entity($user)
    ->execute();

The builder writes every updatable column. Pass values(['email' => 'jane@example.com']) to write only the given columns.

Delete

You can delete entities using Delete builder.

$user = $queryProvider->select(User::class)
    ->where(['id' => 1])
    ->fetchOne();
    
$queryProvider->delete(User::class)
    ->entity($user)
    ->execute();

Delete multiple entities

You can delete multiple entities at once in one delete query.

   
$userA = $queryProvider->select(User::class)
    ->where(['id' => 1])
    ->fetchOne();

$userB = $queryProvider->select(User::class)
    ->where(['id' => 2])
    ->fetchOne();

$queryProvider->delete(User::class)
    ->entity($userA)
    ->entity($userB)
    ->execute();

Transactions

Use getTransactionProvider() to run operations inside a database transaction. The callback is committed on success and automatically rolled back if any exception is thrown.

$orm->getTransactionProvider()->transaction(function () use ($orm): void {
    $orm->getRepository(User::class)->persist($userA);
    $orm->getRepository(User::class)->persist($userB);
});

You can also manage the transaction manually:

$tp = $orm->getTransactionProvider();

$tp->beginTransaction();

try {
    $orm->getRepository(User::class)->persist($user);
    $tp->commit();
} catch (\Throwable $e) {
    $tp->rollback();
    throw $e;
}

Nesting transactions is not supported — calling transaction() inside an active transaction throws a TransactionException.

Schema caching

Building the schema scans the entity classes and reads their attributes, and the hydrator and extractor of each entity are generated on first use. In production, do both once in a build or deploy step and load the result:

// Build or deploy step
new SchemaBuilder()
    ->addEntityPath(__DIR__ . '/Entity')
    ->dump(__DIR__ . '/var/schema.php');

// Every request
$orm = new ORM($database, Schema::fromFile(__DIR__ . '/var/schema.php'));

The file contains the schema and the generated code. With opcache enabled it is served from shared memory, and each entity's schema is only built when a request first uses it. Opcache skips files modified in the last two seconds (opcache.file_update_protection), so a schema dumped right before a request is cached from the following ones on. Dump the schema again whenever an entity changes.

Connections and prepared statements

Creating a database object does not connect: the connection opens on the first query, so a request that never queries never connects. Call connect() to open it early, for example to surface connection errors at startup.

Each distinct SQL string is prepared once and its statement reused, which saves a round trip per query on MySQL and PostgreSQL. The cache keeps the 256 most recently used statements; set another size, or 0 to disable it, with the last constructor argument:

$database = new MySqlDatabase('localhost', 'root', 'password', 'database', statementCacheSize: 512);

After changing the schema on an open connection (for example in a migration), call $database->clearStatementCache(), since cached statements may refer to the old table definitions. Queries you run yourself can use the cache too with $database->execute($sql, $params).

Long-running applications

If you are using ORM in long-running PHP applications like FrankenPHP, Roadrunner or Swoole, clear the identity map after each request. It holds every entity read or written, together with the snapshot used to detect changes, until it is cleared.

$orm->getIdentityMap()->clear();

Security considerations

All values in queries are bound through prepared-statement placeholders. Column names are restricted to [A-Za-z0-9_]+ and quoted, and where-condition operators are validated against an allowlist (=, !=, <>, <, <=, >, >=, LIKE, NOT LIKE, IN, NOT IN), so user input can safely be passed as values, column names, or operators — invalid input throws InvalidArgumentException instead of reaching the database.

Raw SQL expressions

SQL expressions (function calls, computed columns) must be wrapped in RawExpression, which is used verbatim in the query:

use MarekSkopal\ORM\Query\Expression\RawExpression;

$count = $queryProvider->select(User::class)
    ->columns([new RawExpression('count(*) as c'), 'type'])
    ->groupBy(['type'])
    ->fetchAssocAll();

Never build a RawExpression from user input — it bypasses all escaping and validation.

Exception messages

QueryException and ConstrainException carry the executed SQL (via getQuery()) and the raw driver error message. Log them server-side, but never render them to end users — they disclose schema and query structure.

LIKE wildcards

Values bound to LIKE conditions are safely parameterized, but % and _ inside the value still act as wildcards. If user input must be matched literally, escape those characters yourself (e.g. addcslashes($value, '%_\\') on MySQL/PostgreSQL).

Running tests

vendor/bin/phpunit

The SQLite tests always run. The MySQL and PostgreSQL integration tests connect to real servers and are skipped when the server is unreachable. They read their connection settings from these environment variables:

Driver Variables Defaults
MySQL MYSQL_HOST, MYSQL_PORT, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DB 127.0.0.1, 3306, root, empty, orm_test
PostgreSQL POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB localhost, 5432, postgres, empty, orm_test

To run them against throwaway containers:

docker run -d --rm --name orm-pg -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=orm_test -p 5432:5432 postgres:16
docker run -d --rm --name orm-mysql -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=orm_test -p 3306:3306 mysql:8

POSTGRES_PASSWORD=postgres MYSQL_PASSWORD=root ORM_TEST_REQUIRE_DATABASES=1 vendor/bin/phpunit

ORM_TEST_REQUIRE_DATABASES=1 turns a skipped driver test into a failure, which is how CI runs them.