hasOne, hasMany, belongsTo и has-through отношения
На этой странице 11
Самые частые типы связей в Eloquent - это «один к одному» (hasOne), «один ко многим» (hasMany) и обратная сторона обоих, belongsTo. На них держатся типовые схемы: пост и его комментарии, пользователь и его заказы, аккаунт и его профиль. Эта статья детально разбирает это семейство, плюс варианты has-one-of-many (когда из коллекции нужна одна «свежая» запись), has-through (доступ к удалённому родственнику через промежуточную модель), методы вставки save/create/make и каскадное обновление timestamps через #[Touches].
Базовые понятия (что такое Eloquent, как определяется метод-отношение, конвенции по ключам) разобраны в hub-статье про основы - тут идём дальше.
hasOne и hasMany: пара родитель-дети
hasOne и hasMany определяются одинаково по сигнатуре, отличаются только тем, что возвращают - один объект или коллекцию. Пример: телефон у пользователя (hasOne) и комментарии у поста (hasMany):
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasMany;
class User extends Model
{
public function phone(): HasOne
{
return $this->hasOne(Phone::class);
}
}
class Post extends Model
{
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
}
Foreign key у дочерней таблицы выводится из имени родителя: для Phone это user_id, для Comment - post_id. Когда конвенция не подходит, ключ передаётся вторым аргументом, а primary key родителя - третьим:
return $this->hasMany(Comment::class, 'author_id', 'uuid');
У hasMany есть полезный helper - chaperone(). По умолчанию при Post::with('comments')->get() родительский Post не подставляется в каждый Comment автоматически, и $comment->post внутри цикла либо создаст N+1 lazy-load, либо упадёт с LazyLoadingViolationException (если включён Model::preventLazyLoading()). chaperone() принудительно подвязывает уже загруженный родительский инстанс к каждому ребёнку:
class Post extends Model
{
public function comments(): HasMany
{
return $this->hasMany(Comment::class)->chaperone();
}
}
// Один SQL для постов, один для комментариев. $comment->post не дёргает БД.
$posts = Post::with('comments')->get();
foreach ($posts as $post) {
foreach ($post->comments as $comment) {
echo $comment->post->title;
}
}
Альтернатива - вызывать chaperone() на месте при eager-load, не зашивая поведение в определение отношения:
$posts = Post::with(['comments' => fn ($q) => $q->chaperone()])->get();
belongsTo (отношения «Принадлежит»): обратная навигация
belongsTo живёт на дочерней модели и возвращает одного родителя. В пару к Post::comments() ставится Comment::post():
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Comment extends Model
{
public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
}
}
Имя foreign key у belongsTo выводится из имени метода: post() ищет post_id на таблице ребёнка. Если метод называется author(), а класс родителя - User, Eloquent будет искать author_id (а не user_id):
class Comment extends Model
{
// Foreign key 'author_id' на comments
public function author(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
Если имя метода всё-таки user(), но столбец называется как-то иначе - ключ передаётся вторым аргументом:
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
Модели по умолчанию: withDefault() против null-падений
Самая частая боль с belongsTo: $comment->user вернёт null, если у комментария user_id пуст или связанная запись удалена. Дальнейшее $comment->user->name даёт Attempt to read property "name" on null и валит запрос.
withDefault() решает это через паттерн Null Object: вместо null возвращается пустой инстанс модели с дефолтными значениями. Доступен у belongsTo, hasOne, hasOneThrough и morphOne:
public function user(): BelongsTo
{
return $this->belongsTo(User::class)->withDefault();
}
Можно задать конкретные атрибуты массивом:
public function user(): BelongsTo
{
return $this->belongsTo(User::class)->withDefault([
'name' => 'Гость',
'avatar_url' => '/static/default-avatar.png',
]);
}
Или замыканием, если дефолтное значение зависит от контекста (например, от полей самого комментария):
public function user(): BelongsTo
{
return $this->belongsTo(User::class)->withDefault(function (User $user, Comment $comment) {
$user->name = 'Аноним #' . $comment->id;
});
}
В Blade и контроллерах $comment->user->name теперь безопасен - даже если связанный User удалён, отрисуется Гость или то значение, что выставлено в дефолте.
Дефолт срабатывает только при доступе к динамическому свойству $comment->user. При явном вызове $comment->user()->first() через метод-отношение путь идёт через query builder и возвращает null, минуя подстановку default-инстанса. Полагайтесь на динамическое свойство $comment->user, а не на цепочку с ->first().
Has-one-of-many: «Один из Многих» одним запросом
Регулярный кейс: у пользователя много заказов, но нужен «самый свежий» одним обращением. Можно написать $user->orders->sortByDesc('created_at')->first(), но это загрузит все заказы в память. Идиоматический способ - hasOne с latestOfMany():
use Illuminate\Database\Eloquent\Relations\HasOne;
class User extends Model
{
public function latestOrder(): HasOne
{
return $this->hasOne(Order::class)->latestOfMany();
}
public function oldestOrder(): HasOne
{
return $this->hasOne(Order::class)->oldestOfMany();
}
}
По умолчанию latestOfMany() сортирует по литералу 'id' - именно строка id, не динамический getKeyName() модели. Для типичных моделей это совпадает с PK, но при кастомном protected $primaryKey = 'order_uuid' вызов без аргумента упадёт на «column not found». Если PK нестандартный или сортировать нужно по другому столбцу (например, created_at или published_at) - имя передаётся параметром:
// Шорткат: latestOfMany($column) эквивалентен ofMany($column, 'max')
public function latestOrder(): HasOne
{
return $this->hasOne(Order::class)->latestOfMany('created_at');
}
// Более общая форма через ofMany - агрегат указывается явно (min или max)
public function cheapestOrder(): HasOne
{
return $this->hasOne(Order::class)->ofMany('total', 'min');
}
Удобный shortcut, если у модели уже есть базовое hasMany: можно вызвать ->one() поверх него и не дублировать класс:
class User extends Model
{
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
public function largestOrder(): HasOne
{
return $this->orders()->one()->ofMany('total', 'max');
}
}
Ограничение, важное на практике: на PostgreSQL функция MAX() не работает с UUID-столбцами, поэтому latestOfMany() / ofMany() для UUID-первичных ключей на Postgres недоступны - документация это явно отмечает.
Для более сложных правил ofMany() принимает массив сортировочных колонок и closure для дополнительных условий. Типичный случай - текущая цена товара с публикацией в будущем:
public function currentPricing(): HasOne
{
return $this->hasOne(Price::class)->ofMany(
['published_at' => 'max', 'id' => 'max'],
fn ($query) => $query->where('published_at', '<', now()),
);
}
Has-through: «Через отношение» к удалённой модели
Когда между двумя моделями нет прямого foreign key, но есть третья модель в середине, используются hasOneThrough (Has One Through) и hasManyThrough (Has Many Through). Классический пример - страна, пользователи и посты: у Country нет столбца posts.country_id, но Country имеет много User’ов, а каждый User имеет много Post’ов.
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
class Country extends Model
{
public function posts(): HasManyThrough
{
return $this->hasManyThrough(Post::class, User::class);
}
}
Первый аргумент - конечная модель, до которой добираемся. Второй - промежуточная. Eloquent ожидает users.country_id и posts.user_id по конвенции. Если ключи нестандартные, их передают позиционными аргументами - на этом месте часто запутываются, поэтому подсказка по порядку:
return $this->hasManyThrough(
Post::class,
User::class,
'country_id', // FK на промежуточной (users)
'user_id', // FK на конечной (posts)
'id', // local key на текущей (countries)
'id', // local key на промежуточной (users)
);
hasOneThrough (Has One Through) - тот же механизм, но возвращает один объект, не коллекцию. Пример из документации: у механика есть единственный обслуживаемый автомобиль, и у этого автомобиля единственный владелец - механик получает владельца через автомобиль:
class Mechanic extends Model
{
public function carOwner(): HasOneThrough
{
return $this->hasOneThrough(Owner::class, Car::class);
}
}
Если на промежуточных моделях уже определены обычные отношения (Country has many Users, User has many Posts), есть более компактный синтаксис через through():
class Country extends Model
{
public function users(): HasMany
{
return $this->hasMany(User::class);
}
public function posts(): HasManyThrough
{
return $this->through('users')->has('posts');
}
}
Это удобнее тем, что ключевые конвенции наследуются от уже определённых связей - не нужно дублировать имена столбцов в hasManyThrough().
Ограниченные отношения через withAttributes
Частый паттерн: на модели есть базовое отношение, и нужна его «суженная» версия. Например, User::posts() отдаёт все посты, а User::featuredPosts() - только те, у которых featured = true. Прямое решение - дополнительный метод-отношение с where:
class User extends Model
{
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
public function featuredPosts(): HasMany
{
return $this->posts()->where('featured', true);
}
}
Запрос работает, но есть подвох: $user->featuredPosts()->create(['title' => 'Тест']) создаст пост без featured = true - метод where() влияет только на чтение, не на запись. Чтобы создаваемые записи тоже получали атрибут, используется withAttributes:
public function featuredPosts(): HasMany
{
return $this->posts()->withAttributes(['featured' => true]);
}
Теперь withAttributes(['featured' => true]) и фильтрует, и подставляет значение при create. Если подстановка при создании нужна, а фильтрация по этому атрибуту - нет, через named-argument asConditions: false отключают where-условия: атрибут продолжает подставляться при create, но в where уже не попадает.
Метод save, saveMany, push: вставка готового инстанса
В Eloquent существует несколько способов положить ребёнка в БД (Вставка и Обновление Связанных Моделей), и разница между ними - в том, где и когда выставляется foreign key и проходит ли заполнение через mass-assignment.
save($comment) требует уже созданного инстанса. Метод вызывается на relationship-инстансе родителя, поэтому foreign key (post_id) Laravel проставляет автоматически:
$comment = new Comment(['message' => 'Текст комментария']);
$post->comments()->save($comment);
// post_id заполнен, запись в БД
Для пакетной вставки - saveMany([...]) с массивом готовых инстансов.
Нюанс на практике: save() и saveMany() сохраняют записи в БД, но не обновляют уже загруженную in-memory коллекцию у родителя. Если до $post->comments()->save($comment) уже было обращение к $post->comments (Collection закэширована на инстансе), новый комментарий в этой коллекции не появится. Чтобы перечитать данные, нужно $post->refresh() или $post->load('comments') после сохранения.
Метод create, createMany, make: вставка из массива атрибутов
create([...]) принимает массив атрибутов, создаёт инстанс через mass-assignment и сохраняет. Атрибуты должны быть в $fillable на модели:
$comment = $post->comments()->create([
'message' => 'Текст комментария',
]);
Если $fillable пуст и $guarded тоже не настроен на разрешение - create() бросит MassAssignmentException. То же касается createMany([...]). Quiet-варианты (createQuietly, createManyQuietly) делают то же, но не дёргают model events - полезно для сидеров или миграций данных.
make([...]) - редко документируемый, но иногда полезный: создаёт инстанс с foreign key и без сохранения. Дальше с ним можно повозиться (выставить дополнительные поля, провалидировать) и вручную save():
$comment = $post->comments()->make(['message' => 'Черновик']);
$comment->ip = request()->ip();
$comment->save();
push() - рекурсивно сохраняет модель и все её уже загруженные отношения за один вызов:
$post->title = 'Обновлённый заголовок';
$post->comments[0]->message = 'Изменено';
$post->push();
// Сохранит и Post, и Comment в одной операции
pushQuietly() - аналог без events.
associate, dissociate: обновление отношений Один К
Для belongsTo есть пара методов на смену родителя без ручного выставления foreign key:
$user->account()->associate($account);
$user->save();
associate() записывает foreign key (account_id) на инстанс и не вызывает save - после него нужно явно сохранить модель. Это сделано специально: позволяет сначала перепривязать несколько отношений, потом одним save() все коммитнуть.
dissociate() обнуляет foreign key:
$user->account()->dissociate();
$user->save(); // account_id теперь NULL
Если на колонке account_id стоит NOT NULL, dissociate() + save() упадёт на уровне БД. Это контролируется схемой, а не Eloquent.
Затрагивание временных меток родителя через #[Touches]
Когда обновляется комментарий, нужно «протолкнуть» updated_at у родительского поста - типичный паттерн для инвалидации HTTP-кеша по Last-Modified или сортировки списка постов по «последней активности». В 13.x документация показывает атрибут #[Touches] на дочерней модели:
use Illuminate\Database\Eloquent\Attributes\Touches;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Touches(['post'])]
class Comment extends Model
{
public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
}
}
Аргумент - массив имён relationship-методов на текущей модели (не имён колонок). Когда $comment->save() отрабатывает, Laravel вызывает touch() на связанной модели через метод post(), что обновляет posts.updated_at.
Документация явно оговаривает: timestamps родителя обновляются только если ребёнок сохраняется через Eloquent’овский save(). Это значит, что $comment->save(), $comment->update([...]) (внутри он вызывает save), $post->comments()->create([...]) (тоже через save) триггерят touch родителя. А вот массовые операции через query builder (Comment::where(...)->update([...])) - нет: bulk-update идёт мимо событий модели.
Legacy-форма, оставшаяся со времён до атрибутов - свойство $touches:
class Comment extends Model
{
protected $touches = ['post'];
public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
}
}
В 13.x документация показывает только атрибут, свойство всё ещё работает, но в новом коде лучше брать атрибутную форму. Дублировать оба способа на одной модели смысла нет.
Куда двигаться дальше
- Many-to-many (pivot, attach/sync) - другая ветка отношений с собственными гайдами. См. Многие-ко-многим и pivot-таблицы.
- Polymorphic - когда одна и та же дочерняя таблица ссылается на разные родительские. См. Полиморфные отношения.
- Querying -
whereHas,withWhereHas,whereRelation,whereBelongsToдля фильтрации по существованию связей. См. Запросы к отношениям. - Eager loading - стратегии борьбы с N+1:
with,load,loadMissing,preventLazyLoading. См. Eager loading и N+1.
Была ли статья полезна?
Спасибо за отзыв!