Создание кастов моделей в Laravel
Введение
Касты, мутаторы и аксессоры моделей — это то, с чем приходится часто работать при создании приложений Laravel. Они играют важную роль во взаимодействии с моделями и данными, лежащими в их основе.
Laravel поставляется с множеством удобных встроенных кастов, обеспечивающих большинство сценариев использования. Но могут возникнуть ситуации, когда потребуется создать собственные касты для хранения и получения данных определённым образом.
В статье мы рассмотрим, что такое касты, мутаторы и аксессоры моделей в Laravel. Затем рассмотрим, как можно создавать собственные классы кастов и писать для них тесты.
К концу статьи вы должны чувствовать себя достаточно уверенно, приступая к использованию собственных кастов Laravel в своих приложениях.
Что такое мутаторы и аксессоры в Laravel
Мутаторы и аксессоры — две ключевые концепции в Laravel, позволяющие манипулировать данными модели, когда они хранятся или извлекаются из базы данных.
При сохранении или обновлении модели в базе данных мутаторы преобразуют данные в формат, пригодный для хранения в базе данных.
При получении модели из базы данных аксессоры преобразуют данные в формат, пригодный для использования в приложении.
По мутаторам и аксессорам уже есть отличная документация, поэтому не будем слишком углубляться в их изучение в рамках этой статьи. Но я дам краткий обзор того, что они представляют собой и как их можно использовать в своих приложениях.
Аксессоры в моделях Laravel
Представим, что у нас есть модель App\Models\User
, имеющая поля first_name
и last_name
. Вместо того чтобы конкатенировать эти поля каждый раз, когда необходимо получить полное имя, можно использовать аксессор, делающий это за нас.
Рассмотрим модель App\Models\User
:
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Foundation\Auth\User as Authenticatable;
final class User extends Authenticatable
{
// ...
protected function fullName(): Attribute
{
return Attribute::get(
fn(): string => $this->first_name.' '.$this->last_name,
);
}
}
Мы добавили метод fullName
и указали, что он является аксессором, используя метод Illuminate\Database\Eloquent\Casts\Attribute::get
, возвращающий экземпляр Illuminate\Database\Eloquent\Casts\Attribute
. Тем самым мы указали, что при каждой попытке доступа к атрибуту модели full_name
будет возвращаться результат замыкания.
Например, представим, что есть пользователь с именем first_name
"John" и фамилией last_name
"Smith". Используя аксессор, можно получить доступ к атрибуту full_name
следующим образом:
use App\Models\User;
$user = User::find(1);
$fullName = $user->full_name; // "John Smith"
Мутаторы в моделях Laravel
Подобно аксессорам, также можно определить мутаторы для моделей Laravel. Эти мутаторы используются для манипулирования данными перед их сохранением в базе данных.
Они удобны для таких сценариев, как обеспечение хранения данных в согласованном формате. Например, может понадобиться преобразовать все адреса электронной почты в строчные буквы перед сохранением их в базе данных.
Давайте посмотрим, как это можно сделать в модели App\Models\User
:
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Foundation\Auth\User as Authenticatable;
final class User extends Authenticatable
{
// ...
protected function email(): Attribute
{
return Attribute::set(
fn(string $value): string => strtolower($value),
);
}
}
В этом примере мы добавляем метод email
в модель App\Models\User
. Затем указываем, что он является мутатором, используя метод Illuminate\Database\Eloquent\Casts\Attribute::set
, возвращающий экземпляр Illuminate\Database\Eloquent\Casts\Attribute
. Тем самым мы указали, что при каждой попытке сохранить значение в атрибуте email
модели, в базу данных будет сохраняться результат замыкания.
Предположим, мы хотим обновить адрес электронной почты пользователя с ID 1
на HELLO@EXAMPLE.COM
:
use App\Models\User;
$user = User::find(1);
$user->update(['email' => 'HELLO@EXAMPLE.COM']);
$user->email; // hello@example.com
Использование аксессора и мутатора для одного и того же поля
В наших предыдущих примерах мы определяли аксессор или мутатор для отдельного поля. Но могут возникнуть ситуации, когда потребуется использовать и аксессор, и мутатор для одного и того же поля.
Рассмотрим пример модели App\Models\User
из документации Laravel:
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
/**
* Interact with the user's first name.
*/
protected function firstName(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucfirst($value),
set: fn (string $value) => strtolower($value),
);
}
}
В приведённом примере мы видим, что в модели пользователя есть аксессор и мутатор для поля first_name
. Аксессор (определяемый замыканием, переданным параметру get
) при получении имени из базы данных преобразует первый символ имени в заглавный, а мутатор (определяемый замыканием, переданным параметру set
) перед сохранением в базе данных преобразует имя в строчные символы.
Что такое касты в Laravel
Бывают случаи, когда логика, используемая в мутаторах и аксессорах, сложна или предназначена для повторного использования в нескольких моделях. В таких случаях стоит подумать об использовании "каста".
Касты моделей в Laravel — это классы, определяющие логику хранения или получения атрибутов модели из базы данных. Они могут быть использованы для обработки сложной логики, которую было бы трудно или сложно реализовать в мутаторе или аксессоре.
На момент написания статьи Laravel поставляется с 21 кастом, которые можно использовать из коробки.
array
AsStringable::class
boolean
collection
date
datetime
immutable_date
immutable_datetime
decimal:<precision>
double
encrypted
encrypted:array
encrypted:collection
encrypted:object
float
hashed
integer
object
real
string
timestamp
Чтобы понять, как использовать касты в моделях Laravel, давайте рассмотрим пример класса App\Models\User
, а затем обсудим, как он устроен:
declare(strict_types=1);
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
final class User extends Authenticatable
{
// ...
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
// ...
];
}
}
В приведённом примере используется метод casts
, определяющий, какие атрибуты модели должны использовать мутаторы и аксессоры. В данном случае было указано, что атрибут email_verified_at
должен использовать каст datetime
.
Каст datetime
используется для:
- Преобразования поля
email_verified_at
в формат, пригодный для хранения в базе данных. - Преобразования поля
email_verified_at
обратно в экземплярCarbon\Carbon
при извлечении его из базы данных (при условии, что не изменено поведение по умолчанию относительно того, к какому классу следует преобразовывать дату).
Например, есть модель App\Models\User
со значением email_verified_at
, хранящимся в базе данных как 2025-03-13 12:00:00
. Применив каст datetime
, это значение будет преобразовано в экземпляр Carbon\Carbon
, поле получения из базы данных. Это означает, что с этим значением можно взаимодействовать и делать такие вещи, как $user->email_verified_at->addDays(2)
, $user->email_verified_at->format('Y-m-d')
и т.д.
Что такое собственные касты в Laravel
Касты, поставляемые с Laravel, скорее всего, покроют большинство возможных ситуаций. Однако могут возникнуть ситуации, когда необходимо создать собственные касты для хранения и получения данных определённым образом.
Например, можно хранить объект в формате JSON в одном столбце базы данных. Затем, при получении модели, можно преобразовать JSON обратно в объект (например, объект-значение), чтобы с ним можно было взаимодействовать более осмысленно.
Как создавать касты в Laravel
Чтобы понять, как использовать собственные касты в моделях, создадим каст, позволяющий хранить простой объект значения (VO) в одном столбце базы данных. Затем продемонстрируем, как применить каст к модели.
Обратите внимание, можно возразить, что эти данные лучше хранить в отдельных столбцах или в отдельной таблице, а не хранить в виде JSON в одном столбце. Но я решил упростить пример, чтобы сосредоточиться на том, как использовать собственные касты для хранения и получения данных, а не на сложности хранимых данных.
Для начала давайте определим, чего именно мы хотим добиться с помощью логики кастинга. Предположим, что мы создаём веб-приложение, предоставляющее пользователю возможность переключать уведомления, которые он должен получать. Существует три типа уведомлений: маркетинговые, объявления и системные. Мы будем хранить предпочтения пользователя в JSON-столбце notification_preferences
в таблице users
, чтобы их было легко найти и обновить.

Для начала создадим класс App\ValueObjects\Notifications
для представления предпочтений пользователя в отношении уведомлений:
declare(strict_types=1);
namespace App\ValueObjects\Notifications;
final class Preferences implements Castable
{
public function __construct(
public bool $marketing,
public bool $announcements,
public bool $system,
) {}
}
Как видите, у класса есть 3 свойства, каждое представляет собой отдельный тип уведомления.
Затем создадим класс cast
, отвечающий за преобразование объекта-значения. Для этого выполним в корне проекта команду создающую новый класс cast
:
php artisan make:cast NotificationPreferences
Теперь должен появиться новый класс App\Casts\NotificationPreferences
. Обновим cast
для преобразования объекта значения в JSON и обратно, а затем рассмотрим, как всё это делается:
declare(strict_types=1);
namespace App\Casts;
use App\ValueObjects\Notifications\Preferences;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use JsonException;
final readonly class NotificationPreferences implements CastsAttributes
{
/**
* Преобразование указанного значения.
*
* @param array<string, mixed> $attributes
* @throws JsonException
*/
public function get(
Model $model,
string $key,
mixed $value,
array $attributes,
): ?Preferences {
// Если нет предпочтений уведомлений, возвращается null.
if (!$value) {
return null;
}
// Декодирование строки JSON в ассоциативный массив.
$preferencesArray = json_decode(
json: $value,
associative: true,
depth: 512,
flags: JSON_THROW_ON_ERROR
);
// Создаёт и возвращает экземпляр Preferences из декодированного массива.
return new Preferences(
$preferencesArray['marketing'],
$preferencesArray['announcements'],
$preferencesArray['system'],
);
}
/**
* Подготовка заданного значения к хранению.
*
* @param array<string, mixed> $attributes
* @throws JsonException
*/
public function set(
Model $model,
string $key,
mixed $value,
array $attributes
): ?string {
// Если нет предпочтений уведомлений, возвращается null.
if (!$value) {
return null;
}
// Убедитесь, что значение является экземпляром Preferences,
// чтобы мы работали с правильными данными.
if (!$value instanceof Preferences) {
throw new \InvalidArgumentException(
'The given value is not an instance of ' . Preferences::class
);
}
// Возвращает JSON-представление экземпляра Preferences.
return json_encode(
$value,
JSON_THROW_ON_ERROR,
);
}
}
В приведённом выше коде есть 2 метода:
get
— Метод отвечает за преобразование строки JSON, хранящейся в базе данных, обратно в экземплярApp\ValueObjects\Notifications\Preferences
.set
— Метод отвечает за преобразование экземпляраApp\ValueObjects\Notifications\Preferences
обратно в строку JSON, для хранения её в базе данных.
В методе get
сначала проверяется, не является ли значение null
(то есть нет сохранённых предпочтений уведомлений). Если это так, то возвращается null
; однако в реальном приложении может потребоваться вернуть экземпляр App\ValueObjects\Notifications\Preferences
по умолчанию со всеми значениями, установленными на false
. После этого декодируем JSON в ассоциативный массив и используем его для создания и возвращения экземпляра App\ValueObjects\Notifications\Preferences
.
В методе set
сначала проверяем, не пытаемся ли сохранить null
(это означает, что нет предпочтений уведомлений для хранения). Если это так, возвращается null
, то есть столбец будет задан в базе данных как null
. Как и в случае с методом get
, можно хранить здесь не просто null
, а какие-то разумные значения по умолчанию. Затем проверяем, что значение является экземпляром App\ValueObjects\Notifications\Preferences
, чтобы убедиться, что работаем с правильным типом данных. Если значение не является экземпляром App\ValueObjects\Notifications\Preferences
, мы выбрасываем исключение. Наконец, возвращаем JSON-представление экземпляра App\ValueObjects\Notifications\Preferences
, которое затем будет сохранено в базе данных.
Теперь применим каст к модели App\Models\User
. Это можно сделать, обновив метод casts
в модели следующим образом:
declare(strict_types=1);
namespace App\Models;
use App\Casts\NotificationPreferences;
use Illuminate\Foundation\Auth\User as Authenticatable;
final class User extends Authenticatable
{
// ...
/**
* Получение атрибутов, которые должны быть преобразованы.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
// ...
'notification_preferences' => NotificationPreferences::class,
];
}
}
В примере выше было указано, что атрибут notification_preferences
должен использовать каст App\Casts\NotificationPreferences
.
Это означает, что теперь можно хранить предпочтения уведомлений пользователя следующим образом:
use App\Models\User;
use App\ValueObjects\Notifications\Preferences;
// Получение пользователя.
$user = User::find(1);
// Создание объекта значения предпочтений уведомлений.
// В реальном приложении значения могут быть получены из HTTP-запроса.
$preferences = new Preferences(
marketing: true,
announcements: false,
system: true
);
// Сохранение предпочтений уведомлений.
$user->update([
'notification_preferences' => $preferences,
]);
Выполнение приведённого выше кода приведёт к тому, что необработанное значение будет сохранено в столбце notification_preferences
таблицы users
в виде следующей JSON-строки:
{"marketing":true,"announcements":false,"system":true}
Затем можно прочитать предпочтения уведомлений пользователя (которые будут представлять экземпляр App\ValueObjects\Notifications\Preferences
) следующим образом:
$user->notification_preferences->marketing; // true
$user->notification_preferences->announcements; // false
$user->notification_preferences->system; // true
Как уже говорилось выше, создание собственных кастов в Laravel может быть очень удобным, особенно когда необходимо автоматически преобразовывать сложные типы данных для хранения в базе данных.
Как тестировать касты в Laravel
Важно написать тесты, чтобы гарантировать, что касты работают так, как ожидается.
Существует несколько вариантов тестирования кастов. Можно написать изолированные модульные тесты для обоих методов класса cast, а можно написать функциональные тесты, чтобы убедиться, что атрибуты модели передаются правильно. Оба варианта верны и могут использоваться вместе, обеспечивая хороший уровень уверенности. Однако во многих случаях я предпочитаю писать функциональные тесты, чтобы можно было проверить преобразование в контексте экземпляра модели. Впрочем, это личные предпочтения, используйте то, что лучше подходит вам и вашем проекту.
Необходимо протестировать следующие сценарии для каста App\Casts\NotificationPreferences
:
- Пользовательские предпочтения уведомлений правильно хранятся в базе данных.
- Пользовательские предпочтения уведомлений корректно извлекаются из базы данных.
- Если нет предпочтений уведомлений, при извлечении значения из базы данных возвращается
null
. - При попытке сохранить недопустимое значение возникает исключение.
Напишем несколько тестов, и рассмотрим, как они устроены:
declare(strict_types=1);
namespace Tests\Feature\Models\User;
use App\Models\User;
use App\ValueObjects\Notifications\Preferences;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\DB;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
final class NotificationPreferencesTest extends TestCase
{
use LazilyRefreshDatabase;
#[Test]
public function notification_preferences_can_be_stored_correctly(): void
{
// Создание пользователя с предпочтениями уведомлений.
$user = User::factory()->create([
'notification_preferences' => new Preferences(
marketing: true,
announcements: false,
system: false,
),
]);
// Утверждение, что пользовательские предпочтения уведомлений
// правильно сохранены в базе данных.
$this->assertDatabaseHas('users', [
'id' => $user->id,
'notification_preferences' => '{"marketing":true,"announcements":false,"system":false}',
]);
}
#[Test]
public function notification_preferences_can_be_retrieved_correctly(): void
{
// Создание пользователя без предпочтений уведомлений.
$user = User::factory()->create([
'notification_preferences' => null,
]);
// Ручное обновление пользовательских предпочтений в области уведомлений.
// Это делается, чтобы избежать взаимодействия с кастом.
DB::table('users')
->where('id', $user->id)
->update(['notification_preferences' => '{"marketing":true,"announcements":false,"system":false}']);
// Обновление модели пользователя, чтобы убедиться, что работаем с последними данными.
$user->refresh();
// Тест корректности получения пользовательских предпочтений уведомлений.
$this->assertInstanceOf(Preferences::class, $user->notification_preferences);
$this->assertTrue($user->notification_preferences->marketing);
$this->assertFalse($user->notification_preferences->announcements);
$this->assertFalse($user->notification_preferences->system);
}
#[Test]
public function null_is_returned_if_fetching_empty_preferences(): void
{
// Создание пользователя без предпочтений уведомлений.
$user = User::factory()->create([
'notification_preferences' => null,
]);
$user->refresh();
// Утверждение, что при получении пустых предпочтений возвращается null.
$this->assertNull($user->notification_preferences);
}
#[Test]
public function exception_is_thrown_if_trying_to_store_invalid_preferences(): void
{
// Указываем, что в тесте ожидается возникновение исключения.
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage(
'The given value is not an instance of App\ValueObjects\Notifications\Preferences'
);
// Попытка создать пользователя с недопустимыми предпочтениями уведомлений.
// Это должно вызвать выброс исключения.
User::factory()->create([
'notification_preferences' => 'INVALID',
]);
}
}
Благодаря наличию вышеуказанных тестов мы можем быть уверены, что при использовании в контексте модели App\Models\User
каст работает так, как ожидается.
Заключение
Мы рассмотрели, что такое касты, мутаторы и аксессоры моделей в Laravel. Рассказали о том, как создавать свои собственные касты в Laravel и писать для них тесты, чтобы убедиться, что они работают, как ожидается.
Теперь вы должны чувствовать себя достаточно уверенно, чтобы начать использовать касты в своих приложениях Laravel.