wppw.org
enrues
Laravel 13.x · Документация

hasOne, hasMany, belongsTo и has-through отношения

Обновлено 25 мая 2026 г. Laravel 13.x 11 мин чтения
На этой странице 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 документация показывает только атрибут, свойство всё ещё работает, но в новом коде лучше брать атрибутную форму. Дублировать оба способа на одной модели смысла нет.

Куда двигаться дальше

Была ли статья полезна?

Сообщить об ошибке