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.0
- ez-php/console: ^2.0
- ez-php/contracts: ^2.0
Requires (Dev)
- ez-php/dataloader: ^2.0
- ez-php/docker: ^2.0
- ez-php/logging: ^2.0
- ez-php/testing-application: ^2.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/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.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-09-30 19:50:25 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);
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