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\OrmFactoryis bound onAppScope, one per worker. It is built on first use from the link bound underMysqlLinkorPostgresLink— an application’s own binding of it inbootstrap.phpincluded — and the compiled metadata.Kinetis\Orm\EntityManageris bound on everyRequestScope: 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 itsclose()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.EntityManageris never anAppScopeservice. Its constructor is not public, soAppScoperefuses 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.