Relations describe how entities connect to each other.
That sounds simple, but relation bugs usually come from one of three misunderstandings:
- not knowing which side owns the write
- expecting related data to load automatically
- treating collection properties as if they create foreign keys by themselves
This guide focuses on the practical mental model that keeps those mistakes rare.
The most important rule: know the owner side
Use this as the quick reference:
| Relation type | Owner side | Where the stored key lives |
|---|---|---|
OneToOne |
the side with #[JoinColumn(...)] |
a foreign key on the owner table |
ManyToOne |
the ManyToOne property |
a foreign key on the many-side table |
OneToMany |
none on this property; the matching ManyToOne owns it |
nowhere on this property directly |
ManyToMany |
the side with #[JoinTable(...)] |
the join table |
If a relation write behaves strangely, ownership is the first thing to check.
One-to-one
Use one-to-one when each record on one side matches at most one record on the other side.
Example: a cinema has one profile, and a profile belongs to one cinema.
<?php
namespace Assegaiphp\CinemaHub\Cinemas\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: 'cinema_profiles', dataSource: 'cinema')]
class CinemaProfileEntity
{
#[PrimaryGeneratedColumn]
public ?int $id = null;
#[Column(type: ColumnType::TEXT, nullable: false)]
public string $description = '';
#[OneToOne(type: CinemaEntity::class)]
public ?CinemaEntity $cinema = null;
}
#[Entity(table: 'cinemas', dataSource: 'cinema')]
class CinemaEntity
{
#[PrimaryGeneratedColumn]
public ?int $id = null;
#[Column(type: ColumnType::VARCHAR, nullable: false)]
public string $name = '';
#[OneToOne(type: CinemaProfileEntity::class)]
#[JoinColumn(name: 'profile_id')]
public ?CinemaProfileEntity $profile = null;
}
How to read that:
CinemaEntity::$profileis the owner side- the
cinemastable stores the foreign key CinemaProfileEntity::$cinemais the inverse navigation back
SQL shape for the one-to-one example
A one-to-one relation still becomes ordinary SQL tables. The special part is that the owner table stores a foreign key that should point to only one row.
CREATE TABLE cinema_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
description TEXT NOT NULL
);
CREATE TABLE cinemas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
profile_id INTEGER NULL UNIQUE,
FOREIGN KEY (profile_id) REFERENCES cinema_profiles(id)
);
| Table | Important columns | What to notice |
|---|---|---|
cinema_profiles |
id, description |
no foreign key lives here in this example |
cinemas |
id, name, profile_id |
profile_id is the owner-side join column |
Because the join column is declared explicitly as #[JoinColumn(name: 'profile_id')], the SQL column uses that exact name.
Many-to-one and one-to-many
This is the most common relation pair.
Example: many showtimes belong to one cinema, and one cinema has many showtimes.
<?php
namespace Assegaiphp\CinemaHub\Showtimes\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: 'cinemas', dataSource: 'cinema')]
class CinemaEntity
{
#[PrimaryGeneratedColumn]
public ?int $id = null;
#[Column(type: ColumnType::VARCHAR, nullable: false)]
public string $name = '';
#[OneToMany(type: ShowtimeEntity::class, inverseSide: 'cinema')]
public array $showtimes = [];
}
#[Entity(table: 'showtimes', dataSource: 'cinema')]
class ShowtimeEntity
{
#[PrimaryGeneratedColumn]
public ?int $id = null;
#[Column(type: ColumnType::VARCHAR, nullable: false)]
public string $startsAt = '';
#[ManyToOne(type: CinemaEntity::class)]
public ?CinemaEntity $cinema = null;
}
What matters here:
- the foreign key lives on the
showtimestable ShowtimeEntity::$cinemais the owner sideCinemaEntity::$showtimesis the inverse collectionOneToManypoints at the target entity and, when needed, the target property that points back
If the target entity has exactly one ManyToOne back to the parent, Assegai can infer the inverse side:
#[OneToMany(type: ShowtimeEntity::class)]
public array $showtimes = [];
When the target has multiple ManyToOne relations to the same parent type, specify inverseSide so the ORM knows which owner relation to use:
#[OneToMany(type: ShowtimeEntity::class, inverseSide: 'cinema')]
public array $showtimes = [];
Do not pass the parent key, such as id, to OneToMany. The parent key comes from the owning side's JoinColumn metadata, or from the default primary key when no custom reference is declared.
If you are saving relation data, write through the owner side.
SQL shape for the many-to-one example
This pair is easier to understand if you read it from the table side first: one cinema row can be referenced by many showtime rows.
CREATE TABLE cinemas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL
);
CREATE TABLE showtimes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
starts_at VARCHAR(255) NOT NULL,
cinema_id INTEGER NULL,
FOREIGN KEY (cinema_id) REFERENCES cinemas(id)
);
| Table | Important columns | What to notice |
|---|---|---|
cinemas |
id, name |
the parent row lives here |
showtimes |
id, starts_at, cinema_id |
cinema_id is the stored foreign key |
The important part is that #[OneToMany(...)] does not create its own column and does not need a local reference-column argument. The actual stored key is the implicit join column on the ManyToOne side, which the ORM derives here as cinema_id.
Custom join columns and referenced columns
If the many-side table uses a custom foreign-key column, or if it references a parent column other than id, put that on the owning ManyToOne property with JoinColumn.
<?php
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\ManyToOne;
use Assegai\Orm\Attributes\Relations\OneToMany;
use Assegai\Orm\Queries\Sql\ColumnType;
#[Entity(table: 'studios', dataSource: 'cinema')]
class StudioEntity
{
#[PrimaryGeneratedColumn]
public ?int $id = null;
#[Column(type: ColumnType::VARCHAR, nullable: false, isUnique: true)]
public string $code = '';
#[OneToMany(type: ReleaseEntity::class)]
public array $releases = [];
}
#[Entity(table: 'releases', dataSource: 'cinema')]
class ReleaseEntity
{
#[PrimaryGeneratedColumn]
public ?int $id = null;
#[Column(type: ColumnType::VARCHAR, nullable: false)]
public string $title = '';
#[ManyToOne(type: StudioEntity::class)]
#[JoinColumn(name: 'studio_code', referencedColumnName: 'code')]
public ?StudioEntity $studio = null;
}
In that example, StudioEntity::$releases still does not name code. The ORM reads referencedColumnName: 'code' from ReleaseEntity::$studio, keeps the needed parent key internally while loading relations, and removes it from the final payload if it was excluded.
Many-to-many
Use many-to-many when both sides can have multiple related records.
Example: one movie can belong to many genres, and one genre can describe many movies.
<?php
namespace Assegaiphp\CinemaHub\Movies\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: 'genres', dataSource: 'cinema')]
class GenreEntity
{
#[PrimaryGeneratedColumn]
public ?int $id = null;
#[Column(type: ColumnType::VARCHAR, nullable: false)]
public string $name = '';
#[ManyToMany(type: MovieEntity::class, inverseSide: 'genres')]
public array $movies = [];
}
#[Entity(table: 'movies', dataSource: 'cinema')]
class MovieEntity
{
#[PrimaryGeneratedColumn]
public ?int $id = null;
#[Column(type: ColumnType::VARCHAR, nullable: false)]
public string $title = '';
#[ManyToMany(type: GenreEntity::class, inverseSide: 'movies')]
#[JoinTable(name: 'movies_genres', joinColumn: 'movie_id', inverseJoinColumn: 'genre_id')]
public array $genres = [];
}
In this case:
MovieEntity::$genresis the owner side because it has#[JoinTable(...)]- the join table stores the relationship rows
GenreEntity::$moviesis the inverse side
SQL shape for the many-to-many example
A many-to-many relation becomes three tables: the two entity tables you already expected, plus a join table whose only job is to connect their keys.
CREATE TABLE movies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title VARCHAR(255) NOT NULL
);
CREATE TABLE genres (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL
);
CREATE TABLE movies_genres (
movie_id INTEGER NOT NULL,
genre_id INTEGER NOT NULL,
PRIMARY KEY (movie_id, genre_id),
FOREIGN KEY (movie_id) REFERENCES movies(id),
FOREIGN KEY (genre_id) REFERENCES genres(id)
);
| Table | Important columns | What to notice |
|---|---|---|
movies |
id, title |
regular entity table |
genres |
id, name |
regular entity table |
movies_genres |
movie_id, genre_id |
join table declared by #[JoinTable(...)] |
That join table is the whole relation. Each row simply says, “this movie is linked to this genre.”
Relations are loaded explicitly
Do not assume related data appears by magic.
Ask for it in the query:
<?php
$cinema = $cinemas->findOne([
'where' => ['id' => 1],
'relations' => ['showtimes'],
])->getData();
$movie = $movies->findOne([
'where' => ['id' => 42],
'relations' => ['genres'],
])->getData();
That explicitness is a feature. It keeps data access easier to reason about and prevents accidental overfetching.
Writing through the owner side
Here is the practical pattern for a many-to-one relation:
<?php
$cinema = $cinemas->findOne([
'where' => ['id' => 1],
])->getFirst();
$showtime = $showtimes->create([
'startsAt' => '2026-04-12 19:30:00',
]);
$showtime->cinema = $cinema;
$saveResult = $showtimes->save($showtime);
Why this works:
- the write goes through
ShowtimeEntity::$cinema - that property is the owner side
- the owner side is the place that controls the stored foreign key
Practical advice
- Learn the owner side first.
- Load relations explicitly.
- Keep inverse collection properties for navigation, not for pretending they own the write.
- Persist through the side with
#[JoinColumn(...)]or#[JoinTable(...)].
Next steps
Once relations make sense, move on to Migrations so the schema evolves in step with the model.