Мутаторы и касты в Eloquent: типичные ошибки и как их избежать
Касты и аксессоры в Eloquent удобны ровно до момента, когда в базе оказывается null там, где ждали объект, а json-поле молча перезаписывает изменения. Разбираем, где эти абстракции ломаются.

Вы добавили protected $casts = ['options' => 'array'], а потом обнаружили, что $model->options['key'] = 'value' не сохраняется в базу. Или аксессор возвращает объект Money, но при массовой вставке через insert() внезапно получает сырую строку. Касты и мутаторы в Eloquent выглядят как прозрачная магия, но каждая абстракция здесь протекает в конкретном месте. Разберём, где именно и как это обходить.
Материал ориентирован на Laravel 10.x и 11.x — в этих версиях основной API аксессоров построен на классе Illuminate\Database\Eloquent\Casts\Attribute. Старый синтаксис getXxxAttribute() тоже работает, и различия между ними — часть проблем, которые мы разбираем.
Два поколения аксессоров живут вместе
С Laravel 9 появился новый способ описывать аксессоры и мутаторы — через метод, возвращающий объект Attribute. Старый способ с методами getFirstNameAttribute() и setFirstNameAttribute() не удалён и работает параллельно. Это первый источник путаницы: в одной модели можно случайно завести оба варианта для одного поля.
use Illuminate\Database\Eloquent\Casts\Attribute;
class User extends Model
{
// Новый синтаксис (Laravel 9+): один метод на чтение и запись
protected function fullName(): Attribute
{
return Attribute::make(
get: fn (mixed $value, array $attributes) =>
$attributes['first_name'] . ' ' . $attributes['last_name'],
);
}
}Обратите внимание: имя метода — fullName() в camelCase, а обращение к свойству — $user->full_name в snake_case. Eloquent сам делает преобразование. Если назвать метод full_name(), аксессор просто не сработает, и ошибки вы не увидите — вернётся null.
Что здесь ломается
- Смешение стилей. Если для
full_nameесть иgetFullNameAttribute(), и методfullName(): Attribute, приоритет получает новый — старый молча игнорируется. Ревьюеру стоит требовать один стиль на весь проект. - Второй аргумент
$attributes. В новом синтаксисе замыканиеgetполучает не только текущее значение поля, но и массив всех сырых атрибутов. Именно оттуда берут значения соседних колонок для вычисляемых полей. Про этот аргумент часто забывают и лезут в$this->first_name, что при вычисляемом поле уводит в рекурсию.
Касты не срабатывают на insert, update и upsert
Самая частая и болезненная ошибка. Касты и мутаторы — это уровень модели. Методы Model::insert(), DB::table()->update(), upsert() работают напрямую с построителем запросов и модель не создают. Значит, ни один каст не применится.
Если вы пишетеUser::insert(['settings' => ['theme' => 'dark']])с кастомsettingsвarray, в базу уйдёт не JSON, а попытка вставить массив как строку — и PHP выбросит ошибку либо вставитArray. Каст здесь не участвует вообще.
Чтобы касты применились, нужен именно жизненный цикл модели: create(), save(), fill()->save(), updateOrCreate(). Массовые операции требуют ручной сериализации:
// Каст НЕ применится — сериализуем вручную
User::insert([
[
'email' => 'a@example.com',
'settings' => json_encode(['theme' => 'dark']),
],
]);
// Каст применится — идёт через модель
User::create([
'email' => 'a@example.com',
'settings' => ['theme' => 'dark'], // массив, каст сам сделает json_encode
]);Изменение вложенных данных в array-касте не отслеживается
Каст array при чтении делает json_decode и возвращает новый массив каждый раз. PHP-массивы — значения, а не ссылки. Поэтому изменение элемента напрямую не долетает до модели:
$user->settings['theme'] = 'light'; // это НЕ работает как ожидается
$user->save(); // settings не изменилсяEloquent даже подсказывает: при попытке такого присваивания PHP выдаёт Indirect modification of overloaded property — потому что settings это не реальное свойство, а результат __get. Правильный путь — забрать массив, изменить, положить целиком обратно:
$settings = $user->settings;
$settings['theme'] = 'light';
$user->settings = $settings; // теперь isDirty увидит изменение
$user->save();Альтернатива в свежих версиях — каст AsArrayObject или AsCollection, которые возвращают объект и отслеживают мутацию вложенных элементов. У них своя цена: сериализация в очередях и сравнение объектов ведут себя иначе, чем с обычным массивом.
Сравнение способов хранения структурированных данных
| Каст | Что возвращает | Мутация вложенного | Когда брать |
|---|---|---|---|
'array' | обычный массив | не отслеживается | читаете целиком, пишете целиком |
'object' | stdClass | не отслеживается | нужен объектный доступ, но без изменений |
AsArrayObject::class | ArrayObject | отслеживается | часто меняете отдельные ключи |
AsCollection::class | Collection | отслеживается | нужны методы коллекций над данными |
Касты и null: молчаливое падение
Встроенные касты Laravel по умолчанию не вызываются для null — модель отдаёт null напрямую. Но кастомный класс каста получает null в метод get(), и если код не проверяет его, вы получите TypeError уже в проде на первой записи с пустым полем.
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class MoneyCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes)
{
if ($value === null) {
return null; // без этой строки упадёт на пустом поле
}
return new Money((int) $value);
}
public function set($model, string $key, $value, array $attributes)
{
if ($value === null) {
return null;
}
// возвращаем массив: ключ => значение для колонки
return [$key => $value instanceof Money ? $value->cents() : (int) $value];
}
}Если ваш каст должен обрабатывать null сам (например, превращать в объект по умолчанию), реализуйте интерфейс Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes или добавьте на класс каста опцию через castUsing — точные интерфейсы уточните в документации Eloquent для вашей версии, они уточнялись между релизами.
Мутатор возвращает не то, что вы думаете
В новом синтаксисе замыкание set может вернуть либо одно значение (тогда оно кладётся в колонку с именем метода), либо массив ['колонка' => значение] для записи в несколько полей. Частая ошибка — вернуть значение, ожидая, что оно попадёт в другую колонку.
protected function password(): Attribute
{
return Attribute::make(
set: fn (string $value) => Hash::make($value),
);
}Здесь set вернёт строку — она уйдёт в колонку password. А вот если нужно записать хеш в другую колонку или сразу в несколько, возвращайте массив. Ещё нюанс: у аксессоров есть кеширование объектов через shouldCache(). По умолчанию объекты-значения не кешируются, и get вызывается при каждом обращении к свойству — в цикле это заметная нагрузка.
Типичные ошибки, которые ловят на ревью
- Каст даты без формата. Каст
datetimeбез указания формата возвращаетCarbon, но при сериализации в JSON формат берётся изserializeDate(). Разные модели отдают разный формат даты клиенту — фронтенд ломается. - Обращение к соседней колонке через
$this. Внутри аксессора берите значения из аргумента$attributes, а не через$this->other_field— второе снова прогонит другой аксессор и может уйти в рекурсию. - Каст на поле, которого нет в
$fillable. Каст сработает при чтении, но массовое присваивание черезcreate()тихо отбросит поле — данные не сохранятся, ошибки не будет. - Enum-каст на строке из легаси-базы. Каст
enumупадёт сValueError, если в колонке есть значение, которого нет в перечислении. Старые данные надо чистить до включения каста.
Что делать дальше
Проверьте свой проект по трём пунктам. Первый: найдите все вызовы insert(), upsert() и DB::table()->update() на таблицах, где есть касты — там сериализация должна быть ручной. Второй: убедитесь, что каждый кастомный каст обрабатывает null. Третий: если во вьюхах есть присваивания вида $model->json_field['key'] = ..., замените их на паттерн «забрал — изменил — вернул» или переведите поле на AsArrayObject.
Инвалидация кеша аксессоров и работа кастов внутри очередей при сериализации модели — отдельные темы, здесь мы их не разворачивали. Следующий шаг после этой чистки — покрыть касты юнит-тестами: тест на чтение, на запись и обязательно на null.
Редактор изображений: Убрать лишнее, заменить фон, добавить объект, расширить кадр — на вашем изображении.
Спросить за 20 ₽Источники: Eloquent: Mutators & Casting — Laravel Documentation
Частые вопросы
Почему изменение $model->settings['key'] не сохраняется?
Работают ли касты при Model::insert() и upsert()?
У меня Laravel 8, там нет класса Attribute — как быть?
Каст падает с ошибкой на пустом поле — что не так?
Можно ли одновременно использовать старый и новый синтаксис аксессора для одного поля?
Как каст ведёт себя при отправке модели в очередь?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.