Search by

ez-php / orm

AU9500

ORM module for the ez-php framework — Active Record style models with a fluent query builder and schema builder

Package info

github.com/ez-php/orm

pkg:composer/ez-php/orm

Statistics

Installs: 8 356

Dependents: 3

Suggesters: 2

Stars: 0

Open Issues: 0

2.7.1 2026-10-30 15:41 UTC

README

ORM module for the ez-php framework — Data Mapper pattern with Entity, AbstractRepository, a fluent QueryBuilder, and a Schema builder.

CI

Requirements

  • PHP 8.5+
  • ext-pdo
  • ez-php/framework 0.*

Installation

composer require ez-php/orm

Setup

Register the service providers:

$app->register(\EzPhp\Orm\EntityServiceProvider::class);
$app->register(\EzPhp\Orm\Schema\SchemaServiceProvider::class);

Usage

Defining an entity

use EzPhp\Orm\Entity;

class User extends Entity
{
    protected static string $table      = 'users';
    protected static bool   $timestamps = true;
    protected static array  $fillable   = ['name', 'email'];
    protected static array  $casts      = ['age' => 'int'];
    // also: 'gold' => 'bigint' (BigInteger), 'price' => 'decimal:2' (BigDecimal) — needs ez-php/bignum
}

Defining a repository

use EzPhp\Orm\AbstractRepository;

/**
 * @extends AbstractRepository<User>
 */
class UserRepository extends AbstractRepository
{
    protected function entityClass(): string
    {
        return User::class;
    }

    public function findByEmail(string $email): ?User
    {
        return $this->findOneBy('email', $email);
    }

    public function activeUsers(): array
    {
        return $this->query()->where('active', true)->orderBy('name')->get();
    }
}

Persisting

$repo = $app->make(UserRepository::class);

// INSERT
$user = new User(['name' => 'Alice', 'email' => 'alice@example.com']);
$repo->save($user);

// UPDATE (only dirty columns)
$user->name = 'Bob';
$repo->save($user);

// DELETE
$repo->delete($user);

Querying

$user  = $repo->find(1);
$all   = $repo->findAll();
$alice = $repo->findByEmail('alice@example.com');
$page  = $repo->query()->where('active', true)->paginate(perPage: 15, page: 1);

Row locks and scoping — lockForUpdate()/sharedLock() append FOR UPDATE/FOR SHARE (no-op on SQLite); call them inside a transaction. findForUpdate($id) is the repository shortcut. Override defaultCriteria() to scope every read of a repository (e.g. per tenant); withoutDefaultCriteria() bypasses it explicitly:

$db->transaction(function () use ($wallets, $id, $amount): void {
    $wallet = $wallets->findForUpdate($id);              // row locked until commit
    $wallets->incrementColumn($wallet, 'gold', $amount); // int, float, BigInteger or BigDecimal
});

final class ProjectRepository extends AbstractRepository
{
    protected function defaultCriteria(QueryBuilder $query): QueryBuilder
    {
        return $query->where('tenant_id', $this->tenants->current()->id);
    }
}

Large result sets: chunk() pages with LIMIT/OFFSET. chunkById() and lazy() page by key instead (WHERE id > last ORDER BY id) — every page costs the same, and rows the callback updates or deletes do not make later pages skip or repeat rows. Both keep the query's where() clauses and the soft-delete filter; the key column (default id) must be unique and selected:

$repo->query()->where('active', true)->chunkById(500, function (array $users): void {
    foreach ($users as $user) { /* may update or delete $user */ }
});

foreach ($repo->query()->lazy(1000) as $user) { /* one page in memory at a time */ }

(new QueryBuilder($db, 'orders'))->lazy(1000, 'orders.id'); // raw rows; qualified key column

For APIs over large tables, cursorPaginate() returns one page and an opaque cursor for the next, using the same keyset paging. Each page costs the same, and rows inserted or deleted between requests do not shift later pages. Ascending by the key only, and there is no total. A malformed cursor throws InvalidArgumentException, so answer it with a 400:

$page = $repo->query()->where('active', true)->cursorPaginate(perPage: 50, cursor: $request->query('cursor'));

$page->items();      // list of entities
$page->nextCursor(); // pass to the next request; null on the last page
$page->hasMore();

Column names are validated and quoted in every clause. having() and select() take a plain column or one aggregate over a column or * — COUNT, SUM, AVG, MIN, MAX, optionally DISTINCT (select() also accepts AS alias). For any other SQL expression use selectRaw(), which is emitted verbatim — never pass user input to it:

$qb->select('customer_id')->groupBy('customer_id')
   ->having('COUNT(*)', '>', 5)
   ->having('SUM(total)', '>=', 1000);

$qb->having('total) OR 1=1 --', 1);   // throws InvalidArgumentException
$qb->select('customer_id', 'COUNT(*) AS orders');
$qb->selectRaw('DATE(created_at) AS day');

Soft deletes

class Post extends Entity
{
    protected static string $table       = 'posts';
    protected static bool   $softDeletes = true;
}
$repo->delete($post);           // sets deleted_at — row stays in the DB
$post->trashed();               // true after soft delete

// Include soft-deleted rows
$all = $repo->withTrashed()->get();
$deleted = $repo->onlyTrashed()->get();

Relations

Define relation helpers on the repository, then call them on an entity. Each helper takes the related repository instance, the key columns and the owning entity's key value:

final class PostRepository extends AbstractRepository
{
    public function __construct(private readonly UserRepository $users, ?DatabaseInterface $db = null)
    {
        parent::__construct($db);
    }

    protected function entityClass(): string { return Post::class; }

    /** @return EntityBelongsTo<User> */
    public function author(Post $post): EntityBelongsTo
    {
        // FK on the post, PK on the user, the post's FK value
        return $this->belongsTo($this->users, 'user_id', 'id', $post->getAttribute('user_id'));
    }
}

// Lazy load
$author = $postRepo->author($post)->getResult();

// Eager load (avoids N+1)
$posts = $postRepo->query()->with('author')->get();

with() already loads a relation for a whole result set in one query. If you instead access relations lazily inside a loop, RelationBatcher (requires ez-php/dataloader) collapses those per-entity queries into one per relation:

use EzPhp\Orm\Relations\RelationBatcher;

$batcher = new RelationBatcher(); // one per request/unit of work — it memoizes by key
$pending = array_map(fn ($post) => $batcher->belongsTo($postRepo->author($post)), $posts);
$authors = array_map(fn ($d) => $d->get(), $pending); // a single users query

hasOne(), hasMany() and belongsToMany() work the same way (hasMany/belongsToMany resolve to a list, empty when there are none). belongsToMany() loads every queued owner through one pivot join:

$pending = array_map(fn ($user) => $batcher->belongsToMany($userRepo->tags($user)), $users);
$tags = array_map(fn ($d) => $d->get(), $pending); // one tags ⋈ user_tags query

Custom casts

use EzPhp\Orm\CastableInterface;

class Money implements CastableInterface
{
    public function __construct(private readonly int $cents) {}

    public static function castFrom(mixed $value): static
    {
        return new self((int) $value);
    }

    public function castTo(): mixed
    {
        return $this->cents;
    }
}

class Product extends Entity
{
    protected static array $casts = ['price' => Money::class];
}

Entity lifecycle observers

Attach observers to a repository to react to create/update/delete events:

use EzPhp\Orm\EntityObserverInterface;
use EzPhp\Orm\ObservableRepositoryTrait;

class AuditObserver implements EntityObserverInterface
{
    public function creating(object $entity): void {}
    public function created(object $entity): void { /* log insert */ }
    public function updating(object $entity): void {}
    public function updated(object $entity): void { /* log update */ }
    public function deleting(object $entity): void {}
    public function deleted(object $entity): void { /* log delete */ }
}

class UserRepository extends AbstractRepository
{
    use ObservableRepositoryTrait;
    // ...
}

$repo->observe(new AuditObserver());

The *ing hooks fire before the DB operation; *ed hooks fire after.

Append-only repositories

For insert-only tables (audit logs, event logs, immutable records) extend AppendOnlyRepository instead of AbstractRepository. create() inserts and returns the entity with its generated primary key; save() and delete() throw LogicException, so the constraint is enforced by the type rather than by convention.

/** @extends AppendOnlyRepository<LoginEvent> */
final class LoginEventRepository extends AppendOnlyRepository
{
    protected function entityClass(): string
    {
        return LoginEvent::class;
    }
}

$event = $repository->create(['user_id' => 7, 'ip' => '203.0.113.9']);

Typed attribute getters

Entity::getAttribute() returns mixed. Use the TypedAttributes trait for typed access that satisfies PHPStan level 9 without per-call narrowing:

final class User extends Entity
{
    use TypedAttributes;

    public function age(): int { return $this->getInt('age'); }
    public function bio(): ?string { return $this->getNullableString('bio'); }
    public function verifiedAt(): ?DateTimeImmutable { return $this->getNullableDatetime('verified_at'); }
}

getInt()/getString() fall back to a default for non-numeric/non-scalar values; getNullableDatetime() parses Y-m-d H:i:s and returns null otherwise.

Schema builder

use EzPhp\Orm\Schema\Schema;

$schema = new Schema($db); // $db: DatabaseInterface

$schema->create('users', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('email')->unique();
    $table->timestamps();
});

$schema->table('users', function (Blueprint $table) {
    $table->string('phone')->nullable();
});

// natural and composite primary keys
$schema->create('artists', function (Blueprint $table) {
    $table->string('spotify_id', 64)->primary();
});

$schema->create('playlist_tracks', function (Blueprint $table) {
    $table->string('playlist_id', 64);
    $table->unsignedInteger('position');
    $table->primary(['playlist_id', 'position']);
});

$schema->drop('old_table');

Query logging

LoggingDatabase decorates any DatabaseInterface and logs SQL + bindings + duration for every query()/execute() call via ez-php/logging (a soft dependency — install it separately, since it's declared in require-dev here, not require):

use EzPhp\Orm\LoggingDatabase;

$db = new LoggingDatabase($realDb, $logger); // $logger implements EzPhp\Logging\LoggerInterface
$repo = new UserRepository($db);

Opt-in only — wrap the connection you pass in yourself; nothing logs by default.

Console commands

Command Description
make:entity Scaffolds an Entity subclass in app/Entities/
make:repository Scaffolds an AbstractRepository subclass in app/Repositories/

Both are registered automatically by EntityServiceProvider::boot() when the application implements CommandRegistryInterface (the ez-php Application does). They write relative to the working directory (<cwd>/app/…) — run php ez from the project root.

Classes

Class Description
Entity Abstract Data Mapper entity base; attributes, casts, fillable guards, relation storage
AbstractRepository Abstract repository base; INSERT/UPDATE/DELETE, dirty tracking, relations, eager-load
AppendOnlyRepository Repository base for insert-only tables (audit/event logs): create() inserts; save()/delete() throw LogicException
TypedAttributes Trait adding getInt()/getString()/getBool()/getNullableInt()/getNullableString()/getNullableDatetime() to an entity
EntityObserverInterface Lifecycle hook contract: creating/created/updating/updated/deleting/deleted
ObservableRepositoryTrait Adds observer support to a repository; fires hooks around save() and delete()
EntityQueryBuilder Typed fluent query builder for entities; with(), withCount(), paginate(), chunk()/chunkById()/lazy()
EntityServiceProvider Calls Entity::setDatabase($db) in boot(); registers make:entity / make:repository
Hydrator Converts raw DB rows → Entity instances and Entity attributes → storage arrays
CastableInterface Interface for custom value-object casts: castFrom()/castTo()
DuplicateKeyException Thrown on duplicate-key violations by QueryBuilder::insert()/insertBatch() and therefore by repository save() (original PDOException is getPrevious())
Paginator Immutable page-of-results value object
QueryBuilder Fluent SQL builder for raw rows; all WHERE/JOIN/ORDER/LIMIT/aggregates/paginate/chunk/chunkById/lazy/cache
LoggingDatabase DatabaseInterface decorator logging SQL + bindings + duration
EntityHasMany One-to-many relation (FK on related entity)
EntityHasOne One-to-one relation (FK on related entity)
EntityBelongsTo Inverse of HasMany/HasOne (FK on owning entity)
EntityBelongsToMany Many-to-many relation via pivot table
Schema DDL façade: create(), table(), drop(), dropIfExists(), hasTable()
Blueprint Column and constraint builder for CREATE TABLE and ALTER TABLE

License

MIT — Andreas Uretschnig