Глава 8.5: Eloquent ORM — модели, CRUD, mass assignment, accessors, mutators
📖 Введение
Помнишь, как в главе 3.3 ты работал с PDO, вручную писал SQL-запросы и маппил результаты в массивы? Eloquent — это ORM (Object-Relational Mapping), который превращает таблицы базы данных в объекты PHP. Вместо SQL ты работаешь с понятными методами, а Laravel сам генерирует оптимальные запросы.
Что ты освоишь в этой главе:
- Создание и настройка моделей Eloquent
- CRUD операции (Create, Read, Update, Delete) через ORM
- Mass Assignment и защита от уязвимостей
- Accessors и Mutators для преобразования данных
- Работа с датами и типизация атрибутов
🎯 Философия Eloquent: Active Record Pattern
Eloquent реализует паттерн Active Record — каждая модель представляет одну таблицу, а экземпляр модели — одну строку.
Сравним подходы:
Было (PDO):
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$id]);
$userData = $stmt->fetch(PDO::FETCH_ASSOC);
$user = [
'name' => $userData['name'],
'email' => $userData['email']
];Стало (Eloquent):
$user = User::find($id);
echo $user->name;
echo $user->email;🏗️ Создание модели
Через Artisan:
php artisan make:model PostСоздаст файл app/Models/Post.php:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
//
}С дополнительными опциями:
# Модель + миграция
php artisan make:model Post -m
# Модель + миграция + контроллер + seeder
php artisan make:model Post -mcrs
# Все вместе (migration, controller, resource, seeder, factory, policy, requests)
php artisan make:model Post --all🔧 Настройка модели
Конвенции Laravel (работают автоматически):
class Post extends Model
{
// 1. Имя таблицы: posts (множественное число, snake_case)
// 2. Первичный ключ: id
// 3. Timestamps: created_at, updated_at (автоматически управляются)
}Кастомная настройка:
class Post extends Model
{
// Если таблица называется не "posts"
protected $table = 'blog_posts';
// Если первичный ключ не "id"
protected $primaryKey = 'post_id';
// Если первичный ключ не auto-increment
public $incrementing = false;
// Тип первичного ключа (если не int)
protected $keyType = 'string';
// Отключить timestamps
public $timestamps = false;
// Кастомные имена полей timestamps
const CREATED_AT = 'creation_date';
const UPDATED_AT = 'updated_date';
// Подключение к другой БД (если используешь несколько)
protected $connection = 'mysql2';
}📝 CRUD операции
1. CREATE (Создание)
Способ 1: Create + Save
$post = new Post();
$post->title = 'Мой первый пост';
$post->content = 'Содержимое поста';
$post->author_id = 1;
$post->save(); // INSERT в БД
echo $post->id; // Автоматически получен после save()Способ 2: Create (Mass Assignment)
$post = Post::create([
'title' => 'Второй пост',
'content' => 'Еще контент',
'author_id' => 1
]);⚠️ Требует настройки $fillable или $guarded!
Способ 3: FirstOrCreate (найди или создай)
// Найдет пост с таким email или создаст новый
$user = User::firstOrCreate(
['email' => 'test@example.com'],
['name' => 'Test User', 'password' => bcrypt('secret')]
);Способ 4: UpdateOrCreate (обнови или создай)
$post = Post::updateOrCreate(
['slug' => 'my-post'], // Условие поиска
['title' => 'Обновленный заголовок'] // Данные для обновления/создания
);2. READ (Чтение)
Получение всех записей:
$posts = Post::all(); // Collection из всех постов
foreach ($posts as $post) {
echo $post->title;
}Получение с условиями:
// WHERE title = 'Laravel'
$posts = Post::where('title', 'Laravel')->get();
// WHERE views > 100
$posts = Post::where('views', '>', 100)->get();
// WHERE status = 'published' AND views > 50
$posts = Post::where('status', 'published')
->where('views', '>', 50)
->get();
// WHERE status = 'published' OR status = 'draft'
$posts = Post::where('status', 'published')
->orWhere('status', 'draft')
->get();Получение одной записи:
// По первичному ключу
$post = Post::find(1);
// Первая запись или null
$post = Post::where('status', 'published')->first();
// Первая запись или исключение (404 в веб-контексте)
$post = Post::findOrFail(1);
// Первая запись или выполнить callback
$post = Post::firstOr(function () {
return 'Нет постов';
});Сортировка и ограничения:
// ORDER BY created_at DESC
$posts = Post::orderBy('created_at', 'desc')->get();
// Последние 5 постов
$posts = Post::latest()->take(5)->get();
// Пропустить 10, взять 5 (пагинация)
$posts = Post::skip(10)->take(5)->get();Выборка конкретных полей:
// SELECT title, content FROM posts
$posts = Post::select('title', 'content')->get();
// Или через массив
$posts = Post::select(['title', 'content'])->get();Агрегация:
$count = Post::count();
$max = Post::max('views');
$avg = Post::avg('views');
$sum = Post::sum('views');3. UPDATE (Обновление)
Способ 1: Find + Save
$post = Post::find(1);
$post->title = 'Обновленный заголовок';
$post->save(); // UPDATE в БДСпособ 2: Update на модели
$post = Post::find(1);
$post->update([
'title' => 'Новый заголовок',
'content' => 'Новый контент'
]);Способ 3: Mass Update (на коллекции)
// Обновить все посты со статусом 'draft'
Post::where('status', 'draft')->update([
'status' => 'published'
]);Инкремент/Декремент:
$post = Post::find(1);
// views += 1
$post->increment('views');
// views += 5
$post->increment('views', 5);
// views -= 1
$post->decrement('views');
// Инкремент с дополнительными полями
$post->increment('views', 1, [
'last_viewed_at' => now()
]);4. DELETE (Удаление)
Удаление экземпляра:
$post = Post::find(1);
$post->delete(); // DELETE FROM posts WHERE id = 1Удаление по ID:
Post::destroy(1); // Один ID
Post::destroy([1, 2, 3]); // Несколько ID
Post::destroy(1, 2, 3); // Альтернативный синтаксисMass Delete:
// Удалить все посты со статусом 'draft'
Post::where('status', 'draft')->delete();Soft Deletes (мягкое удаление):
use Illuminate\Database\Eloquent\SoftDeletes;
class Post extends Model
{
use SoftDeletes;
}После этого:
$post->delete(); // Установит deleted_at = NOW(), не удалит физически
// Получить только не удаленные
$posts = Post::all();
// Получить только удаленные
$posts = Post::onlyTrashed()->get();
// Получить все (включая удаленные)
$posts = Post::withTrashed()->get();
// Восстановить
$post = Post::withTrashed()->find(1);
$post->restore();
// Удалить окончательно
$post->forceDelete();🛡️ Mass Assignment (массовое присвоение)
Проблема безопасности:
Представь форму регистрации:
<input name="name">
<input name="email">
<input name="password">Злоумышленник может добавить поле:
<input name="is_admin" value="1">Если ты делаешь:
User::create($request->all()); // ОПАСНО!То злоумышленник станет админом!
Решение 1: $fillable (белый список)
class Post extends Model
{
protected $fillable = [
'title',
'content',
'author_id',
'status'
];
}Теперь только эти поля можно массово присваивать:
Post::create($request->all()); // Безопасно, если в $fillable правильные поляРешение 2: $guarded (черный список)
class Post extends Model
{
protected $guarded = [
'id',
'is_featured',
'admin_only_field'
];
}Все поля, кроме указанных, можно присваивать массово.
⚠️ Никогда не делай:
protected $guarded = []; // Отключает всю защиту!Альтернатива: forceFill (в исключительных случаях)
$post = new Post();
$post->forceFill([
'id' => 999, // Даже защищенные поля
'title' => 'Test'
])->save();🎨 Accessors (Геттеры)
Accessors преобразуют данные при чтении из БД.
Пример 1: Форматирование имени
class User extends Model
{
// Accessor для атрибута 'name'
protected function name(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucfirst($value),
);
}
}Использование:
// В БД: 'john doe'
$user = User::find(1);
echo $user->name; // "John doe"Пример 2: Полное имя
class User extends Model
{
protected $appends = ['full_name']; // Добавить в JSON
protected function fullName(): Attribute
{
return Attribute::make(
get: fn () => "{$this->first_name} {$this->last_name}",
);
}
}Использование:
$user = User::find(1);
echo $user->full_name; // "John Doe"
// В JSON автоматически, благодаря $appends
return $user->toArray();
// ['id' => 1, 'first_name' => 'John', ..., 'full_name' => 'John Doe']Пример 3: Форматирование цены
class Product extends Model
{
protected function price(): Attribute
{
return Attribute::make(
get: fn (int $value) => $value / 100, // Хранится в центах
);
}
}// В БД: 1500 (центов)
$product = Product::find(1);
echo $product->price; // 15.00🖊️ Mutators (Сеттеры)
Mutators преобразуют данные при записи в БД.
Пример 1: Автоматическое хеширование пароля
class User extends Model
{
protected function password(): Attribute
{
return Attribute::make(
set: fn (string $value) => bcrypt($value),
);
}
}Использование:
$user = new User();
$user->password = 'secret123'; // Автоматически хешируется
$user->save();
// В БД: '$2y$10$...' (bcrypt hash)Пример 2: Нормализация телефона
class User extends Model
{
protected function phone(): Attribute
{
return Attribute::make(
set: fn (string $value) => preg_replace('/[^0-9]/', '', $value),
);
}
}$user = new User();
$user->phone = '+1 (555) 123-4567';
$user->save();
// В БД: '15551234567'Пример 3: Accessor + Mutator вместе
class Product extends Model
{
protected function price(): Attribute
{
return Attribute::make(
get: fn (int $value) => $value / 100, // При чтении: центы → доллары
set: fn (float $value) => $value * 100, // При записи: доллары → центы
);
}
}$product = new Product();
$product->price = 19.99; // Запись: сохранится 1999
$product->save();
$product = Product::find(1);
echo $product->price; // Чтение: 19.99🕒 Работа с датами
Автоматические timestamps:
Laravel автоматически управляет created_at и updated_at:
$post = new Post();
$post->title = 'Test';
$post->save();
// created_at и updated_at автоматически установлены
$post->title = 'Updated';
$post->save();
// updated_at автоматически обновленДобавление кастомных дат:
class Post extends Model
{
protected $casts = [
'published_at' => 'datetime',
'email_verified_at' => 'datetime',
];
}Теперь эти поля автоматически превращаются в объекты Carbon:
$post = Post::find(1);
// Carbon instance
echo $post->published_at->format('d.m.Y H:i');
echo $post->published_at->diffForHumans(); // "2 days ago"
echo $post->published_at->addDays(5);Query по датам:
// Посты за сегодня
$posts = Post::whereDate('created_at', today())->get();
// Посты за последние 7 дней
$posts = Post::where('created_at', '>', now()->subDays(7))->get();
// Посты опубликованные в 2024 году
$posts = Post::whereYear('created_at', 2024)->get();
// Посты за декабрь
$posts = Post::whereMonth('created_at', 12)->get();🔢 Casting (приведение типов)
Laravel автоматически преобразует типы данных:
class Post extends Model
{
protected $casts = [
'is_published' => 'boolean',
'views' => 'integer',
'rating' => 'float',
'metadata' => 'array', // JSON → массив
'config' => 'object', // JSON → объект
'published_at' => 'datetime',
'tags' => 'encrypted:array', // Шифрование + массив
];
}Примеры использования:
$post = Post::find(1);
// Boolean
var_dump($post->is_published); // true/false (не "1"/"0")
// Array (автоматически json_decode)
$post->metadata = ['key' => 'value'];
$post->save();
// В БД: '{"key":"value"}'
$post = Post::find(1);
print_r($post->metadata); // ['key' => 'value']
// Datetime
echo $post->published_at->format('Y-m-d'); // Carbon instanceКастомные касты:
Создай класс:
php artisan make:cast Json<?php
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class Json implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes)
{
return json_decode($value, true);
}
public function set($model, string $key, $value, array $attributes)
{
return json_encode($value);
}
}Использование:
class Post extends Model
{
protected $casts = [
'options' => Json::class,
];
}🎯 Практические сценарии
Сценарий 1: Блог
// Модель Post
class Post extends Model
{
protected $fillable = [
'title', 'slug', 'content', 'author_id', 'status'
];
protected $casts = [
'published_at' => 'datetime',
'is_featured' => 'boolean',
];
protected $appends = ['excerpt'];
// Accessor для отрывка
protected function excerpt(): Attribute
{
return Attribute::make(
get: fn () => substr(strip_tags($this->content), 0, 200) . '...',
);
}
// Mutator для slug
protected function slug(): Attribute
{
return Attribute::make(
set: fn (string $value) => str($value)->slug(),
);
}
}
// Использование
$post = Post::create([
'title' => 'Мой пост',
'slug' => 'Мой пост', // Автоматически: "moi-post"
'content' => '<p>Длинный текст...</p>',
'author_id' => 1,
]);
echo $post->excerpt; // "Длинный текст..."Сценарий 2: E-commerce
class Product extends Model
{
protected $fillable = [
'name', 'price', 'quantity', 'status'
];
protected $casts = [
'price' => 'float',
'quantity' => 'integer',
'is_available' => 'boolean',
'metadata' => 'array',
];
protected $appends = ['is_in_stock', 'formatted_price'];
// Accessor: товар в наличии?
protected function isInStock(): Attribute
{
return Attribute::make(
get: fn () => $this->quantity > 0,
);
}
// Accessor: форматированная цена
protected function formattedPrice(): Attribute
{
return Attribute::make(
get: fn () => '$' . number_format($this->price, 2),
);
}
}
// Использование
$product = Product::find(1);
echo $product->formatted_price; // "$19.99"
if ($product->is_in_stock) {
echo "Есть в наличии";
}
// Увеличить количество просмотров
$product->increment('views');Сценарий 3: Система заказов
class Order extends Model
{
protected $fillable = [
'user_id', 'total', 'status', 'items'
];
protected $casts = [
'total' => 'float',
'items' => 'array',
'paid_at' => 'datetime',
];
// Статусы заказа
const STATUS_PENDING = 'pending';
const STATUS_PAID = 'paid';
const STATUS_SHIPPED = 'shipped';
const STATUS_DELIVERED = 'delivered';
protected function status(): Attribute
{
return Attribute::make(
set: fn (string $value) => strtolower($value),
);
}
}
// Создание заказа
$order = Order::create([
'user_id' => auth()->id(),
'total' => 149.99,
'status' => Order::STATUS_PENDING,
'items' => [
['product_id' => 1, 'quantity' => 2],
['product_id' => 5, 'quantity' => 1],
]
]);
// Обновление статуса
$order->update(['status' => Order::STATUS_PAID, 'paid_at' => now()]);⚠️ Частые ошибки
❌ Ошибка 1: Забыл $fillable
// Модель
class Post extends Model
{
// Нет $fillable!
}
// Попытка создать
Post::create(['title' => 'Test']);
// MassAssignmentExceptionРешение: Добавь $fillable или $guarded.
❌ Ошибка 2: N+1 проблема (будет в следующей главе)
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // Каждая итерация = 1 запрос!
}
// 1 запрос на посты + N запросов на авторов = N+1Решение: Eager Loading (глава 9.3).
❌ Ошибка 3: Сравнение дат без приведения типа
// БД: '2024-01-15 10:00:00' (строка)
if ($post->created_at > '2024-01-01') { // Работает, но ненадежноРешение: Используй $casts:
protected $casts = ['created_at' => 'datetime'];
if ($post->created_at->gt(now()->subDays(7))) { // Carbon методы❌ Ошибка 4: Изменение атрибута без save()
$post = Post::find(1);
$post->title = 'New Title';
// Забыл $post->save()БД не обновится!
🧪 Упражнения
Упражнение 1: Модель статьи (15 минут)
Создай модель Article с полями:
title(string)content(text)views(integer, по умолчанию 0)published_at(datetime, nullable)
Реализуй:
- Accessor
is_published(проверяет, чтоpublished_atне null и <= now) - Mutator для
title(убирает лишние пробелы:trim()) - Метод для увеличения просмотров
Решение
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;
class Article extends Model
{
protected $fillable = [
'title', 'content', 'views', 'published_at'
];
protected $casts = [
'views' => 'integer',
'published_at' => 'datetime',
];
protected $appends = ['is_published'];
// Accessor: опубликована?
protected function isPublished(): Attribute
{
return Attribute::make(
get: fn () => $this->published_at !== null
&& $this->published_at->lte(now()),
);
}
// Mutator: очистка заголовка
protected function title(): Attribute
{
return Attribute::make(
set: fn (string $value) => trim($value),
);
}
// Увеличить просмотры
public function incrementViews()
{
$this->increment('views');
}
}
// Использование
$article = Article::create([
'title' => ' Моя статья ',
'content' => 'Текст...',
'published_at' => now(),
]);
echo $article->title; // "Моя статья" (без пробелов)
echo $article->is_published; // true
$article->incrementViews();
echo $article->views; // 1Упражнение 2: Система пользователей (20 минут)
Создай модель User с:
first_name,last_name(string)email(string, уникальный)password(string)last_login_at(datetime, nullable)
Реализуй:
- Accessor
full_name(возвращает "First Last") - Mutator для
password(автоматически хеширует) - Метод
wasRecentlyOnline()(проверяет, был ли онлайн за последние 5 минут)
Решение
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;
class User extends Model
{
protected $fillable = [
'first_name', 'last_name', 'email', 'password'
];
protected $hidden = ['password']; // Скрыть при toArray()
protected $casts = [
'last_login_at' => 'datetime',
];
protected $appends = ['full_name'];
// Accessor: полное имя
protected function fullName(): Attribute
{
return Attribute::make(
get: fn () => "{$this->first_name} {$this->last_name}",
);
}
// Mutator: хеширование пароля
protected function password(): Attribute
{
return Attribute::make(
set: fn (string $value) => bcrypt($value),
);
}
// Был онлайн недавно?
public function wasRecentlyOnline(): bool
{
if (!$this->last_login_at) {
return false;
}
return $this->last_login_at->gt(now()->subMinutes(5));
}
}
// Использование
$user = User::create([
'first_name' => 'John',
'last_name' => 'Doe',
'email' => 'john@example.com',
'password' => 'secret123', // Автоматически хешируется
]);
echo $user->full_name; // "John Doe"
$user->last_login_at = now();
$user->save();
if ($user->wasRecentlyOnline()) {
echo "Пользователь онлайн";
}Упражнение 3: E-commerce продукты (25 минут)
Создай модель Product с:
name(string)price_cents(integer, хранится в центах)discount_percent(integer, 0-100)stock_quantity(integer)metadata(JSON)
Реализуй:
- Accessor
price(возвращаетprice_cents / 100) - Accessor
final_price(цена с учетом скидки) - Accessor
is_in_stock(проверяетstock_quantity > 0) - Метод
applyDiscount($percent)(обновляетdiscount_percent)
Решение
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;
class Product extends Model
{
protected $fillable = [
'name', 'price_cents', 'discount_percent', 'stock_quantity', 'metadata'
];
protected $casts = [
'price_cents' => 'integer',
'discount_percent' => 'integer',
'stock_quantity' => 'integer',
'metadata' => 'array',
];
protected $appends = ['price', 'final_price', 'is_in_stock'];
// Accessor: цена в долларах
protected function price(): Attribute
{
return Attribute::make(
get: fn () => $this->price_cents / 100,
);
}
// Accessor: финальная цена со скидкой
protected function finalPrice(): Attribute
{
return Attribute::make(
get: function () {
$price = $this->price;
$discount = ($price * $this->discount_percent) / 100;
return round($price - $discount, 2);
},
);
}
// Accessor: в наличии?
protected function isInStock(): Attribute
{
return Attribute::make(
get: fn () => $this->stock_quantity > 0,
);
}
// Применить скидку
public function applyDiscount(int $percent)
{
if ($percent < 0 || $percent > 100) {
throw new \InvalidArgumentException('Скидка должна быть от 0 до 100');
}
$this->update(['discount_percent' => $percent]);
}
}
// Использование
$product = Product::create([
'name' => 'Ноутбук',
'price_cents' => 99999, // $999.99
'discount_percent' => 10,
'stock_quantity' => 5,
'metadata' => ['color' => 'black', 'warranty' => '2 years'],
]);
echo $product->price; // 999.99
echo $product->final_price; // 899.99 (с 10% скидкой)
echo $product->is_in_stock; // true
$product->applyDiscount(20);
echo $product->final_price; // 799.99🎓 Контрольные вопросы
В чем разница между
find()иfindOrFail()?Ответ
`find()` возвращает `null`, если запись не найдена. `findOrFail()` выбрасывает исключение `ModelNotFoundException`, которое Laravel превращает в 404 ответ.Что произойдет, если использовать
create()без настройки$fillableили$guarded?Ответ
Laravel выбросит `MassAssignmentException`. Защита от массового присвоения обязательна для безопасности.Можно ли использовать Accessor для вычисляемого поля, которое зависит от других моделей?
Ответ
Да, но осторожно! Это может вызвать N+1 проблему. Лучше использовать withCount() или appends с Eager Loading.Зачем нужен
$hiddenв модели?Ответ
`$hidden` скрывает поля при сериализации в JSON (например, `password`, `api_token`). Противоположность — `$visible`.Что лучше:
$fillableили$guarded?Ответ
`$fillable` (белый список) безопаснее, т.к. явно указываешь разрешенные поля. `$guarded` (черный список) рискованнее — можно забыть добавить новое защищенное поле.
🚀 Что дальше?
Ты освоил базовую работу с Eloquent! В следующих главах:
- Глава 9.2: Связи между моделями (hasMany, belongsTo, manyToMany)
- Глава 9.3: Решение N+1 проблемы через Eager Loading
- Глава 9.4: Query Scopes для переиспользуемых запросов
Eloquent — это мощнейший инструмент, который делает работу с БД интуитивной и безопасной. Продолжай практиковаться! 🎯