انتقل إلى المحتوى الرئيسي

العلاقات

Weaver ORM defines all relation metadata inside the entity's mapper class via a RelationMap. There are no attributes on entity properties and no runtime reflection. Relations are always loaded explicitly — Weaver never issues surprise queries behind your back.

Overview

Owning vs inverse side

Every relation has one owning side and one inverse side.

  • The owning side holds the foreign key in its table (or in the pivot table for many-to-many). It controls persistence of the association.
  • The inverse side is declared with mappedBy pointing to the owning side. Changes made only to the inverse side are not written to the database.

Foreign key placement rules

Relation typeFK locationMapper method
One-to-oneOn the "other" entity's tablehasOne
One-to-manyOn the "many" entity's tablehasMany
Many-to-oneOn this entity's tablebelongsTo
Many-to-manyDedicated pivot tablebelongsToMany
Polymorphic one-to-oneOn the morphable entity's tablemorphOne
Polymorphic one-to-manyOn the morphable entity's tablemorphMany

How relations are declared

Relations are registered inside the mapper's relations(RelationMap $map) method:

protected function relations(RelationMap $map): void
{
$map->hasOne('profile', Profile::class)
->foreignKey('user_id')
->localKey('id');

$map->hasMany('posts', Post::class)
->foreignKey('user_id')
->localKey('id')
->orderBy('created_at', 'DESC');
}

HasOne

HasOne represents a one-to-one relationship where the foreign key lives on the other entity's table. A User has one Profile; the profiles table carries user_id.

// src/Entity/User.php
final class User
{
public function __construct(
public readonly int $id,
public readonly string $email,
public readonly string $name,
public ?Profile $profile = null,
) {}
}
// src/Entity/Profile.php
final class Profile
{
public function __construct(
public readonly int $id,
public readonly int $userId,
public readonly string $bio,
public ?User $user = null,
) {}
}

Mapper (owning side is ProfileMapper; UserMapper holds the inverse):

// src/Mapper/UserMapper.php
protected function relations(RelationMap $map): void
{
$map->hasOne('profile', Profile::class)
->foreignKey('user_id') // column on profiles table
->localKey('id') // column on users table (PK)
->mappedBy('user'); // inverse property name on Profile
}
// src/Mapper/ProfileMapper.php
protected function relations(RelationMap $map): void
{
$map->belongsTo('user', User::class)
->foreignKey('user_id')
->ownerKey('id');
}

Eager loading:

// One extra IN query — never N+1
$user = $repository->findById(1, with: ['profile']);
echo $user->profile?->bio;

// Batch-load profiles for many users (single IN query)
$users = $repository->findAll(with: ['profile']);

Cascade persist:

$user    = new User(id: 0, email: 'alice@example.com', name: 'Alice');
$profile = new Profile(id: 0, userId: 0, bio: 'Software engineer');

$em->persist($user, cascade: [CascadeType::Persist]);
$em->flush();
// Inserts users row first, then profiles row with correct user_id

HasMany

HasMany represents a one-to-many relationship where the foreign key lives on the many side. A User has many Posts; the posts table carries user_id.

// src/Entity/User.php
use Weaver\ORM\Collection\EntityCollection;

final class User
{
public function __construct(
public readonly int $id,
public readonly string $email,
public readonly string $name,
/** @var EntityCollection<Post> */
public EntityCollection $posts = new EntityCollection(),
) {}
}
// src/Mapper/UserMapper.php
protected function relations(RelationMap $map): void
{
$map->hasMany('posts', Post::class)
->foreignKey('user_id') // column on posts table
->localKey('id') // column on users table
->orderBy('created_at', 'DESC') // default ordering
->orphanRemoval(true); // delete posts removed from the collection
}

Working with the collection:

// Eager load
$user = $repository->findById(1, with: ['posts']);

// Add
$user->posts->add(new Post(...));
$em->flush(); // INSERT

// Remove (with orphanRemoval: DELETE is issued automatically)
$user->posts->remove($postToDelete);
$em->flush();

// Filter in-memory
$published = $user->posts->filter(fn(Post $p) => $p->published);

// Count without loading
$count = $repository->countRelation($user, 'posts');

Index collection by a field:

$map->hasMany('posts', Post::class)
->foreignKey('user_id')
->indexBy('id'); // EntityCollection keyed by post.id

$post = $user->posts->get(42);

BelongsTo

BelongsTo represents a many-to-one relationship where the foreign key lives on this entity's table. A Post belongs to a User; the posts table carries user_id.

// src/Mapper/PostMapper.php
protected function relations(RelationMap $map): void
{
$map->belongsTo('author', User::class)
->foreignKey('user_id') // column on posts table (this entity)
->ownerKey('id'); // PK on users table
}

Optional (nullable) FK — guest comments without an owner:

$map->belongsTo('author', User::class)
->foreignKey('user_id')
->ownerKey('id')
->nullable(true);

Eager loading:

$posts = $postRepository->findAll(with: ['author']);

foreach ($posts as $post) {
echo "{$post->author->name}: {$post->title}";
}

BelongsToMany

BelongsToMany represents a many-to-many relationship backed by a pivot (junction) table. A Post can have many Tags; the post_tag table holds both foreign keys.

// src/Mapper/PostMapper.php
protected function relations(RelationMap $map): void
{
$map->belongsToMany('tags', Tag::class)
->pivotTable('post_tag') // junction table name
->foreignPivotKey('post_id') // FK pointing to this entity
->relatedPivotKey('tag_id') // FK pointing to related entity
->withPivot('role', 'joined_at') // extra pivot columns to load
->withPivotTimestamps() // adds created_at / updated_at on pivot
->orderByPivot('joined_at', 'ASC');
}

Accessing pivot data:

$post = $postRepository->findById(1, with: ['tags']);

foreach ($post->tags as $tag) {
$pivot = $tag->pivot();
echo $tag->name . ' — role: ' . $pivot->get('role');
}

Managing the pivot table:

// Attach one tag with pivot data
$em->relation($post, 'tags')->attach(tagId: 5, pivot: ['role' => 'primary']);

// Attach multiple
$em->relation($post, 'tags')->attach([
5 => ['role' => 'primary'],
8 => ['role' => 'secondary'],
]);

// Detach one
$em->relation($post, 'tags')->detach(tagId: 5);

// Detach all
$em->relation($post, 'tags')->detach();

// Sync: replace entire pivot set (detach removed, attach added)
$em->relation($post, 'tags')->sync([3, 7, 11]);

// Sync with pivot data
$em->relation($post, 'tags')->sync([
3 => ['role' => 'primary'],
7 => ['role' => 'secondary'],
]);

// Add only, never remove
$em->relation($post, 'tags')->syncWithoutDetaching([15, 16]);

// Toggle: attach if absent, detach if present
$em->relation($post, 'tags')->toggle(tagId: 5);

MorphOne / MorphMany

Polymorphic relations allow a single relation to target more than one entity type. Two columns on the "morph" table identify the parent:

  • {name}_type — stores the entity class (or a configured alias)
  • {name}_id — stores the primary key
images
──────────────────────
id
imageable_type ← 'App\Entity\Post' | 'App\Entity\User'
imageable_id ← FK into whichever table
url

Mapper — owning side (Post):

// src/Mapper/PostMapper.php
protected function relations(RelationMap $map): void
{
// Post has one cover image
$map->morphOne('coverImage', Image::class)
->morphName('imageable') // resolves to imageable_type + imageable_id
->localKey('id');

// Post has many images
$map->morphMany('images', Image::class)
->morphName('imageable')
->localKey('id');
}

Mapper — morphable side (Image):

// src/Mapper/ImageMapper.php
protected function relations(RelationMap $map): void
{
$map->morphTo('imageable')
->morphName('imageable')
->morphMap([
'post' => Post::class, // alias → class mapping
'user' => User::class,
]);
}

Querying:

$posts = $postRepository->findAll(with: ['coverImage', 'images']);

$images = $imageRepository->findWhere([
'imageable_type' => Post::class,
'imageable_id' => $post->id,
]);

HasOneThrough

HasOneThrough traverses two tables to resolve a single related entity. A User has one Carrier through their Phone.

users       phones           carriers
────── ────────────── ──────────
id id id
name user_id (FK) name
carrier_id (FK)
// src/Mapper/UserMapper.php
protected function relations(RelationMap $map): void
{
$map->hasOneThrough(
relation: 'carrier',
related: Carrier::class,
through: Phone::class,
firstKey: 'user_id', // FK on phones pointing to users
secondKey: 'carrier_id', // FK on phones pointing to carriers
localKey: 'id', // PK on users
throughKey: 'id', // PK on carriers
);
}
$user = $userRepository->findById(1, with: ['carrier']);
echo $user->carrier?->name; // 'Verizon'

Generated SQL uses a single JOIN:

SELECT carriers.*
FROM carriers
INNER JOIN phones ON phones.carrier_id = carriers.id
WHERE phones.user_id IN (1, 2, 3)

HasManyThrough

HasManyThrough provides access to a distant collection via an intermediate entity. A Country has many Posts through its Users.

// src/Mapper/CountryMapper.php
protected function relations(RelationMap $map): void
{
$map->hasManyThrough(
relation: 'posts',
related: Post::class,
through: User::class,
firstKey: 'country_id', // FK on users pointing to countries
secondKey: 'user_id', // FK on posts pointing to users
localKey: 'id', // PK on countries
throughKey: 'id', // PK on users
);
}
$country = $countryRepository->findById(1, with: ['posts']);

// With constraint: only published posts
$country = $countryRepository->findById(1, with: [
'posts' => fn($q) => $q->where('published', true)->orderBy('created_at', 'DESC'),
]);

Eager loading

Basic eager loading

Pass relation names to the with: parameter of any repository method:

$user  = $repository->findById(1, with: ['profile', 'posts']);
$users = $repository->findAll(with: ['profile']);

Weaver uses separate queries with IN clauses — never JOINs for collections — to avoid row multiplication.

Dot-notation for nested relations

// Load users → posts → comments → comment authors
// Exactly 4 queries total, regardless of the number of users
$users = $userRepository->findAll(
with: ['posts.comments.author'],
);

Constrained eager loading

Pass a closure to filter or sort a relation at load time:

$users = $userRepository->findAll(with: [
'posts' => fn(RelationQuery $q) =>
$q->where('published', true)
->orderBy('created_at', 'DESC'),
]);

Nested constraints:

$users = $userRepository->findAll(with: [
'posts' => fn(RelationQuery $q) => $q
->where('published', true)
->with([
'comments' => fn(RelationQuery $cq) => $cq
->where('approved', true)
->orderBy('created_at', 'ASC')
->limit(5),
]),
]);

Limit per parent entity

// Load at most 3 posts per user (uses LATERAL JOIN on supported engines)
$users = $userRepository->findAll(with: [
'posts' => fn(RelationQuery $q) =>
$q->orderBy('created_at', 'DESC')
->limitPerGroup(3),
]);

Relation aggregates (without loading)

Attach aggregate values to entities without fetching the full relation:

// Add posts_count virtual property
$users = $userRepository->findAll(withCount: ['posts']);

foreach ($users as $user) {
echo "{$user->name} has {$user->postsCount} posts";
}
// Multiple aggregates in one call
$users = $userRepository->findAll(
withCount: ['posts'],
withSum: [['orders', 'total']],
withMax: [['orders', 'total']],
withAvg: [['orders', 'total']],
);

Constrained aggregate:

$users = $userRepository->findAll(
withCount: [
'publishedPosts' => fn($q) => $q->where('published', true),
'draftPosts' => fn($q) => $q->where('published', false),
],
);
echo $user->publishedPostsCount;
echo $user->draftPostsCount;

Existence queries

// Users with at least one post
$users = $userRepository->query()->has('posts')->get();

// Users with no posts
$users = $userRepository->query()->doesntHave('posts')->get();

// Users with more than 5 posts
$users = $userRepository->query()->has('posts', '>=', 5)->get();

// Users with posts that have at least one published comment
$users = $userRepository->query()
->whereHas('posts', fn($q) => $q->whereHas('comments', fn($cq) =>
$cq->where('approved', true)
))
->get();

Cascade options

OptionEffect
CascadeType::PersistPersist related entities when the owning side is persisted
CascadeType::RemoveDelete related entities when the owning side is deleted
->orphanRemoval(true)Delete HasMany members removed from the collection
$em->persist($user, cascade: [CascadeType::Persist]);
$em->flush();
warning

Cascades must be explicitly opted into. Weaver never cascades silently.


Self-referencing relations

Entities that reference their own table (categories, menus, org charts):

// src/Mapper/CategoryMapper.php
protected function relations(RelationMap $map): void
{
$map->hasMany('children', Category::class)
->foreignKey('parent_id')
->localKey('id')
->orderBy('name', 'ASC')
->orphanRemoval(true);

$map->belongsTo('parent', Category::class)
->foreignKey('parent_id')
->ownerKey('id')
->nullable(true);
}

Recursive eager loading (bounded depth):

// Load three levels deep: children → grandchildren → great-grandchildren
$roots = $categoryRepository->findWhere(
criteria: ['parent_id' => null],
with: ['children' => fn($q) => $q->withRecursive(depth: 3)],
);

// Alternative dot-notation syntax
$roots = $categoryRepository->findWhere(
criteria: ['parent_id' => null],
with: ['children.children.children'],
);