ORM

Note

Not part of core. Install it separately:

composer require kinetis/orm

kinetis/orm depends on kinetis/query-builder and kinetis/persistence, never on kinetis/framework. Its contract — the mapping rules, identifiers, the admitted row values, the identity map, the repository and query API, writing and flushing, optimistic locking, and what it does not do — is the package README. This page covers setting it up.

A data mapper over Query Builder: classes marked #[Entity] load through typed repositories and entity queries without running their constructors, and each unit of work holds one object per row, tracks changes to it, and writes new, changed and removed entities in one transaction on flush(). Updates and deletes of an entity carrying #[Version] are optimistically locked. It has no relationships.

In a Kinetis application

With kinetis/database-bridge installed and DB_CONNECTION configured (Persistence), installing kinetis/orm is the whole wiring. A controller or service in the request scope injects EntityManager:

use Kinetis\Http\Attributes\Get;
use Kinetis\Http\Attributes\Post;
use Kinetis\Orm\EntityManager;

final readonly class ArticleController
{
    public function __construct(private EntityManager $entities) {}

    #[Get('/articles/{id}')]
    public function show(int $id): array
    {
        $article = $this->entities->repository(Article::class)->findOrFail($id);

        return ['title' => $article->title()];
    }

    #[Post('/articles/{id}/publish')]
    public function publish(int $id): array
    {
        $article = $this->entities->repository(Article::class)->findOrFail($id);
        $article->publish();
        $this->entities->flush();

        return ['title' => $article->title()];
    }
}

Article is an application entity, such as the one in the README.

Entity discovery

The bridge declares Kinetis\DatabaseBridge\OrmMetadata as its extra.kinetis discovery class (see CLI). Its compile step scans the project’s PSR-4 roots and every installed package’s scan roots, keeps the classes carrying #[Entity], and maps exactly those with MetadataRegistry. The result is one section of the AOT artifact (Caching & AOT Compilation): development discovers entities live at each boot, and production reads what kinetis build compiled. No environment key narrows the scan.

An entity the mapper refuses fails kinetis build, or a development boot, with its MappingException. A cached section that no longer matches the entity classes, or that was compiled before kinetis/orm was installed or removed, is stale: the boot compiles fresh, as for any other section.

An application entity opts into optimistic locking with #[Version] under the package README’s “Optimistic locking” contract; the bridge adds nothing to it. The attribute is part of the compiled metadata, so run kinetis build again after adding, removing or moving it.

Lifecycle

  • Kinetis\Orm\OrmFactory is bound on AppScope, one per worker. It is built on first use from the link bound under MysqlLink or PostgresLink — an application’s own binding of it in bootstrap.php included — and the compiled metadata.

  • Kinetis\Orm\EntityManager is bound on every RequestScope: an HTTP request, a queued job, an MCP message, a command. The first resolution in a scope opens it, owned by the Fiber resolving it, and registers its close() on that scope’s disposal. A scope that never resolves it opens none. Sequential and concurrent units of work never share a manager or an entity.

  • Nothing is flushed for you. Code that changes entities calls flush() before its unit of work ends; disposal closes the manager, abandons whatever was not flushed, and leaves the connection open.

  • EntityManager is never an AppScope service. Its constructor is not public, so AppScope refuses to autowire one rather than keep a manager for the life of the worker. Code that holds a manager is request-scoped.

Without a database

With kinetis/orm installed and DB_CONNECTION unset, the application still boots. Resolving OrmFactory or EntityManager throws Kinetis\DatabaseBridge\Exception\DatabaseNotConfiguredException, which names DB_CONNECTION.

Other connections and transactions

The bridge wires the default connection only. For a named connection, build a factory once from ConnectionFactory::fromConfig($config, 'reporting') and a MetadataRegistry, and pair each open() with close() in the unit of work that uses it, as in the next section.

flush() begins its own transaction and never joins one. Do not call it inside a TransactionGuard::transaction() callback, or anywhere the Fiber holds a transaction on the same connection: see the README’s “Transactions”.

Without Kinetis

use Kinetis\Orm\Metadata\MetadataRegistry;
use Kinetis\Orm\OrmFactory;
use Kinetis\Persistence\ConnectionDefinition;
use Kinetis\Persistence\SqlConnectionFactory;

// Once per process.
$db = SqlConnectionFactory::create(new ConnectionDefinition(
    dialect: 'pgsql',
    host: 'db.internal',
    database: 'shop',
    user: 'shop',
    password: $password,
));
$orm = OrmFactory::create($db, MetadataRegistry::fromClasses([Article::class]));

// Once per unit of work.
$entities = $orm->open();

try {
    $article = $entities->repository(Article::class)->findOrFail($id);
    $article->publish();
    $entities->flush();
} finally {
    $entities->close();
}

The host builds the client and the factory once and closes the client at shutdown. MetadataRegistry::toArray() and fromArray() let a build step export the metadata so a worker loads it without reflecting a directory.

See also

  • Query Builder — the builder every entity query composes, and what EntityQuery::builder() returns.

  • Persistence — the connection, pooling and TransactionGuard.

  • Caching & AOT Compilation — the AOT artifact the entity metadata is compiled into.