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: 7 261

Dependents: 2

Suggesters: 1

Stars: 0

Open Issues: 0

2.5.4 2026-09-26 02:22 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);

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:

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

    public function author(Post $post): EntityBelongsTo
    {
        return $this->belongsTo(UserRepository::class, 'user_id', '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() and hasMany() work the same way (hasMany resolves to a list, empty when there are none). Many-to-many relations are not batched.

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();
});

$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()
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/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