Eloquent Relationships | Laravel 13.x на русском - hasMany, belongsTo, foreign-key конвенции и #[Touches]
На этой странице 7
Eloquent - это ORM, входящая в Laravel: вы описываете модель PHP-классом, фреймворк сам строит SQL для CRUD-операций и сохраняет/читает результат. Реляционная база делит сущности на разные таблицы, и почти каждый запрос из приложения - попытка их обратно соединить. Eloquent заменяет ручные JOIN-ы методами на модели: вместо select * from comments where post_id = ? вы пишете $post->comments.
Это базовая статья по работе с отношениями в Laravel (примеры тестировались на 11, 12 и 13 ветках, версии 12 и 13 ничего ломающего в API отношений не вносят): что такое отношение, как его определить, какие соглашения применяет фреймворк, чем $user->posts отличается от $user->posts(), и как организовать каскадное обновление updated_at через атрибут #[Touches]. Глубокие сценарии - pivot-таблицы, полиморфизм, eager loading, фильтрация по существованию - вынесены в отдельные статьи, на которые этот текст служит точкой входа.
Минимальный пример: hasMany и belongsTo
Возьмём блог. У пользователя много постов, пост принадлежит одному пользователю. На уровне таблиц это users(id, ...) и posts(id, user_id, ...). На уровне Eloquent определяем два метода:
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class User extends Model
{
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
}
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Post extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
После этого работают оба направления навигации:
$user = User::find(1);
foreach ($user->posts as $post) {
echo $post->title;
}
$post = Post::find(42);
echo $post->user->name;
Eloquent никогда не создаёт обратное отношение автоматически. Если на Post не определить user(), обращение $post->user обычно вернёт null: магический __get модели проходит через getAttribute(), проверяет колонки/мутаторы, потом пытается найти relation-метод через isRelation() (method_exists), не находит и возвращает null. У этого поведения два исключения. Первое: если в таблице posts есть колонка user или модель Post определяет accessor с этим именем, getAttribute() вернёт значение колонки/аксессора, а не null. Второе: если в AppServiceProvider::boot() включён Model::preventAccessingMissingAttributes() (strict mode для отсутствующих атрибутов) или полный Model::shouldBeStrict() (его Laravel сам предлагает ограничивать non-production, потому что внутри включается preventLazyLoading), бросится Illuminate\Database\Eloquent\MissingAttributeException. Ошибка BadMethodCallException возникает в третьем случае - при вызове $post->user()->where(...), когда PHP пытается вызвать несуществующий метод-отношение.
Терминология: parent (родитель) определяет коллекцию через hasMany/hasOne, child (ребёнок) определяет владельца через belongsTo. Это два независимых метода на двух моделях; пропустишь один - пропадёт навигация в соответствующем направлении.
Для полиморфных отношений принцип немного другой: на child используется morphTo() без явного указания parent-модели - она определяется в рантайме через колонку *_type. Это покрыто в статье по полиморфным отношениям; здесь достаточно помнить: в обычной (non-polymorphic) связке всегда два метода, в полиморфной - morphTo на child и morphMany/morphOne на каждом возможном parent.
Возвращаемый тип в сигнатуре relation-метода - не косметика. PHPStan, Psalm и IDE используют его, чтобы понимать, какие методы будут доступны при дальнейшем чейнинге. У каждого класса отношения свой набор: BelongsTo открывает withDefault() и associate()/dissociate(); HasOne - withDefault(), latestOfMany()/oldestOfMany()/ofMany(); HasMany - saveMany()/createMany() и one()-конвертацию в HasOne; BelongsToMany - attach/detach/sync/withPivot. Без явного типа подсветка идёт для базовой Relation-сигнатуры, и более специфичные методы помечаются как unknown. Для нового кода в Laravel 11+ явный возвратный тип - де-факто стандарт.
Откуда Eloquent берёт foreign key
При hasMany(Post::class) фреймворк делает два вывода: имя текущей модели - User, значит ищется колонка user_id в таблице posts. Для belongsTo(User::class) правило другое: имя relationship-метода (user) суффиксируется через имя primary-колонки родителя (id), получаем user_id. Правила разные, и в нестандартных случаях легко спутать.
Если на Post метод называется author(), а не user(), дефолт сломается: Eloquent будет ждать колонку author_id. Если в таблице она называется user_id, ключ нужно указать явно:
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
Позиционные аргументы belongsTo: первый - класс родителя, второй - foreign-колонка на дочерней модели, третий - primary-колонка на родительской. Опциональный четвёртый аргумент - имя relation-метода (нужен в редких случаях вроде динамически создаваемых отношений; обычно auto-detected из имени вызывающего метода). Если первичный ключ users - uuid, а не id:
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_uuid', 'uuid');
}
Для hasMany симметрично: второй аргумент - foreign key на дочерней таблице, третий - local key на родительской:
public function posts(): HasMany
{
return $this->hasMany(Post::class, 'author_id', 'id');
}
Те же параметры используются и в SQL-запросах, и при создании моделей через relationship-метод. $user->posts()->create([...]) подставит правильное значение в foreign key без вашего участия - даже если имена колонок нестандартные. Это второй аргумент в пользу определять отношения корректно: код вставки получается чище, чем при ручном Post::create(['user_id' => $user->id, ...]).
Метод против динамического свойства
Одно из самых частых заблуждений: $post->comments и $post->comments() - это разные вещи, и от выбора зависит производительность.
$post->comments (без скобок) - dynamic property. При первом обращении Eloquent выполняет SQL-запрос, кэширует результат на инстанс модели и возвращает Illuminate\Database\Eloquent\Collection. Повторное обращение не идёт в БД:
$post = Post::find(1);
$post->comments; // SELECT * FROM comments WHERE post_id = 1
$post->comments; // та же коллекция из памяти, запрос не повторяется
$post->comments->count();
$post->comments() (со скобками) - вызов метода, возвращающего query builder. Каждый вызов даёт новый билдер, никакого кэша:
$post = Post::find(1);
$post->comments()->where('approved', true)->get();
$post->comments()->where('approved', false)->count();
Практические выводы:
- Нужны все связанные модели и потом фильтрация в PHP -
$post->comments->where(...). Это фильтр по уже загруженной коллекции (Illuminate\Support\Collection::where()), не SQL. - Фильтрация должна идти в SQL -
$post->comments()->where(...)->get().whereподставится в WHERE-клаузу. - Нужна агрегация и коллекция ещё НЕ загружена -
$post->comments()->count()выполнит SQLSELECT COUNT(*)без вытаскивания строк. Если же коллекция уже в памяти (через eagerwith('comments')или предыдущее обращение к свойству), наоборот предпочтительно$post->comments->count()- это count в памяти без лишнего запроса;$post->comments()->count()в этой ситуации сгенерирует ненужный дополнительный SELECT.
То же различие применимо ко всем типам отношений: hasOne, hasMany, belongsTo, belongsToMany, morphMany и остальным. Это общий принцип работы Eloquent с relationship, не особенность одного метода.
В шаблонах Blade проблема усугубляется: цикл @foreach ($posts as $post) {{ $post->comments->count() }} @endforeach без предварительного with('comments') даст классическую N+1, по одному запросу на каждый пост. Лечится через Post::with('comments')->get() или через withCount('comments'), который добавит атрибут comments_count подзапросом в основном SELECT без загрузки самих комментариев.
Создание связанных моделей через отношение
Запись с автоматической подстановкой foreign key - одна из главных причин определять отношение, а не работать с моделью напрямую:
$user = User::find(1);
$post = $user->posts()->create([
'title' => 'Новый пост',
'body' => '...',
]);
user_id будет проставлен автоматически в значение $user->getKey(). То же работает для save:
$post = new Post(['title' => '...', 'body' => '...']);
$user->posts()->save($post);
Разница между save и create не только в типе аргумента. save принимает уже созданный инстанс модели; если вы инстанцировали через new Post([...]) или fill([...]), поля массива прошли через mass-assignment-фильтр ($fillable на модели), просто этот шаг отделён от самого save. Прямое присваивание через сеттеры ($post->title = '...') фильтр обходит. create всегда принимает массив атрибутов и всегда идёт через mass assignment - каждое поле должно быть в $fillable Post-модели (или модель полностью разгуарденная через protected $guarded = []/Model::unguard()), иначе Illuminate\Database\Eloquent\MassAssignmentException. Для массивов есть варианты saveMany([...]) и createMany([...]) - оба персистят несколько моделей одним вызовом, но каждая всё равно идёт отдельным INSERT’ом.
Если объект уже загружен в коллекцию $user->posts и вам нужно увидеть в ней свежее добавление, после save/create нужно или дёрнуть $user->refresh() (перезагружает атрибуты модели и уже загруженные отношения), или присвоить вручную. Уточнение, важное для тестов: $user->posts->count() показывает старое значение только если коллекция $user->posts уже была разрешена до create() (через setUp, фикстуры, ранее в коде). Если же первое обращение к свойству идёт после create(), оно триггерит свежий SELECT и видит новую модель.
Каскадное обновление updated_at через #[Touches]
Типовой сценарий: у поста есть комментарии. Когда добавляется или редактируется комментарий, родительский Post полезно отметить как «затронутый» - для last-modified заголовка, сортировки по последней активности, отображения «обновлено N минут назад» в админке.
В Laravel 13.x для этого используется PHP-атрибут #[Touches] на дочерней модели (атрибут как класс появился в линейке 12.x; на 11.x используйте классическое свойство protected $touches = ['post'] - семантика та же):
namespace App\Models;
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);
}
}
После этого $comment->save() выполнит дополнительный запрос вида UPDATE posts SET updated_at = ? WHERE id = ? - при условии, что у parent-модели (Post) включены timestamps (public $timestamps = true, default) и getUpdatedAtColumn() возвращает не-null значение. Поведение touch() при выключенных timestamps зависит от способа выключения: если у parent $timestamps = false, метод сразу возвращает false и UPDATE не уходит; если const UPDATED_AT = null, touch() доходит до save(), но ни одна колонка не помечается dirty - save() возвращает true, никакого SQL также не отправляется. С точки зрения базы результат одинаков (запрос не уходит), а возвращаемое значение разное, поэтому закладываться на if (! $parent->touch()) для детекции «выключенных timestamps» не стоит. Кастомное имя колонки через const UPDATED_AT = 'modified_at' работает нормально. Значение 'post' в атрибуте - имя relationship-метода на Comment, не имя колонки.
Документация явно гарантирует touch parent именно для save()-операций ребёнка. На практике в текущих исходниках Laravel $comment->delete() на инстансе тоже триггерит touchOwners() через жизненный цикл модели, но это поведение не зафиксировано как публичный контракт - для production-логики, где удаление должно вызывать инвалидацию, надёжнее завести явный observer на Comment::deleted. Bulk-операции через query builder (Comment::query()->where(...)->update(...), Comment::query()->where(...)->delete()) обходят модельные события целиком и parent никогда не тронут - для них touch нужен вручную.
Для production cache invalidation - сценария «комментарий изменился - сбросить кеш страницы поста» - одного #[Touches] обычно недостаточно. Cache-key от updated_at родителя зависит от точности timestamp-колонки (MySQL TIMESTAMP/DATETIME без явной precision хранит до секунды, два save’а в одну секунду оставят значение байт-в-байт одинаковым; Postgres TIMESTAMP по умолчанию хранит микросекунды и эту проблему обходит), видимости записи внутри транзакции, replica lag в master-replica топологии, и согласованности с in-memory коллекциями. Надёжнее использовать #[Touches] для своей основной цели - метки last-modified - а инвалидацию кеша делать явно через Observer или event listener на Comment::saved/Comment::deleted, где есть полный контроль над тем, какой ключ и когда сбрасывается.
Чейнинг и query builder поверх отношения
Метод-отношение возвращает построитель запроса, и на него применимы все стандартные методы query builder’а:
$recent = $user->posts()
->where('published_at', '>=', now()->subDays(7))
->orderBy('published_at', 'desc')
->limit(10)
->get();
В обратную сторону действует whereBelongsTo - синтаксический сахар для тривиальных запросов «дай посты этого пользователя». Когда метод-отношение на Post назван по правилу Laravel - Str::camel(class_basename($parent)) от класса родителя - достаточно одного аргумента:
$posts = Post::whereBelongsTo($user)->get();
Для класса User Str::camel('User') даёт user, поэтому метод должен называться user(). Для класса BlogPost это будет blogPost() (camelCase, не blog_post). Вызов эквивалентен Post::where('user_id', $user->id)->get(), но foreign-колонка и primary-колонка определяются через relationship-метод user(), который вы уже описали. Eloquent ищет именно по camelCase-имени класса передаваемой модели, он не инспектирует все методы child-модели на тип BelongsTo.
Если на child-модели метод-отношение назван иначе (например, author() вместо user()), его нужно указать вторым аргументом - иначе будет RelationNotFoundException:
$posts = Post::whereBelongsTo($user, 'author')->get();
При комбинации where и orWhere после relationship-метода есть тонкость: orWhere группируется на одном уровне с основным условием отношения:
$user->posts()
->where('active', true)
->orWhere('votes', '>=', 100)
->get();
Этот код сгенерирует SQL where user_id = ? and active = 1 or votes >= 100, что вернёт и посты других пользователей с количеством голосов от 100. Чтобы изолировать условие отношения, используйте группировку:
$user->posts()
->where(function ($query) {
$query->where('active', true)
->orWhere('votes', '>=', 100);
})
->get();
Без скобок логика «ИЛИ» вырывается за пределы конкретного пользователя - частая ловушка, особенно при копировании условий из существующего scope.
Куда двигаться дальше
После того как определены базовые hasOne/hasMany/belongsTo, естественные следующие темы:
- запросы по существованию связи (
has,whereHas,doesntHave,whereRelation) для фильтрации parent-моделей по children; - eager loading через
with()и борьба с N+1 при выводе списков, включаяwithCount,loadMissingиpreventLazyLoadingвboot-методе AppServiceProvider; - pivot-таблицы для many-to-many через
belongsToMany,attach/sync/syncWithoutDetachingи кастомные модели pivot черезusing(); - полиморфные отношения для случаев, когда один child может принадлежать разным parent-моделям (
morphTo,morphMany,morphedByMany) иRelation::enforceMorphMapдля безопасности рефакторинга namespace; - has-one-of-many для извлечения одного связанного объекта по критерию (
latestOfMany,oldestOfMany,ofMany('price', 'max')).
Отдельно стоит упомянуть Model::automaticallyEagerLoadRelationships() - попытку фреймворка автоматически eager-load’ить отношения при первом обращении. На момент Laravel 13.x фича помечена в документации как beta, поведение может меняться даже на патч-релизах, для production-кода её лучше рассматривать как экспериментальную.
Базовое правило - два метода на двух моделях с правильным типом возврата - даёт читаемый код и работающую IDE-навигацию. Eloquent устроен так, что простой случай выглядит просто: определили метод, обратились по имени. Дальнейшая сложность подключается там, где нужна, и каждая ступенька (eager load, pivot, polymorphic, scoped relations) описана в своих статьях.
Была ли статья полезна?
Спасибо за отзыв!