Eloquent — это ORM (Object-Relational Mapping) в Laravel. Каждая таблица в базе данных представлена PHP-классом (моделью). Вместо написания SQL ты работаешь с объектами: создаёшь, читаешь, обновляешь, удаляешь — а Eloquent сам генерирует SQL под капотом.
Table of Contents
1. Модель и соглашения
Создаётся командой php artisan make:model Product. С миграцией: php artisan make:model Product -m.
// app/Models/Product.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
//
}
Пустой класс — и он уже работает. Eloquent применяет соглашения (conventions) по умолчанию:
Модель → Таблица → Первичный ключ
───────────────────────────────────────────────────────────
Product → products → id
User → users → id
OrderItem → order_items → id
Category → categories → id
Правила: имя модели в единственном числе, PascalCase. Таблица — во множественном, snake_case. Laravel делает это автоматически, включая неправильные формы (Person → people, Child → children).
Переопределение соглашений
Если таблица или ключ называются нестандартно:
class Product extends Model
{
protected $table = 'shop_products'; // нестандартная таблица
protected $primaryKey = 'product_id'; // нестандартный ключ
public $incrementing = false; // ключ не автоинкрементный
protected $keyType = 'string'; // ключ — строка (UUID)
public $timestamps = false; // таблица без created_at/updated_at
protected $connection = 'mysql_secondary'; // другое подключение к БД
}
2. CRUD-операции
Create — создание
// Вариант 1: create() — одной строкой
$product = Product::create([
'name' => 'iPhone 16',
'price' => 999.00,
'stock' => 50,
]);
// Вариант 2: new + save() — в два шага
$product = new Product();
$product->name = 'iPhone 16';
$product->price = 999.00;
$product->save();
// Вариант 3: firstOrCreate — найти или создать
$product = Product::firstOrCreate(
['sku' => 'IPHONE-16'], // ищем по этим полям
['name' => 'iPhone 16', 'price' => 999.00] // если не нашли — создаём с этими
);
// Вариант 4: updateOrCreate — обновить или создать (upsert)
$product = Product::updateOrCreate(
['sku' => 'IPHONE-16'],
['price' => 899.00, 'stock' => 100]
);
Read — чтение
// Одна запись
$product = Product::find(1); // по ID, вернёт null если нет
$product = Product::findOrFail(1); // по ID, бросит 404 если нет
$product = Product::where('sku', 'IPHONE-16')->first(); // первая по условию
$product = Product::where('sku', 'IPHONE-16')->firstOrFail(); // или 404
// Коллекция записей
$products = Product::all(); // все
$products = Product::where('price', '>', 500)->get(); // с условием
$products = Product::where('stock', '>', 0)
->orderBy('price', 'asc')
->limit(10)
->get();
// Отдельные значения
$count = Product::where('stock', 0)->count();
$maxPrice = Product::max('price');
$avgPrice = Product::where('category_id', 3)->avg('price');
$exists = Product::where('sku', 'IPHONE-16')->exists(); // true/false
// Pluck — достать одну колонку
$names = Product::pluck('name'); // Collection ['iPhone 16', 'MacBook', ...]
$priceMap = Product::pluck('price', 'name'); // ['iPhone 16' => 999, 'MacBook' => 1999]
// Chunk — обработка большого количества записей порциями
Product::where('stock', 0)->chunk(200, function ($products) {
foreach ($products as $product) {
$product->update(['status' => 'out_of_stock']);
}
});
Update — обновление
// Одна запись
$product = Product::find(1);
$product->update(['price' => 899.00]);
// Или по отдельности
$product->price = 899.00;
$product->save();
// Массовое обновление (без загрузки моделей в память)
Product::where('category_id', 5)->update(['on_sale' => true]);
Важно: массовое обновление через Product::where(...)->update() не загружает модели, поэтому события модели (creating, updating и т.д.) НЕ срабатывают. Если нужны события — обновляй каждую модель отдельно.
Delete — удаление
// Одна запись
$product = Product::find(1);
$product->delete();
// По ID
Product::destroy(1);
Product::destroy([1, 2, 3]);
// Массовое удаление
Product::where('stock', 0)->where('updated_at', '<', now()->subYear())->delete();
3. Mass Assignment — fillable и guarded
Когда ты используешь create() или update() с массивом, Eloquent проверяет, какие поля разрешено заполнять массово. Это защита от того, чтобы пользователь не подсунул лишнее поле (например, is_admin = true) через запрос.
// Вариант 1: $fillable — белый список (разрешённые поля)
class Product extends Model
{
protected $fillable = [
'name',
'price',
'stock',
'category_id',
];
}
// Вариант 2: $guarded — чёрный список (запрещённые поля)
class Product extends Model
{
protected $guarded = ['id']; // всё кроме id можно заполнять массово
}
// Разрешить вообще всё (опасно в продакшене)
protected $guarded = [];
Частая ошибка: забыть добавить поле в $fillable. Eloquent молча проигнорирует его — запись создастся, но поле будет null. Никакой ошибки не будет, и ты будешь долго искать, почему данные не сохраняются.
4. Связи (Relationships)
Связи — это методы модели, которые описывают, как таблицы связаны друг с другом. Eloquent берёт на себя JOIN-ы, подзапросы и подгрузку данных.
Все типы связей
Связь Пример из жизни FK
─────────────────────────────────────────────────────────────────────
hasOne User → Phone phones.user_id
hasMany User → Orders orders.user_id
belongsTo Order → User orders.user_id
belongsToMany User ↔ Roles (pivot) role_user таблица
hasOneThrough Country → User → Phone через промежуточную
hasManyThrough Country → User → Orders через промежуточную
morphOne Post/User → Image (полиморфная) images.imageable_id
morphMany Post/User → Comments comments.commentable_id
morphToMany Post/Video ↔ Tags taggables pivot
hasOne / belongsTo — один к одному
// У пользователя один профиль
class User extends Model
{
public function profile(): HasOne
{
return $this->hasOne(Profile::class);
// SQL: SELECT * FROM profiles WHERE user_id = ?
}
}
// Профиль принадлежит пользователю
class Profile extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
// SQL: SELECT * FROM users WHERE id = ?
}
}
// Использование
$user->profile->bio;
$profile->user->name;
hasMany / belongsTo — один ко многим
// У пользователя много заказов
class User extends Model
{
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
}
// Заказ принадлежит пользователю
class Order extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
// Использование
$user->orders; // Collection всех заказов
$user->orders()->where('status', 'paid')->get(); // можно добавлять условия
$order->user->email;
belongsToMany — многие ко многим
Требует промежуточную (pivot) таблицу. Например, пользователи и роли — у одного пользователя может быть несколько ролей, и одна роль может быть у нескольких пользователей.
// Миграция pivot-таблицы
Schema::create('role_user', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('role_id')->constrained()->cascadeOnDelete();
$table->timestamps();
});
class User extends Model
{
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class);
// Laravel сам найдёт таблицу role_user (имена в алфавитном порядке)
}
}
class Role extends Model
{
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class);
}
}
// Использование
$user->roles; // Collection ролей
$role->users; // Collection пользователей
// Управление связями
$user->roles()->attach(1); // добавить роль
$user->roles()->detach(1); // убрать роль
$user->roles()->sync([1, 2, 3]); // установить ровно эти роли
$user->roles()->toggle([1, 2]); // если есть — убрать, если нет — добавить
Pivot с дополнительными полями
// Pivot-таблица с доп. полями (например, количество в заказе)
class Order extends Model
{
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class)
->withPivot('quantity', 'unit_price')
->withTimestamps();
}
}
// Доступ к pivot-полям
foreach ($order->products as $product) {
$product->pivot->quantity;
$product->pivot->unit_price;
}
Полиморфные связи (morphOne, morphMany)
Одна таблица хранит связи с разными моделями. Например, и у поста, и у пользователя могут быть изображения — но таблица images одна.
// Таблица images:
// id | url | imageable_type | imageable_id
// 1 | ... | App\Models\Post | 5
// 2 | ... | App\Models\User | 12
class Post extends Model
{
public function images(): MorphMany
{
return $this->morphMany(Image::class, 'imageable');
}
}
class User extends Model
{
public function avatar(): MorphOne
{
return $this->morphOne(Image::class, 'imageable');
}
}
class Image extends Model
{
public function imageable(): MorphTo
{
return $this->morphTo();
}
}
// Использование
$post->images; // все изображения поста
$user->avatar; // аватар пользователя
$image->imageable; // вернёт Post или User — зависит от type
5. Builder
Builder — это объект, который собирает SQL-запрос по кусочкам. Каждый вызов where(), orderBy(), limit() добавляет условие, но запрос к БД ещё не выполнен. Он выполнится только когда ты вызовешь завершающий метод — get(), first(), count(), find().
// Это всё ещё Builder — запроса к БД нет
$query = Order::where('status', 'paid')
->where('total_price', '>', 100)
->orderBy('created_at', 'desc');
// Тип: Illuminate\Database\Eloquent\Builder
// Запрос выполнится только сейчас:
$orders = $query->get(); // → Collection (результат из БД)
$order = $query->first(); // → Model или null
$count = $query->count(); // → int
```
По сути Builder — это черновик SQL-запроса. Ты дописываешь условия сколько нужно, а потом говоришь «выполняй». Примерно как составлять SQL руками:
```
Builder строит: SELECT * FROM orders
->where('status', 'paid') → WHERE status = 'paid'
->where('price', '>', 100) → AND price > 100
->orderBy('created_at') → ORDER BY created_at DESC
->limit(10) → LIMIT 10
->get() → выполнить!
Именно поэтому важна разница:
// Builder — фильтрация в SQL (быстро)
Order::where('status', 'paid')->get();
// Collection — фильтрация в PHP (медленно, всё уже в памяти)
Order::all()->where('status', 'paid');
Первый вариант говорит базе «дай мне только оплаченные». Второй — «дай мне ВСЕ заказы», а потом фильтрует массив в PHP. При тысячах записей разница огромная.
6. Eager Loading и проблема N+1 – with()
Это одна из самых частых ловушек в Eloquent. Без eager loading каждое обращение к связи — отдельный запрос к БД.
Проблема N+1
// ПЛОХО — N+1 запросов
$orders = Order::all(); // 1 запрос: SELECT * FROM orders
foreach ($orders as $order) {
echo $order->user->name;
// Каждая итерация = ещё 1 запрос: SELECT * FROM users WHERE id = ?
}
// Если 100 заказов → 1 + 100 = 101 запрос к БД!
Решение: with()
Eager loading – это когда ты заранее загружаешь связанные данные вместе с основной моделью, одним дополнительным запросом, а не по одному запросу на каждую запись.
// ХОРОШО — eager loading
$orders = Order::with('user')->get();
// Запрос 1: SELECT * FROM orders
// Запрос 2: SELECT * FROM users WHERE id IN (1, 2, 3, ...)
foreach ($orders as $order) {
echo $order->user->name; // данные уже загружены, запроса нет
}
// 100 заказов → всего 2 запроса!
Вложенный eager loading
// Загрузить заказы → товары заказа → категорию товара
$orders = Order::with('items.product.category')->get();
// Загрузить несколько связей
$orders = Order::with(['user', 'items', 'coupon'])->get();
// Eager loading с условиями
$users = User::with(['orders' => function ($query) {
$query->where('status', 'paid')
->orderBy('created_at', 'desc');
}])->get();
Ленивый eager loading (load)
// Если модель уже загружена, но связь нужна позже
$order = Order::find(1);
// Подгрузить связь после факта
$order->load('items');
// loadMissing — подгрузит только если ещё не загружено
$order->loadMissing('items');
Как обнаружить N+1
Пакет laravel/telescope или barryvdh/laravel-debugbar покажет все SQL-запросы на странице. Если видишь десятки одинаковых SELECT * FROM users WHERE id = ? — это N+1.
У меня стоит “fruitcake/laravel-debugbar”.

Или добавь в AppServiceProvider защиту от N+1 в режиме разработки:
// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
// В dev-режиме бросит исключение при ленивой загрузке связей
Model::preventLazyLoading(! app()->isProduction());
}
7. Scopes — переиспользуемые фильтры
Local Scopes
Именованные фильтры, которые можно комбинировать. Метод должен начинаться с scope, вызывается без этого префикса:
class Order extends Model
{
public function scopePaid($query)
{
return $query->where('status', 'paid');
}
public function scopeRecent($query, int $days = 7)
{
return $query->where('created_at', '>=', now()->subDays($days));
}
public function scopeExpensive($query, float $min = 1000)
{
return $query->where('total_price', '>=', $min);
}
}
// Использование — цепочка читается как текст
$orders = Order::paid()->recent()->get();
$orders = Order::paid()->recent(30)->expensive(500)->get();
Global Scopes
Применяются автоматически ко ВСЕМ запросам модели. Типичный пример — показывать только активные записи:
// Через атрибут (Laravel 11+)
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
#[ScopedBy(ActiveScope::class)]
class Product extends Model {}
// Сам scope
class ActiveScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('is_active', true);
}
}
// Теперь Product::all() всегда добавляет WHERE is_active = true
// Отключить global scope для конкретного запроса
Product::withoutGlobalScope(ActiveScope::class)->get();
Ловушка: SoftDeletes — это тоже global scope. Именно поэтому удалённые записи не видны по умолчанию.
8. Accessors и Mutators
Позволяют трансформировать значения при чтении (accessor) и записи (mutator) атрибутов модели.
Новый синтаксис (Laravel 9+)
use Illuminate\Database\Eloquent\Casts\Attribute;
class User extends Model
{
// Accessor — трансформация при чтении
protected function firstName(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucfirst($value),
);
}
// Mutator — трансформация при записи
protected function email(): Attribute
{
return Attribute::make(
set: fn (string $value) => strtolower($value),
);
}
// Оба вместе
protected function name(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucwords($value),
set: fn (string $value) => strtolower($value),
);
}
// Виртуальный атрибут (нет в БД)
protected function fullName(): Attribute
{
return Attribute::make(
get: fn () => "{$this->first_name} {$this->last_name}",
);
}
}
// Использование
$user->first_name; // "john" в БД → "John" при чтении
$user->email = '[email protected]'; // сохранится как "[email protected]"
$user->full_name; // "John Doe" — вычисляется на лету
Старый синтаксис (до Laravel 9)
// Старый стиль — всё ещё работает, но новый удобнее
class User extends Model
{
// Accessor
public function getFirstNameAttribute($value)
{
return ucfirst($value);
}
// Mutator
public function setEmailAttribute($value)
{
$this->attributes['email'] = strtolower($value);
}
}
9. Casts — приведение типов
В базе данных всё хранится как строки, числа, JSON. Casts автоматически конвертируют значения в нужный PHP-тип при чтении и обратно при записи.
class Order extends Model
{
protected function casts(): array
{
return [
'total_price' => 'decimal:2', // string "999.00"
'is_paid' => 'boolean', // true/false вместо 1/0
'metadata' => 'array', // JSON ↔ PHP array
'settings' => 'object', // JSON ↔ PHP stdClass
'shipped_at' => 'datetime', // Carbon instance
'created_at' => 'immutable_datetime', // CarbonImmutable
'options' => 'collection', // JSON ↔ Collection
'secret' => 'encrypted', // шифрование в БД
'status' => OrderStatus::class, // PHP Enum
];
}
}
// Примеры использования
$order->is_paid; // true (а не 1)
$order->metadata; // ['key' => 'value'] (а не JSON-строка)
$order->shipped_at->diffForHumans(); // "2 дня назад"
$order->status; // OrderStatus::Paid (enum)
Ловушка: без каста 'boolean' значение из БД будет 1 или 0 (int), а не true/false. Проверка if ($order->is_paid === true) не сработает для 1 без строгого сравнения.
Enum Cast
// app/Enums/OrderStatus.php
namespace App\Enums;
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
}
// В модели
protected function casts(): array
{
return [
'status' => OrderStatus::class,
];
}
// Использование
$order->status = OrderStatus::Paid;
$order->save();
if ($order->status === OrderStatus::Paid) {
// ...
}
10. Soft Deletes — мягкое удаление
Вместо реального удаления из БД запись помечается как удалённая (заполняется поле deleted_at). Это позволяет восстановить данные.
use Illuminate\Database\Eloquent\SoftDeletes;
class Order extends Model
{
use SoftDeletes;
}
// Миграция должна содержать:
$table->softDeletes(); // добавляет колонку deleted_at
// Обычное удаление — запись остаётся в БД, deleted_at заполняется
$order->delete();
// Обычные запросы не видят удалённые записи
Order::all(); // только где deleted_at IS NULL
// Включить удалённые
Order::withTrashed()->get(); // все, включая удалённые
Order::onlyTrashed()->get(); // только удалённые
// Восстановить
$order->restore();
// Удалить навсегда (из БД)
$order->forceDelete();
// Проверить
$order->trashed(); // true если мягко удалена
Ловушка: связи по умолчанию тоже не видят мягко удалённые записи. Если Order мягко удалён, $user->orders его не покажет. Чтобы включить — $user->orders()->withTrashed()->get().
11. События модели (Model Events)
Eloquent автоматически вызывает события на каждом этапе жизненного цикла модели. Можно подписаться и выполнить логику.
creating → created (при создании)
updating → updated (при обновлении)
saving → saved (при создании ИЛИ обновлении)
deleting → deleted (при удалении)
restoring → restored (при восстановлении soft delete)
trashed (при мягком удалении)
retrieved (при получении из БД)
Вариант 1: метод booted() в модели
class Order extends Model
{
protected static function booted(): void
{
static::creating(function (Order $order) {
$order->number = 'ORD-' . strtoupper(uniqid());
});
static::updating(function (Order $order) {
if ($order->isDirty('status') && $order->status === 'paid') {
// Статус изменился на "paid"
}
});
}
}
booted() в модели Laravel – это специальный статический метод, который вызывается, когда модель “загружается” фреймворком.
Проще говоря: это место, где ты можешь повесить обработчики событий модели, например:
creatingcreatedupdatingupdatedsavingsaveddeleting
В твоем примере:
- при создании нового
Orderсрабатываетcreating - перед обновлением существующего
Orderсрабатываетupdating
То есть booted() – это просто место, где ты регистрируешь такую логику.
static::creating(function (Order $order) {
$order->number = 'ORD-' . strtoupper(uniqid());
});
означает:
“перед созданием заказа автоматически проставь ему номер”
Вариант 2: Observer
Когда логики много, выносим в отдельный класс. Создаётся командой php artisan make:observer OrderObserver --model=Order.
// app/Observers/OrderObserver.php
namespace App\Observers;
use App\Models\Order;
class OrderObserver
{
public function creating(Order $order): void
{
$order->number = 'ORD-' . strtoupper(uniqid());
}
public function updated(Order $order): void
{
if ($order->wasChanged('status') && $order->status === 'paid') {
event(new \App\Events\OrderPaid($order));
}
}
public function deleting(Order $order): void
{
// Удалить связанные файлы перед удалением заказа
Storage::delete($order->invoice_path);
}
}
// Регистрация Observer — в модели через атрибут (Laravel 11+)
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
#[ObservedBy(OrderObserver::class)]
class Order extends Model {}
Ловушка: события НЕ срабатывают при массовых операциях. Order::where(...)->update([...]) не вызовет updating/updated. Только когда ты работаешь с конкретным экземпляром модели: $order->update([...]).
12. Полезные методы, которые часто не знают
isDirty / isClean / wasChanged
$order = Order::find(1);
$order->status = 'paid';
$order->isDirty(); // true — есть несохранённые изменения
$order->isDirty('status'); // true — конкретно status изменился
$order->isDirty('price'); // false — price не трогали
$order->isClean(); // false — противоположность isDirty
$order->getOriginal('status'); // 'pending' — значение ДО изменения
$order->save();
$order->wasChanged('status'); // true — status был изменён при последнем save()
increment / decrement
// Атомарно увеличить/уменьшить значение (без race condition)
$product->increment('stock', 5); // stock += 5
$product->decrement('stock'); // stock -= 1
$product->increment('views'); // views += 1
// Можно обновить и другие поля одновременно
$product->increment('sales_count', 1, ['last_sold_at' => now()]);
replicate — клонировать модель
$original = Product::find(1);
$copy = $original->replicate(); // копия без id и timestamps
$copy->name = $original->name . ' (копия)';
$copy->save(); // новая запись в БД
withCount / withSum / withAvg
// Подсчёт связанных записей без загрузки их
$users = User::withCount('orders')->get();
$users->first()->orders_count; // 15
// Сумма поля в связи
$users = User::withSum('orders', 'total_price')->get();
$users->first()->orders_sum_total_price; // 12500.00
// С условиями
$users = User::withCount(['orders' => function ($query) {
$query->where('status', 'paid');
}])->get();
$users->first()->orders_count; // только оплаченные
toArray / toJson / only / except
$product = Product::find(1);
$product->toArray(); // все атрибуты как массив
$product->toJson(); // JSON-строка
$product->only(['name', 'price']); // ['name' => '...', 'price' => ...]
$product->except(['created_at']); // всё кроме created_at
hidden / visible — скрытие полей при сериализации
class User extends Model
{
// Эти поля НЕ попадут в toArray() и toJson()
protected $hidden = [
'password',
'remember_token',
];
// Или наоборот — только эти попадут
protected $visible = [
'name',
'email',
];
}
13. Коллекции (Collections)
Когда Eloquent возвращает несколько записей, это не массив, а Collection — объект с десятками полезных методов. Работает как цепочка.
$products = Product::where('stock', '>', 0)->get();
// Фильтрация
$expensive = $products->where('price', '>', 500);
$cheap = $products->whereBetween('price', [10, 100]);
// Трансформация
$names = $products->pluck('name'); // ['iPhone', 'MacBook', ...]
$mapped = $products->map(fn ($p) => [
'label' => $p->name,
'value' => $p->id,
]);
// Группировка
$byCategory = $products->groupBy('category_id');
// Агрегация
$products->sum('price');
$products->avg('price');
$products->min('price');
$products->max('price');
$products->count();
// Сортировка
$products->sortBy('price');
$products->sortByDesc('price');
// Поиск
$products->first();
$products->last();
$products->find(5); // по primary key
$products->firstWhere('sku', 'IPHONE-16');
// Проверки
$products->isEmpty();
$products->isNotEmpty();
$products->contains('name', 'iPhone');
// Уникальные
$products->unique('category_id');
// Разбиение
$chunks = $products->chunk(10); // разбить по 10
[$active, $inactive] = $products->partition(fn ($p) => $p->is_active);
Важно: методы коллекции работают уже с загруженными данными в PHP. Это НЕ SQL-запросы. $products->where('price', '>', 500) фильтрует массив в памяти, а не добавляет WHERE в SQL. Для фильтрации в SQL — используй Builder до ->get().
14. Частые ловушки и ошибки
Ловушка 1: Collection vs Builder
// ПЛОХО — загружает ВСЕ заказы в память, потом фильтрует в PHP
$paid = Order::all()->where('status', 'paid');
// ХОРОШО — фильтрует в SQL, в память попадают только нужные
$paid = Order::where('status', 'paid')->get();
Order::all() возвращает Collection. Order::where() возвращает Builder.
Разница — в том, ГДЕ происходит фильтрация: в БД или в PHP. При 100 000 записей это критично.
Ловушка 2: create() молча игнорирует поля
class Product extends Model
{
protected $fillable = ['name', 'price'];
}
// Поле stock НЕ в fillable — оно тихо проигнорируется!
Product::create([
'name' => 'iPhone',
'price' => 999,
'stock' => 50, // не сохранится, ошибки не будет
]);
Ловушка 3: save() vs update() после изменений
$order = Order::find(1);
$order->status = 'paid';
// Оба варианта сохранят — но есть разница:
$order->save(); // сохранит ВСЕ dirty-поля
$order->update(['status' => 'paid']); // сохранит только переданные поля
// Подвох: если ты изменил поле, а потом вызвал update() с другими полями:
$order->status = 'paid';
$order->update(['notes' => 'test']);
// status НЕ сохранится! update() работает только с тем, что передано в массив
Ловушка 4: find() с несуществующим ID
$order = Order::find(999); // null
$order->status; // Error: accessing property on null
// Решение 1: проверка
$order = Order::find(999);
if ($order) { ... }
// Решение 2: findOrFail() — сразу 404
$order = Order::findOrFail(999); // бросит ModelNotFoundException → 404
Ловушка 5: связь возвращает null или пустую коллекцию
$order = Order::find(1);
// belongsTo / hasOne → вернёт null если связи нет
$order->user->name; // Error если user_id = null
// Решение: optional() или nullsafe оператор
optional($order->user)->name; // null вместо ошибки
$order->user?->name; // PHP 8 nullsafe — то же самое
// hasMany / belongsToMany → вернёт пустую Collection (не null)
$order->items; // Collection (может быть пустая, но не null)
$order->items->count(); // 0, ошибки не будет
Ловушка 6: обновил модель, а в ней старые данные – refresh()
$order = Order::find(1);
// Обновили в БД через другой запрос
Order::where('id', 1)->update(['status' => 'shipped']);
$order->status; // всё ещё 'paid' — модель не знает об изменении
// Решение: перезагрузить из БД
$order->refresh();
$order->status; // 'shipped'
Ловушка 7: timestamps обновляются когда не ждёшь
// save() и update() на модели автоматически обновляют updated_at
$order->update(['notes' => 'test']); // updated_at тоже изменится
// Чтобы НЕ обновлять timestamps:
$order->timestamps = false;
$order->update(['notes' => 'test']); // updated_at не тронуто
// Массовые операции через Builder НЕ обновляют timestamps
Order::where('id', 1)->update(['notes' => 'test']); // updated_at не тронуто
15. refresh() vs. save()
Это противоположные операции:
save()— отправляет данные из PHP в базу. Ты изменил модель в коде и хочешь сохранить изменения.refresh()— загружает данные из базы в PHP. Кто-то изменил запись в БД, и ты хочешь подтянуть свежие данные.
$order = Order::find(1); // status = 'pending'
// save() — из PHP → в БД
$order->status = 'paid';
$order->save(); // записал 'paid' в базу
// refresh() — из БД → в PHP
$order->refresh(); // перечитал запись из базы в объект
Типичный случай для refresh() — когда запись могла измениться «за спиной» модели:
$order = Order::find(1);
// Где-то в другом месте кода обновили через Builder
Order::where('id', 1)->update(['status' => 'shipped']);
$order->status; // 'pending' — объект не знает об изменении
$order->refresh();
$order->status; // 'shipped' — теперь знает
Анонимные функции (Closures) в Eloquent Builder
Многие методы Eloquent Builder принимают анонимную функцию (Closure) вместо обычных аргументов. Внутри этой функции ты получаешь $query — вложенный Builder, который собирает часть SQL-запроса отдельно.
Что такое вложенный Builder
Обычный Builder — это цепочка методов, которая собирает один SQL-запрос. Вложенный Builder — это ещё одна цепочка внутри основной, которая формирует изолированную группу условий. В SQL это превращается в скобки.
Обычный Builder:
Order::where('status', 'paid')->where('price', '>', 100)->get();
SQL: WHERE status = 'paid' AND price > 100
Вложенный Builder (через Closure):
Order::where('status', 'paid')->where(function ($query) {
$query->where('price', '>', 100)->orWhere('is_vip', true);
})->get();
SQL: WHERE status = 'paid' AND (price > 100 OR is_vip = true)
↑ ↑
скобки — это и есть вложенный Builder
$query внутри Closure — это отдельный Builder, который собирает условия в скобках. Всё, что ты цепляешь на него, попадёт внутрь этих скобок. Основной Builder не знает подробностей — он видит только результат как одну группу.
1. Группировка условий в where()
Проблема без Closure
// НЕПРАВИЛЬНО — orWhere ломает логику
Order::where('status', 'paid')
->where('total_price', '>', 1000)
->orWhere('is_vip', true)
->get();
// SQL: WHERE status = 'paid' AND total_price > 1000 OR is_vip = true
// Вернёт ВСЕХ VIP-пользователей, даже с неоплаченными заказами
// Потому что OR относится ко всему выражению, а не к части
Решение с Closure
// ПРАВИЛЬНО — Closure создаёт скобки
Order::where('status', 'paid')
->where(function ($query) {
$query->where('total_price', '>', 1000)
->orWhere('is_vip', true);
})
->get();
// SQL: WHERE status = 'paid' AND (total_price > 1000 OR is_vip = true)
// Теперь OR работает только внутри скобок
Ещё пример — сложная фильтрация
// Заказы: оплаченные за последнюю неделю ИЛИ VIP за последний месяц
Order::where(function ($query) {
$query->where('status', 'paid')
->where('created_at', '>=', now()->subWeek());
})
->orWhere(function ($query) {
$query->where('is_vip', true)
->where('created_at', '>=', now()->subMonth());
})
->get();
// SQL: WHERE (status = 'paid' AND created_at >= '...')
// OR (is_vip = true AND created_at >= '...')
Правило: как только в запросе появляется orWhere, почти всегда нужна Closure для группировки. Без скобок OR распространяется на всё выражение и результат будет неожиданным.
2. Фильтрация по связям — whereHas()
whereHas() фильтрует записи на основе условий в связанной таблице. Closure получает Builder связанной модели.
// Пользователи, у которых есть оплаченные заказы дороже 500
User::whereHas('orders', function ($query) {
// $query — Builder модели Order (не User!)
$query->where('status', 'paid')
->where('total_price', '>', 500);
})->get();
// SQL: SELECT * FROM users
// WHERE EXISTS (
// SELECT 1 FROM orders
// WHERE orders.user_id = users.id
// AND status = 'paid'
// AND total_price > 500
// )
// Товары, у которых есть отзывы с рейтингом 5
Product::whereHas('reviews', function ($query) {
$query->where('rating', 5);
})->get();
// Товары, у которых НЕТ отзывов
Product::doesntHave('reviews')->get();
// Товары, у которых НЕТ отзывов с рейтингом ниже 3
Product::whereDoesntHave('reviews', function ($query) {
$query->where('rating', '<', 3);
})->get();
Важно: $query внутри whereHas() — это Builder связанной модели, не основной. Если связь orders, то $query работает с таблицей orders. Условия к основной таблице добавляются снаружи Closure.
3. Eager Loading с условиями — with()
Обычный with('orders') загружает все связанные записи. Closure позволяет отфильтровать или отсортировать то, что подгружается.
// Загрузить пользователей, а с ними — только оплаченные заказы, последние первыми
User::with(['orders' => function ($query) {
$query->where('status', 'paid')
->orderBy('created_at', 'desc');
}])->get();
// Каждый $user->orders теперь содержит только оплаченные, отсортированные
// Загрузить заказы, а с ними — только товары дороже 100 и название категории
Order::with([
'items' => function ($query) {
$query->where('unit_price', '>', 100);
},
'items.product.category',
'user',
])->get();
Разница между whereHas() и with()
// whereHas — ФИЛЬТРУЕТ основную модель
// "Дай мне только тех пользователей, у которых есть оплаченные заказы"
User::whereHas('orders', function ($query) {
$query->where('status', 'paid');
})->get();
// Результат: не все пользователи, а только те, у кого есть оплаченные заказы
// with — ПОДГРУЖАЕТ связь (не фильтрует основную модель)
// "Дай мне всех пользователей, но загрузи им только оплаченные заказы"
User::with(['orders' => function ($query) {
$query->where('status', 'paid');
}])->get();
// Результат: все пользователи, но $user->orders содержит только оплаченные
// Часто нужны вместе
User::whereHas('orders', function ($query) {
$query->where('status', 'paid');
})
->with(['orders' => function ($query) {
$query->where('status', 'paid');
}])
->get();
// Только пользователи с оплаченными заказами + сами заказы подгружены
4. Условный Builder — when()
when() выполняет Closure только если условие истинно. Удобно для необязательных фильтров из формы поиска.
// Без when() — много if-ов
$query = Order::query();
if ($request->status) {
$query->where('status', $request->status);
}
if ($request->min_price) {
$query->where('total_price', '>=', $request->min_price);
}
if ($request->sort === 'newest') {
$query->orderBy('created_at', 'desc');
}
$orders = $query->get();
// С when() — чистая цепочка
$orders = Order::query()
->when($request->status, function ($query, $status) {
$query->where('status', $status);
})
->when($request->min_price, function ($query, $minPrice) {
$query->where('total_price', '>=', $minPrice);
})
->when($request->sort === 'newest', function ($query) {
$query->orderBy('created_at', 'desc');
})
->get();
// Если $request->status = null → Closure не выполнится, условие не добавится
// Если $request->status = 'paid' → добавит WHERE status = 'paid'
when() принимает и третий аргумент — Closure для случая, когда условие ложно (аналог else):
Order::query()
->when(
$request->sort === 'cheapest',
fn ($query) => $query->orderBy('total_price', 'asc'), // if true
fn ($query) => $query->orderBy('created_at', 'desc'), // if false
)
->get();
5. Подзапросы (Subqueries)
Closure используется для построения подзапросов — вложенный SELECT внутри основного запроса.
// Добавить к каждому пользователю дату его последнего заказа
User::addSelect([
'last_order_at' => Order::select('created_at')
->whereColumn('orders.user_id', 'users.id')
->latest()
->limit(1),
])->get();
// SQL: SELECT users.*,
// (SELECT created_at FROM orders
// WHERE orders.user_id = users.id
// ORDER BY created_at DESC LIMIT 1) as last_order_at
// FROM users
// Фильтрация через подзапрос — whereIn с Closure
Order::whereIn('user_id', function ($query) {
$query->select('id')
->from('users')
->where('is_vip', true);
})->get();
// SQL: SELECT * FROM orders
// WHERE user_id IN (SELECT id FROM users WHERE is_vip = true)
Итого: где используются Closure в Builder
Метод Зачем Closure $query работает с
──────────────────────────────────────────────────────────────────────────
where(fn) Группировка условий (скобки) Основная таблица
orWhere(fn) Группировка OR-условий Основная таблица
whereHas(rel, fn) Фильтрация по условиям в связи Таблица связи
with([rel => fn]) Eager loading с условиями Таблица связи
when(cond, fn) Условное добавление фильтра Основная таблица
whereIn(col, fn) Подзапрос в IN (...) Любая таблица
addSelect(fn) Подзапрос в SELECT Любая таблица
Принцип везде один: Closure получает $query (вложенный Builder), ты цепляешь на него условия, и они становятся изолированной частью основного запроса — скобками, подзапросом или фильтром связи.
Шпаргалка: основные методы Eloquent
ЧТЕНИЕ
──────────────────────────────────────────────────────────────
find(1) По ID, null если нет
findOrFail(1) По ID, 404 если нет
first() Первая запись, null если нет
firstOrFail() Первая запись, 404 если нет
get() Коллекция записей
all() Все записи (без условий)
pluck('name') Одна колонка как Collection
value('name') Одно значение (скаляр)
exists() / doesntExist() Есть ли записи (bool)
count() / sum() / avg() Агрегация
chunk(100, fn) Порциями (экономия памяти)
СОЗДАНИЕ
──────────────────────────────────────────────────────────────
create([...]) Создать и сохранить (mass assignment)
new Model + save() Создать, настроить, потом сохранить
firstOrCreate([...]) Найти или создать
updateOrCreate([...]) Обновить или создать
ОБНОВЛЕНИЕ
──────────────────────────────────────────────────────────────
$model->update([...]) Обновить переданные поля
$model->save() Сохранить все dirty-поля
increment('col') / decrement('col') Атомарно +1 / -1
$model->fill([...])->save() Заполнить + сохранить
УДАЛЕНИЕ
──────────────────────────────────────────────────────────────
$model->delete() Удалить (или soft delete)
destroy(1) / destroy([...]) Удалить по ID
$model->forceDelete() Удалить навсегда (soft delete)
$model->restore() Восстановить (soft delete)
СВЯЗИ
──────────────────────────────────────────────────────────────
with('relation') Eager loading
load('relation') Ленивый eager loading
has('relation') Где есть связанные записи
whereHas('relation', fn) Где связанные удовлетворяют условию
withCount('relation') Подсчёт связанных
doesntHave('relation') Где НЕТ связанных записей
ПРОВЕРКИ СОСТОЯНИЯ
──────────────────────────────────────────────────────────────
isDirty() / isClean() Есть ли несохранённые изменения
wasChanged() Изменилось ли при последнем save()
getOriginal('col') Значение до изменения
trashed() Мягко удалена? (soft delete)
refresh() Перезагрузить из БД
СЕРИАЛИЗАЦИЯ
──────────────────────────────────────────────────────────────
toArray() / toJson() Конвертация
$hidden / $visible Скрыть/показать поля
only([...]) / except([...]) Выбрать поля на лету
$appends Добавить виртуальные атрибуты
Практика
Clone
$category = Category::where('slug', $slug)
->where('is_active', true)
->firstOrFail();
$children = $category->children()->where('is_active', true)->get();
$categoryIds = $category->descendants()->where('is_active', true)->pluck('id')->prepend($category->id);
$baseQuery = Product::whereIn('category_id', $categoryIds)->where('is_active', true);
// Get the lowest and highest price from the "price" column and return them as min_price and max_price
$priceRange = (clone $baseQuery)->selectRaw('MIN(price) as min_price, MAX(price) as max_price')->first();
$query = (clone $baseQuery)->with('images');
У нас есть есть базовый запрос – $baseQuery.
Теперь ты хочешь на основе этого же запроса сделать две разные вещи:
- получить диапазон цен
- добавляем к ней eager loading связи
images
сам $baseQuery при этом не трогаем.
SelectRaw
$baseQuery = Product::whereIn('category_id', $categoryIds)->where('is_active', true);
// Get the lowest and highest price from the "price" column and return them as min_price and max_price
$priceRange = (clone $baseQuery)->selectRaw('MIN(price) as min_price, MAX(price) as max_price')->first();
Ты буквально говоришь SQL:
- возьми колонку
price - посчитай по ней минимальное значение через
MIN(price) - и назови результат
min_price
Тут две разные вещи.
price– это реальное поле в таблицеproducts

MIN(price)– это SQL-функция. Она смотрит на все значения в колонкеprice и возвращает самое маленькое.- as min_price – мы просто говорим: назови результат не
MIN(price), аmin_price, чтобы потом было удобно обращаться в PHP. Потом в шаблоне или в коде можно писать:
$priceRange->min_price

prepend()
prepend() – это метод коллекции Laravel, который добавляет элемент в начало списка.
$category = Category::where('slug', $slug)
->where('is_active', true)
->firstOrFail();
$categoryIds = $category->descendants()->where('is_active', true)->pluck('id')->prepend($category->id);
Разбираем по шагам:
pluck('id')
→ получаешь коллекцию ID всех дочерних категорий
Например:
[5, 8, 12]
prepend($category->id)
→ добавляет ID текущей категории в начало
[2, 5, 8, 12] // где 2 - это $category->id
Получается мы формируем список всех категорий:
- все её потомки
- сама категория
whereIn()
Это метод построителя запросов (query builder), используемый для фильтрации данных. Он добавляет условие WHERE ... IN (...) в SQL-запрос, проверяя, входит ли значение поля в заданный массив или коллекцию.
Самый простой пример:
Product::whereIn('category_id', [1, 2, 3])->get();
Это превращается примерно в SQL:
SELECT * FROM products
WHERE category_id IN (1, 2, 3)
То есть: “дай мне все товары, у которых category_id равен 1 или 2 или 3″.
Пример:
$categoryIds = $category->descendants()->where('is_active', true)->pluck('id')->prepend($category->id);
$baseQuery = Product::whereIn('category_id', $categoryIds)->where('is_active', true);
load()
load() подгружает связи в уже существующую модель, которая уже была получена из базы.
$order->load('items.product');
- объект
$orderуже есть - теперь к нему нужно дозагрузить связь
items - и у каждого
itemеще связьproduct
В чем разница с with()
with()
Используется до получения модели, прямо в запросе:
$order = Order::with('items.product')->find($id);
То есть:
- сначала строишь запрос
- сразу говоришь, какие связи подгрузить
- потом получаешь модель уже с ними
load()
Используется после того, как модель уже получена:
$order = Order::find($id);
$order->load('items.product');
То есть:
- модель уже есть
- потом отдельно подгружаешь связи
Пример
public function orderShow(Order $order)
{
if ($order->user_id !== auth()->id()) {
abort(403);
}
$order->load('items.product');
return view('account.order-show', compact('order'));
}
Order $order пришел через route model binding.
То есть Laravel уже нашел заказ и передал его в метод.
Но без load() у тебя будут подгружены только данные самого заказа, без items и product.
Зачем это нужно
Чтобы избежать N+1.
Без load():
- загрузится заказ
- потом при обращении к
$order->itemsбудет отдельный запрос - потом для каждого item при обращении к
$item->productеще запрос
С load() Laravel заранее подгрузит нужные связи.
Что значит 'items.product'
Это вложенная связь:
- у
itemsестьproduct - у
orderестьitems
Коротко
load()– подгружает связи после того, как модель уже полученаwith()– подгружает связи при запросе