Verified against Semitexa Ultimate 2026.09.19.1020

ORM Working Guide

The practical manual for the package: how to declare resources, query, load relations, write aggregates, and wire a store. The structural rules live in MODULE_STRUCTURE.md; this file is about using the ORM day to day.

Concepts in one paragraph

A resource model is a final readonly class mapped to ONE table via attributes (#[FromTable], #[Column], #[PrimaryKey]). A domain model is your business object; an #[AsMapper] class converts between the two, and MapperRegistry routes those conversions. A DomainRepository gives you reads (findById, findBy, query()) and writes (insert/update/delete) in domain-model terms. The ORM is deliberately single-table: no JOINs — relations load as separate batched queries, and anything genuinely multi-table is a hand-written SQL decision, not an accident.

Declaring a resource

#[FromTable(name: 'orders')]
#[TenantScoped(strategy: 'same_storage', column: 'tenantId')]
#[SoftDelete(column: 'deletedAt')]
final readonly class OrderResource
{
    use HasColumnReferences;
    use HasRelationReferences;

    public function __construct(
        #[PrimaryKey(strategy: 'uuid')]              // string UUID PK; for AUTO_INCREMENT use strategy: 'auto' with `public int $id` + MySqlType::Int
        #[Column(type: MySqlType::Varchar, length: 36)]
        public string $id,

        #[Column(type: MySqlType::Varchar, length: 64)]
        public string $tenantId,

        #[Column(type: MySqlType::Varchar, length: 32)]
        public string $status,

        #[Column(type: MySqlType::Int)]
        public int $amount,

        #[Version]                                    // optimistic locking (see Writes)
        #[Column(type: MySqlType::Int)]
        public int $version = 1,

        #[Column(type: MySqlType::Datetime, nullable: true)]
        public ?DateTimeImmutable $deletedAt = null,

        #[HasMany(target: OrderItemResource::class, foreignKey: 'orderId',
                  writePolicy: RelationWritePolicy::CascadeOwned)]
        public ?RelationState $items = null,
    ) {}
}

bin/semitexa orm:sync creates/alters the table from this declaration (FKs, indexes, two-phase destructive ops behind --allow-destructive).

Queries

$repository->query() returns a ResourceModelQuery. Column and relation references are TYPED — ColumnRef::for(Class, 'prop') / RelationRef::for(Class, 'prop') throw on unknown members, and using a ref from another class in a query is rejected.

$orders = $repo->query()
    ->where(ColumnRef::for(OrderResource::class, 'status'), Operator::Equals, 'open')
    ->whereIn(ColumnRef::for(OrderResource::class, 'tenantId'), $ids)
    ->orderBy(ColumnRef::for(OrderResource::class, 'amount'), Direction::Desc)
    ->limit(50)
    ->fetchAllAs(OrderDomain::class);           // mapper registry defaults to the repo's

Available: where (eq/neq/gt/gte/lt/lte/like/notlike), whereIn, whereNotIn, whereNull/whereNotNull, whereBetween, whereAnyLike (OR-group), whereRaw (placeholder-scanned escape hatch), offset pagination (paginate) and keyset cursors (through the collection feed compiler).

Aggregations (single-table)

$total   = $q->sum(ColumnRef::for(OrderResource::class, 'amount'));   // 0 on empty
$mean    = $q->avg(...);                                              // null on empty
$oldest  = $q->min(...);  $newest = $q->max(...);                     // null on empty
$byState = $q->countBy(ColumnRef::for(OrderResource::class, 'status'));
// ['open' => 12, 'done' => 30] — same WHERE/tenant/soft-delete state as fetchAll()

Tenancy is fail-closed

A query on a #[TenantScoped] resource without tenant context throws a LogicException. Call forTenant($value), or opt out explicitly with withoutTenantScope(SystemScopeToken) — there is no silent global read.

Relations

Relation properties are typed ?RelationState and hydrate to an UNLOADED state; loading is always explicit and always batched (one IN (...) query per relation — no lazy N+1):

$orders = $repo->findBy([...], relations: [
    RelationRef::for(OrderResource::class, 'items'),
    RelationRef::path(OrderResource::class, 'items.product'),   // nested: dot paths
]);

Dot paths recurse level by level and stay batched: items.product is exactly two queries regardless of row counts. Every segment is validated at construction.

Write policies

Each relation declares who owns the rows on write:

Policy Meaning on insert/update/delete
CascadeOwned Children are part of the aggregate: written/replaced/deleted with the root
SyncPivotOnly Only the pivot table is synced (delete + chunked batch insert); related rows untouched
ReferenceOnly Never written; the FK column must already agree with the referenced object (validated)

Writes

DomainRepository::insert/update/delete run through the AggregateWriteEngine, and every call is atomic: root row + cascade-owned children + pivot sync commit or roll back together (TransactionManager::run; a caller already inside a transaction nests as a savepoint). The ResourceChangedEvent auto-publish signal fires strictly AFTER commit.

Optimistic locking

Declare one int column as #[Version]. Updates then guard on the version the aggregate was read with and bump it in the same statement:

try {
    $order = $repo->update($order);        // WHERE id = ? AND version = ?
} catch (StaleAggregateException) {
    // someone committed first — re-read, reapply, retry
}

update() returns the aggregate with the BUMPED version — reassign it if you keep working with the object, otherwise the next update of the stale in-memory copy will (correctly) throw. Without #[Version] updates behave as before (last write wins).

Transactions

$orm->getTransactionManager()->run(function (DatabaseAdapterInterface $tx) {
    // statements here MUST use $tx — it is bound to the transaction's
    // connection; the pooled adapter would bypass the BEGIN.
});

Nesting creates savepoints. All transaction state is per-coroutine (CoroutineLocal) — safe under Swoole concurrency.

The engine's ResourceChangedEvent auto-publish is commit-gated: a repository write that nests inside your run() buffers its signal on the TransactionManager and it flushes after YOUR commit (and is dropped on rollback). If you call bufferEvent() yourself, wire a dispatcher with setEventDispatcher() first — flushing with no dispatcher discards silently.

Wiring a store

Use the OrmBackedStore trait instead of hand-rolling the ritual:

#[AsService]
final class OrderStore
{
    use OrmBackedStore;

    // The injected property MUST live on the class (framework rule:
    // #[InjectAs*] is forbidden inside traits).
    #[InjectAsReadonly]
    protected OrmManager $orm;

    public function open(string $tenantId): array
    {
        return $this->domainRepository(OrderResource::class, OrderDomain::class)
            ->forTenant($tenantId)
            ->query()
            ->where(ColumnRef::for(OrderResource::class, 'status'), Operator::Equals, 'open')
            ->fetchAllAs(OrderDomain::class);
    }
}

findByIdOrFail() throws the framework NotFoundException (HTTP 404).

Gotchas worth knowing

  • Never memoize what OrmManager hands out across requests — pools self-heal and getters re-check; capture lazily (see the write engine's closure-injected dispatcher/transaction manager for the pattern).
  • CLI writes and auto-publish: the engine resolves its event dispatcher per dispatch, so long-running workers keep publishing after late bootstrap. Don't "optimize" that closure away.
  • whereRaw placeholders are scanned quote-aware; still, prefer typed refs — raw SQL bypasses column validation, not tenancy (the tenant gate applies regardless).
  • orm:sync never drops without --allow-destructive, and drops are two-phase (deprecation comment first).