Продвинутое использование Eloquent в Laravel
В этом руководстве мы углубимся в ORM (объектно-реляционное преобразование), который использует Laravel — Eloquent.
Введение
Мы рассмотрим некоторые не часто используемые возможности Laravel Eloquent и покажем, как это может облегчить ваш процесс разработки.
Laravel делает создание PHP-приложений простым и удобным. Он разработан для предоставления методов работы с основными функциями, которые нужны вашему приложению — взаимодействие с базой данных, маршрутизация, сессии, кеширование и многое другое. Он имеет поставщиков услуг (service providers), которые позволяют вам загружать пользовательские конфигурации и расширять возможности Laravel в соответствии с вашими потребностями.
Требования
Чтобы понять это руководство, вы должны:
- Иметь практические знания PHP.
- Иметь базовые или средние знания фреймворка Laravel.
- Быть знакомым с Eloquent и его синтаксисом.
- Пример рабочего проекта Laravel для практики не является обязательным, но рекомендуется.
Что такое Eloquent
Eloquent ORM, включённый в Laravel, предоставляет красивую и простую реализацию паттерна ActiveRecord (в стиле Ruby on Rails) для работы с вашей базой данных. Каждая модель Eloquent создаёт обёртку вокруг связанной с ней таблицы базы данных. Это делает каждый экземпляр модели Eloquent представлением строки из таблицы.
Eloquent предоставляет методы доступа и свойства, соответствующие каждой ячейке таблицы в строке. Кроме того, он предоставляет методы для установления отношений с другими моделями и позволяет вам получать доступ к этим моделям через эти отношения.
Обновление экземпляра модели Eloquent обновляет запись в базе данных, на которую он отображается, а удаление также удаляет запись.
Accessors (Аксессоры)
Accessors позволяют форматировать значение, полученное из базы данных, определённым способом. Например, ваше приложение выдаёт коды отслеживания для заказов, которые имеют префикс Acme_. Вы можете создать строковое поле и добавить инициалы вашей компании ко всем сгенерированным кодам перед сохранением. Но это может нарушить нормализацию базы данных.
Чтобы убедиться, что при возврате кодов отслеживания пользователям код следует одному и тому же формату, вы можете определить аксессор для доступа к этим кодам следующим образом:
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
protected $fillable = [
'product_id',
'user_id',
'tracking_code',
'total_cost',
];
public function getTrackingCodeAttribute($value)
{
return "Acme_" . $value;
}
// [...]
}
Выше мы определили аксессор для tracking_codes. Формат для этого — getFooAttribute, где Foo — это имя свойства модели, к которому вы хотите получить доступ. Имена всегда должны быть в camelCase.
Чтобы использовать его, сделайте следующее:
$order = \App\Order::find(1);
$order->tracking_code;
Ваш акцессор будет вызываться каждый раз, когда вы пытаетесь получить код отслеживания, и он добавит префикс Acme_ к полученному коду.
Вы также можете использовать акcессоры для вычисляемых значений:
public function getTotalCostAttribute()
{
return $this->quantity * $this->unit_price;
}
И вы получаете вычисляемое свойство следующим образом:
$order = \App\Order::find(1);
$order->total_cost;
Mutators (Мутаторы)
Мутаторы похожи на аксессоры, но работают в противоположном направлении. Они используются для изменения данных, которые сохраняются в базу данных, в отличие от того, как они извлекаются. Мутаторы вызываются, когда вы присваиваете значение свойству модели, на котором определён мутатор.
Вы можете определить мутаторы в модели следующим образом:
public function setTrackingCodeAttribute($value)
{
return $this->attributes['tracking_code'] = str_replace('Acme_', '', $value);
}
Этот мутатор будет вызываться каждый раз, когда вы присваиваете значение свойству модели tracking_code, вот так:
$order = \App\Order::find(1);
$order->total_cost = 24451;
Как и в случае с акcессорами, свойства в snake_case будут преобразованы в camelCase. Например, total_cost становится TotalCost, а мутатор будет называться setTotalCostAttribute.
Eager Loading (Жадная загрузка)
Когда вы получаете доступ к модели Eloquent, отношения для этой модели по умолчанию не загружаются. Laravel лениво загружает отношения, когда вы пытаетесь получить к ним доступ. Это хорошо, так как экономит память, используемую для хранения всех этих данных. Однако что, если вы знаете, что вам нужны все отношения? Тогда на помощь приходит Eager Loading (жадная загрузка).
Ещё одно преимущество жадной загрузки — это решение проблемы N+1. Рассмотрим этот пример кода:
$orders = \App\Order::all();
foreach($orders as $order) {
echo $order->user->first_name;
}
В коде выше мы получаем все заказы из хранилища данных, а затем в цикле пытаемся получить доступ к свойству first_name в отношении user. Хотя этот код будет работать, у нас возникнет проблема. Поскольку отношения загружаются лениво каждый раз, когда выполняется цикл, будет выполнен новый запрос для получения отношения user.
Что происходит, когда у вас сто заказов? Ваше приложение выполнит 101 запрос, чтобы получить имена всех пользователей, связанных с заказами. Первый запрос получает все заказы, а каждый следующий получает пользователей для каждого заказа. Вы видите, что ваша операция имеет временную сложность O(n).
Мы сокращаем время получения всех заказов и пользователей до сложности O(1), используя жадную загрузку всех пользователей:
$orders = \App\Order::with('user')->get();
foreach($orders as $order){
echo $order->user->first_name;
}
Приведённый выше код выполнит два запроса к базе данных. Один для получения всех заказов и второй для получения всех пользователей, связанных с заказами. Если у вас есть 1000 записей заказов, ваше приложение всё равно выполнит только два запроса.
Поскольку нам нужно только имя пользователя, мы можем поступить так:
$orders = \App\Order::with('user:id,name')->get();
foreach($orders as $order){
echo $order->user->first_name;
}
При жадной загрузке свойств отношения вы должны включать столбец
id.
Предположим, ваше приложение имеет модель Coupon и вы хотите получить купоны пользователя вместе с информацией о пользователе. Вы делаете следующее:
$orders = \App\Order::with('user.coupons')->get();
foreach($orders as $order){
echo $order->user->coupons;
}
Мы также можем загружать несколько отношений одновременно так:
$orders = \App\Order::with(['user', 'product'])->get();
foreach($orders as $order){
echo $order->user->first_name;
}
Жадная загрузка очень полезена, если у вас есть API и вам нужно вернуть данные через конечную точку API. Это предоставит вам весь набор данных, который вам нужен, сохраняя вас от необходимости делать несколько запросов к API для получения данных.
Eloquent Collections (Коллекции Eloquent)
Всякий раз, когда вы запускаете запрос модели для возврата множества результатов, вы получаете объект Eloquent Collection. Этот объект коллекции расширяет базовую коллекцию Laravel. Это означает, что он наследует десятки методов, используемых для удобной работы с лежащим в основе массивом моделей Eloquent.
Коллекции
Eloquentнеизменяемы, поэтому каждая операция, которую вы выполняете над ними, возвращает новый экземпляр коллекцииEloquent. Для методовpluck,keys,zip,collapse,flattenиflipвозвращается экземпляр базовой коллекции. Всегда присваивайте результат переменной и используйте эту переменную.
Например, предположим, вы хотите получить только заказы, которые были доставлены, и назначить им текст статуса доставки:
$orders = \App\Order::all()->reject(function ($order) {
return $order->is_delivered == false;
})
->map(function ($order) {
$order->status = "Fulfilled";
return $order;
});
Переменная $orders будет содержать только заказы, отмеченные как доставленные.
Скажем, вы хотите вернуть как доставленные, так и недоставленные заказы и сгруппировать их как delivered = [], undelivered = []. Вы можете сделать что-то вроде этого:
$orders = \App\Order::all()->map(function ($order) {
$order->status = $order->is_delivered ? "delivered" : "undelivered";
return $order;
})->mapToGroups(function ($item, $key) {
return [$item['status'] => $item];
});
Если вы хотите, чтобы продукт и цена были парой key-value, вы можете сделать что-то вроде этого:
$products = \App\Product::all()->mapWithKeys(function ($item) {
return [$item['name'] => $item['price']];
});
foreach($products as $key => $value) {
echo "{$key}: {$value}";
}
Что если у вас есть переиспользуемый контейнер div, который содержит только четыре элемента? Вы можете вернуть данные порциями по четыре вот так:
$products = \App\Product::all()->chunk(4);
И в вашем шаблоне Blade вы делаете:
@foreach ($products as $chunk)
<div class="item-container">
@foreach ($chunk as $product)
<div class="col-xs-3">{{ $product->name }}: {{ $product->price }}</div>
@endforeach
</div>
@endforeach
Что если вы хотите получить все заказы, доставленные в конкретный день? Вы можете сделать что-то вроде этого:
public function orderOnDate(Request $request){
$orders = \App\Order::all()->filter(function ($order) use ($request) {
return $order->delivered_at == $request->date;
});
}
Этот метод отфильтрует все заказы, которые не были выполнены в этот конкретный день.
Collection— это одна из самых мощных функций Laravel, и вам, однозначно, стоит её изучить. Вы можете узнать больше о коллекциях и увидеть больше полезных методов для вашего приложения в официальной документации.
Eloquent Model Events (События моделей Eloquent)
Модели Eloquent запускают несколько событий, которые позволяют вам перехватывать различные части жизненного цикла модели: retrieved, creating, created, updating, updated, saving, saved, deleting, deleted, restoring и restored. Каждый раз, когда происходит событие, вы можете выполнить код или выполнить действие.
Для отслеживания и реагирования на события вы можете использовать класс Observer для модели, на которой вы хотите перехватывать события. Классы Observers имеют имена методов, которые отражают события Eloquent, которые вы хотите прослушивать. Каждый из этих методов получает модель в качестве единственного аргумента.
Чтобы посмотреть, как работают Observers, напишем класс Observers для вымышленной модели User. Мы будем проверять, есть ли у пользователя приглашение при создании учётной записи и помечать реферера. Мы также проверим, есть ли какой-либо ожидающий заказ перед удалением учётной записи пользователя.
Сначала создайте директорию app/observers и там создайте новый PHP-файл UserObserver.php. В файл вставьте следующий код:
<?php
namespace App\Observers;
use App\User;
use App\Invites;
use Illuminate\Support\Facades\Mail;
class UserObserver
{
public function creating(User $user)
{
$invite = Invites::where('email',$user->email)->first();
if ($invite) {
$user->referrer = $invite->user_id;
}
}
public function created(User $user)
{
Mail::raw("Some custom message here", function ($message){
$message->to($user->email)->subject("Please confirm your account");
});
}
public function deleting(User $user)
{
$user->orders->map(function($order) {
if ($order->is_delivered == false) {
abort(403,'You cannot delete your account yet');
}
});
}
public function deleted(User $user)
{
Mail::raw("Some custom message here", function ($message){
$message->to($user->email)->subject("We hate to see you go");
});
}
}
В observer выше мы определили операции, которые хотим запустить при срабатывании определённых событий. Теперь мы можем создать наш контроллер и не беспокоиться о том, что контроллер будет перегружен логикой, которая его не касается.
Вот как будет выглядеть контроллер:
<?php
use App\User;
class UserController extends Controller
public function store(Request $request)
{
User::create($request->all());
return back();
}
public function delete(User $user)
{
$user->delete();
return back();
}
}
Как видите, контроллер остаётся компактным и управляемым.
Чтобы зарегистрировать наш UserObserver, используйте метод observe на модели User. Вы можете зарегистрировать observers в методе boot одного из ваших service providers.
В этом примере мы регистрируем observer в нашем AppServiceProvider:
<?php
namespace App\Providers;
use App\User;
use App\Observers\UserObserver;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot()
{
User::observe(UserObserver::class);
}
}
И всё!
Scopes (Области видимости)
Scopes позволяют добавлять ограничения ко всем запросам для данной модели. Laravel имеет два типа scopes: глобальные и локальные.
Глобальные области видимости применяются каждый раз, когда вы вызываете модель по умолчанию. Локальные области видимости позволяют вам определять распространённые наборы ограничений, которые вы можете повторно использовать во всём вашем приложении. Вы используете локальные scopes только когда вам это нужно, и они не будут применяться каждый раз, когда вы вызываете модель.
Примером глобального scope является softDeletes, который фильтрует ваши запросы, чтобы удалить записи, которые вы ранее отметили как удалённые. Место, где вы можете захотеть использовать глобальный scope, — это функция, подобная тикетам, где вы получаете только тикеты, которые не закрыты. Давайте рассмотрим, как определить этот глобальный scope.
Вы можете написать scope как отдельный класс или определить его как динамический scope в методе boot вашей модели.
Отдельная глобальная область видимости
Мы можем создать директорию app/Scopes в нашем приложении Laravel и в этой директории создать класс scope следующим образом:
<?php
namespace App\Scopes;
use Illuminate\Database\Eloquent\{Scope, Model, Builder};
class ClosedScope implements Scope
{
public function apply(Builder $builder, Model $model)
{
$builder->where('is_closed', '=', false);
}
}
Чтобы применить его к вымышленной модели Ticket, вы можете сделать что-то вроде этого:
<?php
namespace App;
use use App\Scopes\ClosedScope;
use Illuminate\Database\Eloquent\Builder;
class Ticket extends Model
{
protected static function boot()
{
parent::boot();
static::addGlobalScope(new ClosedScope);
}
}
Динамическая глобальная область видимости
Чтобы создать динамический глобальный scope, вы можете просто написать логику непосредственно в методе boot вашей модели Ticket следующим образом:
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
class Ticket extends Model
{
protected static function boot()
{
parent::boot();
static::addGlobalScope('closed', function (Builder $builder) {
$builder->where('is_closed', '=', false);
});
}
}
Оба определения scope достигнут одного и того же результата. Они будут возвращать только тикеты, которые не были закрыты. Чтобы использовать их в вашем контроллере, сделайте следующее:
# Показать все незакрытые тикеты
$tickets = \App\Ticket::all();
# Без отдельного ClosedScope
$tickets = \App\Ticket::withoutGlobalScope(\App\Scopes\ClosedScope::class)->get();
# Без динамического scope на "is_closed"
$tickets = \App\Ticket::withoutGlobalScope('closed')->get();
# Без всех scopes
$tickets = \App\Ticket::withoutGlobalScopes()->get();
# Без нескольких конкретных scopes
$tickets = \App\Ticket::withoutGlobalScopes([FirstScope::class, SecondScope::class])->get();
Это всё про глобальные scopes.
Если вы хотите определить то же ограничение для ваших запросов как локальный scope, вы можете сделать следующее внутри модели:
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
class Ticket extends Model
{
public function scopeOpen($query)
{
return $query->where('is_closed', false);
}
public function scopeIsClosed($query, $state)
{
return $query->where('is_closed', $state);
}
}
Выше мы определили локальный scope на модели Ticket. scopeOpen будет возвращать только открытые тикеты, а scopeIsClosed делает противоположное. Однако, вы можете передать аргумент scopeIsClosed для возврата тикетов, которые либо открыты, либо закрыты.
Чтобы использовать его, сделайте следующее:
# Получить только открытые тикеты
$tickets = \App\Ticket::open()->get();
# Получить только открытые тикеты
$tickets = \App\Ticket::isClosed('false')->get();
# Получить только закрытые тикеты
$tickets = \App\Ticket::isClosed('true')->get();
# Получить все тикеты
$tickets = \App\Ticket::all();
Вызываемые области видимости
Tapable scopes (вызываемые области видимости) — это современный подход к созданию переиспользуемых ограничений запросов, который использует метод tap() и invokable классы. Это предоставляет более чистый и более объектно-ориентированный способ манипулирования запросами Eloquent по сравнению с традиционными локальными и глобальными scopes.
Основная идея заключается в том, чтобы создать класс, который реализует интерфейс __invoke, и передать его в метод tap(). Это позволяет вам инкапсулировать логику запроса в отдельный класс и повторно использовать её в различных местах вашего приложения.
Базовый пример
class MatchingEmail
{
public function __construct(
protected readonly string $email,
) {}
public function __invoke(Builder $query): void
{
$query->where('email', $this->email);
}
}
// Использование
User::query()
->tap(new MatchingEmail('taylor@laravel.com'))
->get();
В примере выше мы создали invokable класс MatchingEmail, который получает email в конструктор и применяет условие where к запросу. Затем мы используем этот класс с методом tap().
Эквивалент с использованием закрытия (callback функции) выглядит так:
User::query()
->tap(function (Builder $query) {
$query->where('email', 'taylor@laravel.com');
})
->get();
Однако invokable класс предпочтителен, потому что он:
• Более переиспользуем — вы можете создать один класс и использовать его во многих местах
* Более тестируем — логика инкапсулирована в отдельный класс, который легко тестировать
* Более читаем — название класса ясно описывает, что оно делает
* Могу быть параметризованы — вы можете передавать параметры в конструктор
Множественные Tapable scopes
Одна из самых мощных возможностей Tapable scopes — это способность комбинировать несколько областей видимости в одном запросе:
class ActiveUser
{
public function __invoke(Builder $query): void
{
$query->where('is_active', true);
}
}
class MatchingEmail
{
public function __construct(
protected readonly string $email,
) {}
public function __invoke(Builder $query): void
{
$query->where('email', $this->email);
}
}
// Использование множественных scopes
User::query()
->tap(new MatchingEmail('taylor@laravel.com'))
->tap(new ActiveUser())
->get();
Этот подход позволяет легко комбинировать различные критерии фильтрации без необходимости создавать сложные условия в одном методе.
Преимущества Tapable scopes перед традиционными scopes
В отличие от локальных и глобальных scopes, Tapable scopes обладают несколькими преимуществами:
1. Явность — вы ясно видите, какие фильтры применяются к запросу
2. Параметризация — легко передавать различные параметры без магических методов
3. Модульность — каждая scope представляет собой самостоятельный класс
4. Тестируемость — проще писать модульные тесты для каждой scope
5. Ясная сигнатура — вы точно знаете, какой интерфейс реализует scope
Комплексный пример с несколькими параметрами
class FilterByDateRange
{
public function __construct(
protected readonly Carbon $startDate,
protected readonly Carbon $endDate,
) {}
public function __invoke(Builder $query): void
{
$query->whereBetween('created_at', [
$this->startDate,
$this->endDate
]);
}
}
class FilterByStatus
{
public function __construct(
protected readonly string $status,
) {}
public function __invoke(Builder $query): void
{
$query->where('status', $this->status);
}
}
// Использование в контроллере
$orders = Order::query()
->tap(new FilterByStatus('completed'))
->tap(new FilterByDateRange(
now()->subMonth(),
now()
))
->get();
Этот пример демонстрирует, как можно создать множество переиспользуемых фильтров и комбинировать их в различных комбинациях для создания сложных запросов без дублирования кода.
Выбор scope зависит от потребностей вашего приложения.
Заключение
В этом руководстве мы рассмотрели, насколько полезен Eloquent в Laravel и как вы можете его использовать для улучшения вашего кода. Eloquent могут показаться запутанными в первый раз, когда вы с ними столкнётесь, но как только вы начнёте их использовать на практике, это может сэкономить вам много времени и сделать ваш код более читаемым и поддерживаемым.

