ez-php / orm
ORM module for the ez-php framework — Active Record style models with a fluent query builder and schema builder
Requires
- php: ^8.5
- ez-php/cache: ^2.7.0
- ez-php/console: ^2.7.0
- ez-php/contracts: ^2.7.0
Requires (Dev)
- ez-php/bignum: ^2.7.0
- ez-php/dataloader: ^2.7.0
- ez-php/docker: ^2.7.0
- ez-php/logging: ^2.7.0
- ez-php/testing-application: ^2.7.0
- friendsofphp/php-cs-fixer: ^3.94
- phpstan/phpstan: ^2.1
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^13.0
Suggests
- ez-php/bignum: Needed for the 'bigint' and 'decimal[:scale]' attribute casts
- ez-php/dataloader: Needed for RelationBatcher (batched lazy relation loading)
- ez-php/logging: Needed for LoggingDatabase (SQL query logging)
Provides
None
Conflicts
None
Replaces
None
- dev-main
- 2.7.1
- 2.7.0
- 2.6.5
- 2.6.4
- 2.6.3
- 2.6.2
- 2.6.1
- 2.6.0
- 2.5.10
- 2.5.9
- 2.5.8
- 2.5.7
- 2.5.6
- 2.5.4
- 2.5.3
- 2.5.2
- 2.5.1
- 2.5.0
- 2.4.11
- 2.4.10
- 2.4.9
- 2.4.8
- 2.4.7
- 2.4.6
- 2.4.5
- 2.4.4
- 2.4.3
- 2.4.2
- 2.4.1
- 2.4.0
- 2.3.9
- 2.3.8
- 2.3.7
- 2.3.6
- 2.3.5
- 2.3.4
- 2.3.3
- 2.3.2
- 2.3.1
- 2.3.0
- 2.2.1
- 2.2.0
- 2.1.1
- 2.1.0
- 2.0.1
- 2.0.0
- 1.14.0
- 1.13.1
- 1.13.0
- 1.12.2
- 1.12.1
- 1.12.0
- 1.11.2
- 1.11.1
- 1.11.0
- 1.10.0
- 1.9.2
- 1.9.1
- 1.9.0
- 1.8.0
- 1.7.1
- 1.7.0
- 1.6.1
- 1.6.0
- 1.5.1
- 1.5.0
- 1.4.2
- 1.4.1
- 1.4.0
- 1.3.0
- 1.2.0
- 1.1.1
- 1.1.0
- 1.0.1
- 1.0.0
- 0.9.3
- 0.9.2
- 0.9.1
- 0.9.0
- 0.8.6
- 0.8.5
- 0.8.4
- 0.8.3
- 0.8.2
- 0.8.1
- 0.8.0
- 0.7.1
- 0.7.0
- 0.6.2
- 0.6.1
- 0.6.0
- 0.5.2
- 0.5.1
- 0.5.0
- 0.4.1
- 0.4.0
- 0.3.0
- 0.2.0
- 0.1.0
This package is auto-updated.
Last update: 2026-10-30 15:48:13 UTC
README
ORM module for the ez-php framework — Data Mapper pattern with Entity, AbstractRepository, a fluent QueryBuilder, and a Schema builder.
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