Relations are usually the first ORM topic that feels confusing.
This guide focuses on the practical questions that matter most: where the foreign key lives, which side owns the write, and when related data appears on an entity.
The mental model is intentionally close to TypeORM:
- owner sides store the actual foreign key or join table metadata
- inverse sides describe how to navigate the graph
- relations are loaded explicitly
- collection relations are not magic arrays from nowhere; they appear because the query asked for them
The current ORM test coverage verifies:
- loading
OneToOne,ManyToOne,OneToMany, andManyToManyrelations - owner-side join-column writes for relation objects
That is the behavior this guide leans on.
The most important rule: know the owner side
Use this as the quick reference:
| Relation type | Owner side | Stored key |
|---|---|---|
OneToOne |
the side with #[JoinColumn(...)] |
a foreign key column on the owner table |
ManyToOne |
the ManyToOne property |
a foreign key column on the many-side table |
OneToMany |
inverse side only | no foreign key lives on this property |
ManyToMany |
the side with #[JoinTable(...)] |
rows in the join table |
When writes feel surprising, ownership is usually the first thing to check.
One-to-one
Think of User and Profile: one user has one profile, and one profile belongs to one user.
<?php
namespace Assegaiphp\BlogApi\Users\Entities;
use Assegai\Orm\Attributes\Columns\Column;
use Assegai\Orm\Attributes\Columns\PrimaryGeneratedColumn;
use Assegai\Orm\Attributes\Entity;
use Assegai\Orm\Attributes\Relations\JoinColumn;
use Assegai\Orm\Attributes\Relations\OneToOne;
use Assegai\Orm\Queries\Sql\ColumnType;
#[Entity(table: 'profiles', database: 'blog')]
class ProfileEntity
{
#[PrimaryGeneratedColumn]
public ?int $id = null;
#[Column(type: ColumnType::TEXT, nullable: false)]
public string $bio = '';
#[OneToOne(type: UserEntity::class)]
public ?UserEntity $user = null;
}
#[Entity(table: 'users', database: 'blog')]
class UserEntity
{
#[PrimaryGeneratedColumn]
public ?int $id = null;
#[Column(type: ColumnType::VARCHAR, nullable: false)]
public string $name = '';
#[OneToOne(type: ProfileEntity::class)]
#[JoinColumn(name: 'profileId')]
public ?ProfileEntity $profile = null;
}
In that model:
UserEntity::$profileis the owner sideusers.profileIdstores the keyProfileEntity::$useris the inverse navigation back to the user
Load it explicitly:
<?php
$user = $usersRepository->findOne([
'where' => ['id' => 1],
'relations' => ['profile'],
])->getData();
$profile = $profilesRepository->findOne([
'where' => ['id' => 1],
'relations' => ['user'],
])->getData();
Many-to-one and one-to-many
This is the classic Author and Post relationship:
- many posts belong to one author
- one author has many posts
<?php
namespace Assegaiphp\BlogApi\Posts\Entities;
use Assegai\Orm\Attributes\Columns\Column;
use Assegai\Orm\Attributes\Columns\PrimaryGeneratedColumn;
use Assegai\Orm\Attributes\Entity;
use Assegai\Orm\Attributes\Relations\ManyToOne;
use Assegai\Orm\Attributes\Relations\OneToMany;
use Assegai\Orm\Queries\Sql\ColumnType;
#[Entity(table: 'authors', database: 'blog')]
class AuthorEntity
{
#[PrimaryGeneratedColumn]
public ?int $id = null;
#[Column(type: ColumnType::VARCHAR, nullable: false)]
public string $name = '';
#[OneToMany(type: PostEntity::class, referencedProperty: 'id', inverseSide: 'author')]
public array $posts = [];
}
#[Entity(table: 'posts', database: 'blog')]
class PostEntity
{
#[PrimaryGeneratedColumn]
public ?int $id = null;
#[Column(type: ColumnType::VARCHAR, nullable: false)]
public string $title = '';
#[ManyToOne(type: AuthorEntity::class)]
public ?AuthorEntity $author = null;
}
In practice:
- the foreign key lives on the
poststable PostEntity::$authoris the write-oriented owner sideAuthorEntity::$postsis the read-oriented inverse collection
Load either direction explicitly:
<?php
$post = $postsRepository->findOne([
'where' => ['id' => 1],
'relations' => ['author'],
])->getData();
$author = $authorsRepository->findOne([
'where' => ['id' => 1],
'relations' => ['posts'],
])->getData();
Many-to-many
Use this when both sides can have multiple related records, like Post and Tag.
<?php
namespace Assegaiphp\BlogApi\Posts\Entities;
use Assegai\Orm\Attributes\Columns\Column;
use Assegai\Orm\Attributes\Columns\PrimaryGeneratedColumn;
use Assegai\Orm\Attributes\Entity;
use Assegai\Orm\Attributes\Relations\JoinTable;
use Assegai\Orm\Attributes\Relations\ManyToMany;
use Assegai\Orm\Queries\Sql\ColumnType;
#[Entity(table: 'tags', database: 'blog')]
class TagEntity
{
#[PrimaryGeneratedColumn]
public ?int $id = null;
#[Column(type: ColumnType::VARCHAR, nullable: false)]
public string $label = '';
#[ManyToMany(type: PostEntity::class, inverseSide: 'tags')]
public array $posts = [];
}
#[Entity(table: 'posts', database: 'blog')]
class PostEntity
{
#[PrimaryGeneratedColumn]
public ?int $id = null;
#[Column(type: ColumnType::VARCHAR, nullable: false)]
public string $title = '';
#[ManyToMany(type: TagEntity::class, inverseSide: 'posts')]
#[JoinTable(name: 'posts_tags', joinColumn: 'post_id', inverseJoinColumn: 'tag_id')]
public array $tags = [];
}
In that model:
PostEntity::$tagsis the owner side because it has#[JoinTable(...)]- the join table stores the relationship rows
TagEntity::$postsis the inverse side
Load either side with the relations option:
<?php
$post = $postsRepository->findOne([
'where' => ['id' => 2],
'relations' => ['tags'],
])->getData();
$tag = $tagsRepository->findOne([
'where' => ['id' => 2],
'relations' => ['posts'],
])->getData();
How relation loading works
Relations are not loaded unless you ask for them.
The most common forms are:
<?php
['relations' => ['author', 'tags']]
or:
<?php
['relations' => ['author' => true, 'tags' => true]]
Use that on find(), findOne(), and the entity-manager level equivalents when you need related data.
Writing relation objects through the owner side
When you want to persist an owner-side relation object, use save() with relation options enabled.
Example: create a post and point it at an existing author.
<?php
use Assegai\Orm\Management\Options\InsertOptions;
$author = $authorsRepository->findOne([
'where' => ['id' => 1],
])->getData();
$post = $postsRepository->create([
'title' => 'Inserted with author',
]);
$post->author = $author;
$result = $postsRepository->save(
$post,
new InsertOptions(relations: ['author'])
);
The key idea is that the owner-side property is what the ORM can translate into the stored key.
For updates, the same pattern applies with UpdateOptions:
<?php
use Assegai\Orm\Management\Options\UpdateOptions;
$post = $postsRepository->findOne([
'where' => ['id' => 1],
])->getData();
$post->author = $anotherAuthor;
$postsRepository->save(
$post,
new UpdateOptions(relations: ['author'])
);
Relation options
Relation attributes accept a RelationOptions object for advanced behavior such as:
cascadeisNullableonDeleteonUpdateisEagerisPersistentorphanedRowAction
Those options are there for cases where your schema and lifecycle rules need more control, but the most important first step is still correct ownership and explicit loading.
Common pitfalls
- Putting
#[JoinColumn]on both sides of a one-to-one. Pick one owner side. - Expecting
OneToManyto store a key. The actual key lives on theManyToOneside. - Forgetting
#[JoinTable]on the owner side of a many-to-many. - Reading a relation property without loading it in the query.
- Trying to write a relation from the inverse side and expecting the foreign key to move automatically.
Relation techniques that work well
- Use singular names for related object properties like
$post->authorand plural names for collection properties like$author->posts. - Keep the owning side obvious in the entity definition, even when the inverse side feels more convenient to read from.
- Load only the relations the request actually needs.
- Keep relation writes in the service layer so the controller stays about HTTP, not graph persistence.
Next step
Once the model is in place, move on to ORM Migrations and Database Workflows.