Documentation menu
On this page
Packages · v1.x
ORM
The ORM package (src/packages/Orm) is a data mapper built on the Database component: entities are plain PHP classes mapped with attributes, and the ORM (OrmInterface, the entity manager) tracks them and writes the changes on flush().
It ships repositories, query builders, lifecycle events, code generators and migrations, and works with MySQL / MariaDB, PostgreSQL and SQLite.
Configuration
config/packages/orm.yaml (every key is optional, these are the defaults):
connection: ~
entity:
path: src/Entity
namespace: App\Entity
repository:
path: src/Repository
namespace: App\Repository
migration:
path: migrations
namespace: Migrations
table: neo_migrations
proxy:
path: '%kernel.cache_path%/orm/proxies'
ignore_tables: []
| Key | Description |
|---|---|
connection |
database connection used by the ORM (database.yaml); the default connection when empty |
entity |
directory and namespace of the entities, used by make:entity and make:migration |
repository |
directory and namespace of the repositories, used by make:repository and to find the repository of an entity |
migration |
directory, namespace and table of the migrations |
proxy |
directory of the generated proxy classes (cleared by cache:clear) |
ignore_tables |
tables ignored by make:migration (never created nor dropped) |
Entities
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Enum\PostStatus;
use App\Repository\PostRepository;
use DateTimeImmutable;
use NeoPHP\Package\Orm\Collection\ArrayCollection;
use NeoPHP\Package\Orm\Contract\CollectionInterface;
use NeoPHP\Package\Orm\Mapping as ORM;
#[ORM\Entity(repository: PostRepository::class)]
#[ORM\Index(columns: ['status', 'publishedAt'])]
class Post
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 120)]
private ?string $title = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $content = null;
#[ORM\Column]
private PostStatus $status = PostStatus::Draft;
#[ORM\Column(nullable: true)]
private ?DateTimeImmutable $publishedAt = null;
#[ORM\ManyToOne(Category::class, inversedBy: 'posts', nullable: false)]
private ?Category $category = null;
#[ORM\ManyToMany(Tag::class, inversedBy: 'posts')]
private CollectionInterface $tags;
#[ORM\OneToMany(Comment::class, mappedBy: 'post', cascade: ['persist', 'remove'], orphanRemoval: true, orderBy: ['createdAt' => 'DESC'])]
private CollectionInterface $comments;
public function __construct()
{
$this->tags = new ArrayCollection();
$this->comments = new ArrayCollection();
}
}
The table is the class name in snake_case (BlogPost → blog_post) and each column is the property name in snake_case (publishedAt → published_at). #[ORM\Entity(table: 'users')] and #[ORM\Column(name: '...')] change them.
| Attribute | Options |
|---|---|
#[ORM\Entity] |
table, repository |
#[ORM\Id] |
the identifier (one per entity) |
#[ORM\GeneratedValue] |
strategy: 'auto' (auto increment, default), 'uuid' (UUID v7 generated on persist()), 'none' (set by the application) |
#[ORM\Column] |
name, type, length (255), nullable (false), unique, default, precision / scale (decimal), enumType |
#[ORM\Index] |
columns (properties or columns), name, unique; repeatable, on the class |
The type is deduced from the property type when it is not given:
| Type | PHP type | Deduced from |
|---|---|---|
string |
string |
string |
text |
string |
|
integer, smallint, bigint |
int |
int |
float |
float |
float |
decimal |
string (formatted with the scale) |
|
boolean |
bool |
bool |
datetime, date, time |
DateTime |
DateTime, DateTimeInterface |
datetime_immutable, date_immutable, time_immutable |
DateTimeImmutable |
DateTimeImmutable |
json |
array |
array |
guid |
string |
|
| backed enum | the enum | a BackedEnum (stored as its value) |
Relations
| Attribute | Owning side | Options |
|---|---|---|
#[ORM\ManyToOne(Category::class)] |
always (column category_id) |
inversedBy, joinColumn, nullable (true), onDelete (CASCADE, SET NULL), cascade, fetch (lazy, eager) |
#[ORM\OneToMany(Comment::class, mappedBy: 'post')] |
never: mapped by the ManyToOne of the target |
cascade, orphanRemoval, orderBy |
#[ORM\OneToOne(Profile::class)] |
without mappedBy (unique column profile_id) |
mappedBy, inversedBy, joinColumn, nullable, onDelete, cascade, orphanRemoval |
#[ORM\ManyToMany(Tag::class)] |
without mappedBy (join table post_tag) |
mappedBy, inversedBy, joinTable, joinColumn, inverseJoinColumn, cascade, orderBy |
- Only the owning side is written to the database: update it (the
add...()/set...()methods generated bymake:entitydo it). cascade: ['persist']persists the new related entities,cascade: ['remove']removes them with the entity,'all'does both. Withoutpersistcascade, a new entity found through a relation throws an exception onflush().orphanRemoval: trueremoves an entity removed from the collection (OneToMany) or replaced (OneToOne).- Collections (
OneToMany,ManyToMany) are typedCollectionInterface: anArrayCollectionfor a new entity, a lazyPersistentCollectionloaded on first use for an entity read from the database. ManyToOneandOneToOnerelations are loaded lazily with a proxy (a generated subclass invar/cache/orm/proxies) that loads the entity on its first method call;getId()does not load it. Afinalclass, or a class with__get(), is loaded immediately instead. An entity is unique per request: once a proxy exists for an id,find()and the queries return that same (initialized) proxy, so compare classes withinstanceof, not with$entity::class.
Collections
NeoPHP\Package\Orm\Contract\CollectionInterface (implemented by NeoPHP\Package\Orm\Collection\ArrayCollection and PersistentCollection) is Countable, IteratorAggregate and ArrayAccess:
| Method | Description |
|---|---|
add(mixed $element): static |
appends an element |
set(string|int $key, mixed $value): static / get(string|int $key): mixed |
writes / reads a key |
remove(string|int $key): mixed / removeElement(mixed $element): bool |
removes by key / by value |
contains(mixed $element): bool / containsKey(string|int $key): bool / indexOf(mixed $element): string|int|false |
searches |
getKeys(), getValues(), toArray(), first(), last(), isEmpty(), clear() |
access |
filter(callable $callback), map(callable $callback) |
a new collection |
exists(callable $callback): bool, slice(int $offset, ?int $length = null): array |
queries |
PersistentCollection also exposes getOwner(), getAssociation(), isInitialized(), initialize(), isDirty() and unwrap() (the inner ArrayCollection).
Persisting
$orm = $this->getOrm();
$post = (new Post())->setTitle('Hello')->setCategory($category);
$post->addTag($tag);
$orm->persist($post);
$orm->flush();
$post->setTitle('Hello world');
$orm->flush();
$orm->remove($post);
$orm->flush();
flush() computes the changes of every managed entity and writes them in one transaction: inserts (in the order of the relations), updates of the changed columns only, join tables, deletes.
| Method | Description |
|---|---|
persist($entity) |
manages a new entity (inserted on flush()) |
remove($entity) |
schedules the deletion |
flush() |
writes the changes |
find(Post::class, $id) |
the entity, or null |
getReference(Post::class, $id) |
a proxy, without query |
getRepository(Post::class) |
the repository of the entity |
createQueryBuilder() / createSqlQueryBuilder() |
the query builders |
refresh($entity), detach($entity), clear(), contains($entity) |
unit of work |
transactional(fn (OrmInterface $orm) => ...) |
runs the callback and flushes in a transaction |
The same entity is returned for the same row (identity map). In a controller, getOrm() and getRepository(Post::class) are available; elsewhere, inject NeoPHP\Package\Orm\Contract\OrmInterface.
The OrmInterface also gives access to the lower layers: getConnection(), getPlatform(), getMetadata($class), getMetadataFactory(), getUnitOfWork(), getProxyFactory() and getEventDispatcher().
In a controller
The OrmController trait of AbstractController adds (see the Controllers documentation):
| Method | Returns |
|---|---|
getOrm(): OrmInterface |
the ORM |
getRepository(string $entityClass): RepositoryInterface |
the repository of the entity |
Repositories
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Post;
use App\Enum\PostStatus;
use NeoPHP\Package\Orm\Contract\AbstractRepository;
class PostRepository extends AbstractRepository
{
protected string $entityClass = Post::class;
public function findLatestPublished(int $limit = 10): array
{
return $this->createQueryBuilder('p')
->leftJoin('p.category', 'c')->addSelect('c')
->where('p.status = :status')->setParameter('status', PostStatus::Published)
->orderBy('p.publishedAt', 'DESC')
->setMaxResults($limit)
->getResult();
}
}
Repositories are services: inject them in controllers and services (public function index(PostRepository $posts)). An entity without repository gets a generic EntityRepository.
| Method | Returns |
|---|---|
find($id) |
an entity or null |
findAll($orderBy) |
all the entities |
findBy(['category' => $category, 'status' => [PostStatus::Draft, PostStatus::Published]], ['title' => 'ASC'], $limit, $offset) |
the matching entities; an array becomes IN, null becomes IS NULL |
findOneBy($criteria, $orderBy) |
the first matching entity or null |
count($criteria) |
the number of matching entities |
createQueryBuilder('p') |
a query builder selecting the entity |
save($entity, $flush = false) / delete($entity, $flush = false) |
persist() / remove(), then flush() if asked |
getEntityClass(), getOrm(), getMetadata() |
the entity class, the ORM, its metadata |
Query builder
The entity query builder uses properties (p.publishedAt) and relations (p.category); it translates them to columns and joins:
$posts = $orm->createQueryBuilder()
->select('p', 'c')
->from(Post::class, 'p')
->leftJoin('p.category', 'c')
->join('p.tags', 't')
->where('t.name IN (:tags)')
->andWhere('p.category = :category')
->setParameter('tags', ['php', 'orm'])
->setParameter('category', $category)
->orderBy('p.title')
->setMaxResults(20)
->getResult();
| Method | Description |
|---|---|
select() / addSelect() |
an alias selects entities (a joined alias loads the relation in the same query and sets the real entities, not proxies), anything else is a scalar expression (COUNT(p.id) AS total) |
distinct() |
SELECT DISTINCT |
from(Post::class, 'p') |
the root entity (getRootEntity(), getRootAlias()) |
join() / innerJoin() / leftJoin() |
a relation ('p.category'), or an entity with a condition (Category::class, 'c', 'c.id = p.category'); an extra condition is added with AND |
where() / andWhere() / orWhere(), groupBy() / addGroupBy(), having() / andHaving(), orderBy() / addOrderBy() |
clauses; p.category is the foreign key column |
setParameter() / setParameters() / getParameters() |
named parameters; an entity becomes its id, an array is expanded for IN |
setMaxResults() / setFirstResult() |
limit and offset |
getResult() |
the entities (or rows [entity, scalar...] when scalars are selected too) |
getOneOrNullResult() / getSingleResult() |
one entity (NonUniqueResultException, NoResultException) |
getSingleScalarResult(), getScalarResult(), getArrayResult() |
a value, raw rows, entities as arrays |
getSQL(), getSqlQueryBuilder() |
the generated SQL, the underlying SQL query builder |
createSqlQueryBuilder() returns a SQL query builder working on tables and columns, usable without entities:
$rows = $orm->createSqlQueryBuilder()
->select('c.name', 'COUNT(p.id) AS total')
->from('category', 'c')
->leftJoin('post', 'p', 'p.category_id = c.id')
->groupBy('c.name')
->fetchAllAssociative();
$orm->createSqlQueryBuilder()->update('post')->set('views', 'views + 1')->where('id = :id')->setParameter('id', 1)->executeStatement();
| Method | Description |
|---|---|
select() / addSelect(), distinct(), from($table, $alias) |
select queries |
insert($table) + values([...]) / setValue() |
insert queries |
update($table, $alias) + set($column, $expression) |
update queries |
delete($table, $alias) |
delete queries |
join() / innerJoin() / leftJoin() / rightJoin() |
($table, $alias, $condition) |
where() / andWhere() / orWhere(), groupBy() / addGroupBy(), having() / andHaving() / orHaving(), orderBy() / addOrderBy() |
clauses |
setMaxResults() / getMaxResults(), setFirstResult() / getFirstResult() |
limit and offset |
setParameter() / setParameters() / getParameter() / getParameters() |
parameters |
executeQuery() (a Result), executeStatement() (affected rows) |
execution |
fetchAllAssociative(), fetchAssociative(), fetchOne(), fetchFirstColumn() |
fetch helpers |
getSQL() / __toString(), getType(), getConnection() |
inspection |
Lifecycle callbacks and events
#[ORM\PrePersist]
public function onPrePersist(): void
{
$this->createdAt = new DateTimeImmutable();
}
#[ORM\PreUpdate]
public function onPreUpdate(PreUpdateEvent $event): void
{
if ($event->hasChangedField('title')) {
$this->updatedAt = new DateTimeImmutable();
}
}
| Callback attribute | Event (NeoPHP\Package\Orm\Event\) |
When |
|---|---|---|
#[ORM\PrePersist] |
PrePersistEvent |
on persist() |
#[ORM\PostPersist] |
PostPersistEvent |
after the insert |
#[ORM\PreUpdate] |
PreUpdateEvent (getChangeSet(), hasChangedField(), getOldValue(), getNewValue()) |
before the update; the changes made in the callback are saved |
#[ORM\PostUpdate] |
PostUpdateEvent |
after the update |
#[ORM\PreRemove] |
PreRemoveEvent |
on remove() |
#[ORM\PostRemove] |
PostRemoveEvent |
after the delete |
#[ORM\PostLoad] |
PostLoadEvent |
after the entity is loaded |
PreFlushEvent, PostFlushEvent |
around flush() |
Every event extends LifecycleEvent (getEntity(), getOrm()), except PreFlushEvent and PostFlushEvent which extend FlushEvent (getOrm()). The events are dispatched with the event dispatcher: a listener receives them like any other event (see the Events documentation).
<?php
declare(strict_types=1);
namespace App\Listener;
use NeoPHP\Component\Event\Attribute\AsListener;
use NeoPHP\Package\Orm\Event\PrePersistEvent;
class TimestampListener
{
#[AsListener]
public function onPrePersist(PrePersistEvent $event): void
{
$entity = $event->getEntity();
if (method_exists($entity, 'setCreatedAt')) {
$entity->setCreatedAt(new \DateTimeImmutable());
}
}
}
Forms
The entity form type (NeoPHP\Package\Orm\Helper\Form\EntityType, a ChoiceType) lists entities as choices (see the Forms documentation):
$builder->add('category', EntityType::class, [
'class' => Category::class,
'query_builder' => fn (CategoryRepository $repository): QueryBuilder => $repository->createQueryBuilder('c')->orderBy('c.name'),
]);
| Option | Description |
|---|---|
class |
the entity class (required) |
query_builder |
a QueryBuilder, or a callable receiving the repository and returning one; findAll() when empty |
choices |
an explicit array of entities |
Generating code
make:entity without fields starts a wizard. It creates the entity, or completes it when it already exists (the new properties and methods are added to its class, the rest of the file is kept):
$ php bin/neo make:entity
Class name of the entity to create or update (e.g. BlogPost) (? to list):
> Comment
New property name (press <return> to stop adding fields):
> content
Field type (enter ? to see all types) [text]:
>
Can this field be null in the database (nullable) (yes/no) [no]:
>
New property name (press <return> to stop adding fields):
> post
Field type (enter ? to see all types) [ManyToOne]:
>
What class should this entity be related to? [Post] (? to list):
>
Is the Comment.post property allowed to be null (nullable) (yes/no) [yes]:
> no
Do you want to add a new property to Post so that you can access/update Comment objects from it - e.g. $post->getComments() (yes/no) [yes]:
>
New field name inside Post [comments]:
>
Do you want to delete the orphaned Comment objects (orphanRemoval)? ... (yes/no) [no]:
> yes
New property name (press <return> to stop adding fields):
>
- Types:
?lists them (string,text,integer,decimal,boolean, dates,json,uuid,enum,relationand the four relation types). The default type is guessed from the name:email→string180 unique,slug→ unique,createdAt→datetime_immutable,publishedAt→ nullable datetime,isActive/published→boolean,price→decimal,description→text,position→integer,roles→json, and a name matching an entity (category→Category,tags→Tag) → a relation. - Questions per type: length for
string, precision and scale fordecimal, the enum (fromsrc/Enum) forenum, unique for strings and integers, and nullable. - Relations:
relationexplains the four types with the real class names. The inverse side is proposed and written in the target entity (Post::$commentswithgetComments(),addComment()andremoveComment(), which keep both sides in sync).OneToManyalways adds theManyToOneside in the target. Self-references (Category::$children/$parent) are supported. - Checks: an existing property or method, or a name used twice, is refused before anything is written; a refused field is not added and the wizard goes on.
With fields on the command line, nothing is asked (useful in scripts), and the fields are added to the entity if it exists:
php bin/neo make:entity Category name:string:100 posts:OneToMany:Post:category
php bin/neo make:entity Post title:string:120 content:text? status:enum:App\\Enum\\PostStatus publishedAt:datetime_immutable? category:ManyToOne:Category tags:ManyToMany:Tag
php bin/neo make:repository Post
A field is name:type; a trailing ? makes it nullable. Types: string[:length], text, integer, smallint, bigint, float, decimal[:precision[:scale]], boolean, datetime, datetime_immutable, date, date_immutable, time, json, guid, enum:Class, and the relations ManyToOne:Target, OneToOne:Target, OneToMany:Target[:mappedBy], ManyToMany:Target. On the command line, the inverse side is not generated. --force regenerates an existing entity from scratch (its repository is kept). An entity in a sub-namespace (Blog/Post) gets its repository in the same sub-namespace (App\Repository\Blog\PostRepository).
Migrations
php bin/neo make:migration --description="Blog schema"
php bin/neo migration:migrate
php bin/neo migration:status
php bin/neo migration:rollback
make:migration compares the entities to the database (tables, columns, indexes, foreign keys) and writes migrations/Migration_{hash}.php with the SQL of the database in use. The hash starts with the creation time, so migrations run in the order they were generated. The optional description is asked only when there are changes (press Enter to skip), unless --description is given.
<?php
declare(strict_types=1);
namespace Migrations;
use NeoPHP\Package\Orm\Contract\AbstractMigration;
class Migration_01a0d70c0b0beeb3 extends AbstractMigration
{
public function getDescription(): string
{
return 'Blog schema';
}
public function up(): void
{
$this->abortIf($this->getPlatformName() !== 'mysql', 'This migration was generated for mysql.');
$this->addSql('CREATE TABLE `category` (...)');
}
public function down(): void
{
$this->abortIf($this->getPlatformName() !== 'mysql', 'This migration was generated for mysql.');
$this->addSql('DROP TABLE `category`');
}
}
make:migrationrefuses to run while migrations are pending, and does nothing when the database is in sync.--emptycreates an empty migration to write by hand.- The executed migrations are stored in the
neo_migrationstable. Each migration runs in a transaction on PostgreSQL and SQLite (MySQL commits DDL statements immediately). - On SQLite, a changed table is rebuilt (new table, copy of the data, rename), with the foreign keys disabled during the migration.
- The generated SQL can be edited before
migration:migrate;$this->connectionis available for data migrations.
AbstractMigration (implements MigrationInterface):
| Method | Description |
|---|---|
up(): void / down(): void |
apply / revert the migration |
getDescription(): string |
the description shown by migration:status |
getVersion(): string |
the version (the class name without Migration_) |
addSql(string $sql, array $params = []) |
adds a statement (protected) |
abortIf(bool $condition, string $message) |
stops the migration (protected) |
getPlatformName(): string |
mysql, pgsql or sqlite |
isTransactional(): bool |
whether it runs in a transaction |
getSql() / clearSql() |
the collected statements |
Commands
| Command | Arguments and options |
|---|---|
make:entity |
name (e.g. Post, Blog/Post), fields...; global --force regenerates the entity |
make:repository |
entity |
make:migration |
--empty, --description/-d |
migration:migrate (alias migrate) |
--dry-run shows the SQL without executing it |
migration:rollback (alias rollback) |
--steps/-s (1), --dry-run |
migration:status |
lists the migrations and whether they are executed |
Exceptions
All in NeoPHP\Package\Orm\Exception\, extending OrmException (itself a FrameworkException):
| Exception | Thrown when |
|---|---|
MappingException |
an entity is badly mapped |
EntityNotFoundException |
a proxy cannot load its entity |
NoResultException |
getSingleResult() finds nothing |
NonUniqueResultException |
getOneOrNullResult() / getSingleResult() find several entities |
MigrationException |
a migration fails or is aborted |
Limits
Composite identifiers, inheritance mapping, readonly properties and changes of the primary key are not supported. The ORM should be cleared (clear()) after a failed flush().
Profiler
The unit of work keeps cheap counters, read with $orm->getUnitOfWork()->getStatistics(): managed entities per class (identity map), pending inserts and removals, flushes (inserts, updates, deletes, collection updates and duration of the last 100 flushes) and initialized proxies.
When the Web Profiler is enabled, the Helper/Profiler/OrmProfiler element adds an ORM item to the toolbar (number of managed entities, shown only when the ORM was used) and a panel with the managed entities per class, the flushes and the unit of work state. SQL queries are shown by the Database panel.
Changelog
- v1.25.1 — profiler integration:
UnitOfWork::getStatistics()(managed entities, flushes, initialized proxies) and ORM panel of the Web Profiler. - v1.17.0 —
make:entitywizard (fields asked one by one, guessed types, relations with their inverse side written in the target entity, completion of existing entities, checks before writing), sub-namespace repositories;make:*commands ask for their values. Bugfix:make:migrationasks the optional description only when there are changes. - v1.15.0 — ORM commands rewritten as
AbstractConsolecommands. - v1.12.0 —
entityform type. - v1.11.0 — ORM package: entities mapped with attributes,
ManyToOne/OneToMany/OneToOne/ManyToManyrelations with lazy loading (generated proxies, lazy collections), cascade and orphan removal, unit of work with identity map and change tracking, repositories, entity and SQL query builders, lifecycle callbacks and events, enum / JSON / date / decimal types,make:entity,make:repository,make:migration,migration:migrate,migration:rollbackandmigration:statuscommands,config/packages/orm.yaml.