Создание кастов моделей в Laravel

В статье мы рассмотрим, что такое мутаторы, аксессоры и касты и как их использовать в приложении Laravel. Также рассмотрим, как создавать и тестировать касты в 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 используется для:

Например, есть модель 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 сначала проверяется, не является ли значение 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:

Напишем несколько тестов, и рассмотрим, как они устроены:

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.

Комментарии


Дополнительные материалы

Предыдущая Статья

Расширенное использование attr() в CSS

Следующая Статья

Значения по умолчанию с оператором нулевого слияния