<? phpukraine СТАТТІ
⌕Пошук по платформі
LARAVEL 25 вересня 2026 · 8 хв читання

Власні касти в Eloquent: value objects у колонках без магії

Три колонки з зарплатою, які всюди читають разом, і JSON-масив тегів, який кожен нормалізує по-своєму. Кастом можна закрити обидва випадки так, щоб модель віддавала готовий обʼєкт, а база лишалась базою. Розбираємо CastsAttributes і Castable на Laravel 13, включно з тим, де каст перестає працювати: у `where`, в `isDirty` і в `toArray`.

РP
Редакція phpukraine
Редакція платформи

У таблиці vacancies на phpukraine зарплата живе в трьох колонках: salary_from, salary_to, salary_currency. Усі три nullable, бо більшість вакансій вилку не показує. Питання «чи є у цієї вакансії зарплата» має три різні відповіді залежно від того, хто питає: картка в каталозі, фільтр і експорт у структуровані дані для пошуковиків.

Поки таких місць два, кожне з них має свій if. Коли їх стає шість, зʼявляється перша розбіжність: десь «від 2000 без верхньої межі» вважається вилкою, а десь ні. Каст переносить це рішення в одне місце, і модель віддає вже не три скаляри, а обʼєкт, який знає відповідь.

Три колонки, які завжди читають разом

Ознака, за якою колонка просить value object, проста: її ніколи не читають наодинці. salary_currency без salary_from безглузда. tags без нормалізації дає дублікати Laravel і laravel у фасетах. В обох випадках у базі лежить представлення, а сенс збирається кодом, і збирається він щоразу заново.

Value object тут дешевший за агрегат з мапером. Він не має життєвого циклу, не зберігається окремо, не має ідентичності. Це readonly-клас з кількома полями і методами, які відповідають на питання, що виникають у шаблонах:

final readonly class SalaryRange
{
    private function __construct(
        public ?int $from,
        public ?int $to,
        public ?string $currency,
    ) {}

    public static function undisclosed(): self
    {
        return new self(null, null, null);
    }

    public static function between(?int $from, ?int $to, string $currency): self
    {
        if ($from !== null && $to !== null && $from > $to) {
            [$from, $to] = [$to, $from];
        }

        return new self($from, $to, $currency);
    }

    public function isDisclosed(): bool
    {
        return $this->currency !== null && ($this->from !== null || $this->to !== null);
    }

    public function midpoint(): ?int
    {
        return match (true) {
            $this->from !== null && $this->to !== null => intdiv($this->from + $this->to, 2),
            default => $this->from ?? $this->to,
        };
    }
}

Перевірка $from > $to тепер одна на весь проєкт, а не в кожному імпортері джерела.

CastsAttributes і Castable

Каст складається з двох методів. get() отримує сире значення колонки і повертає те, що побачить код, set() робить зворотне перетворення. Почнемо з простого випадку, де одна колонка відповідає одному обʼєкту: теги вакансії лежать у JSON-колонці tags.

namespace PhpUkraine\Hiring\Infrastructure\Casts;

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use PhpUkraine\Hiring\Domain\Job\TagList;

/**
 * @implements CastsAttributes<TagList, TagList|list<string>>
 */
final class TagListCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): TagList
    {
        return TagList::fromArray(json_decode((string) $value, true) ?: []);
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): array
    {
        $list = $value instanceof TagList ? $value : TagList::fromArray($value);

        return [$key => json_encode($list->all(), JSON_UNESCAPED_UNICODE)];
    }
}

TagList::fromArray() тримає нормалізацію: mb_strtolower, array_unique, сортування. Далі код не має жодного способу покласти в базу масив з дублікатами, бо інший шлях до колонки просто не описаний.

Підключення в моделі звичайне:

protected function casts(): array
{
    return [
        'tags' => TagListCast::class,
    ];
}

Castable прибирає з цього рядка згадку про інфраструктуру. Value object сам каже, яким кастом його діставати:

final readonly class TagList implements Castable
{
    public static function castUsing(array $arguments): string
    {
        return TagListCast::class;
    }
}

Після цього в casts() пишеться 'tags' => TagList::class, і місце, де оголошено модель, більше не знає назви класу-каста. $arguments це те, що йде після двокрапки: TagList::class.':lower,sorted' віддасть у castUsing() масив ['lower', 'sorted']. Домен при цьому імпортує Illuminate\Contracts\Database\Eloquent\Castable, тобто перестає бути framework-free; якщо архітектурний тест це забороняє, Castable лишається на інфраструктурному класі, а в casts() пишеться каст напряму.

Поруч є ще три контракти з того ж неймспейсу. CastsInboundAttributes для значень, які пишуться, але не читаються назад (хеші, шифровані токени): у нього немає get(). SerializesCastableAttributes додає метод serialize(), який керує тим, що піде в toArray(). DeviatesCastableAttributes дає increment() і decrement(), якщо $model->increment('counter') має щось означати для вашого типу.

Кілька колонок в одному обʼєкті

Повернення масиву з set() це і є механізм для кількох колонок. Ключі масиву, а не імʼя атрибута, визначають, куди піде запис:

/**
 * @implements CastsAttributes<SalaryRange, SalaryRange>
 */
final class SalaryRangeCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): SalaryRange
    {
        if (($attributes['salary_currency'] ?? null) === null) {
            return SalaryRange::undisclosed();
        }

        return SalaryRange::between(
            $attributes['salary_from'] !== null ? (int) $attributes['salary_from'] : null,
            $attributes['salary_to'] !== null ? (int) $attributes['salary_to'] : null,
            $attributes['salary_currency'],
        );
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): array
    {
        if (! $value instanceof SalaryRange) {
            throw new InvalidArgumentException('salary expects a SalaryRange instance.');
        }

        return [
            'salary_from' => $value->from,
            'salary_to' => $value->to,
            'salary_currency' => $value->currency,
        ];
    }
}

У casts() зʼявляється ключ 'salary' => SalaryRange::class, якого немає серед колонок. Це віртуальний атрибут, і з ним повʼязані три речі, про які краще дізнатись зараз, а не з бага в проді.

Перше: $model->isDirty('salary') завжди поверне false. getDirty() ходить по реальних атрибутах, а salary туди не потрапляє, бо set() повернув інші ключі. Слідкувати треба за колонками: isDirty('salary_from').

Друге: toArray() і JSON-серіалізація віртуальний ключ пропускають. addCastAttributesToArray() пропускає каст, якщо його ключа немає в масиві атрибутів, тому в результаті будуть salary_from, salary_to, salary_currency. SerializesCastableAttributes тут не рятує, бо до нього справа не доходить; потрібен $appends з аксесором або явна збірка в API-ресурсі.

Третє стосується мутабельності. Обʼєкт, який повернув get(), кешується в моделі, а перед збереженням Laravel проганяє кеш через mergeAttributesFromCachedCasts(), тобто викликає set() ще раз з тим самим інстансом. Якщо ваш value object має сетери, то $job->salary->raiseTo(5000) тихо запишеться в базу при найближчому save(), навіть якщо ви цього не планували. readonly знімає питання цілком.

Є ще один необовʼязковий метод. Якщо каст-клас оголошує compare(Model $model, string $key, mixed $first, mixed $second): bool (контракт ComparesCastableAttributes), Eloquent використає його замість типового порівняння сирих значень у originalIsEquivalent(). Перевірка робиться через method_exists(), не через instanceof, але контракт краще реалізувати явно. Потрібно це рідко: коли одне й те саме значення може мати різне сире представлення, як от JSON з іншим порядком ключів.

Касти і запити where

Каст працює на рівні атрибутів моделі, а where() збирає біндінги в Query Builder. Між ними немає зчеплення, окрім одного винятку: Builder::castBinding() викликає enum_value(), який розгортає BackedEnum у скаляр. Саме тому where('status', JobStatus::Active) працює, а where('salary', $range) впаде на спробі перетворити обʼєкт на рядок.

Практичний наслідок: усе, що стосується вибірок, сортувань і агрегацій, пишеться по колонках. Каст лишається для читання і запису однієї моделі. Щоб це не розповзлось по коду, фільтри варто закрити скоупами, які приймають value object і самі його розкладають:

public function scopePayingAtLeast(Builder $query, int $amount, string $currency): Builder
{
    return $query->where('salary_currency', $currency)
        ->where('salary_to', '>=', $amount);
}

public function scopeTagged(Builder $query, TagList $tags): Builder
{
    foreach ($tags->all() as $tag) {
        $query->whereJsonContains('tags', $tag);
    }

    return $query;
}

У scopeTagged() нормалізація з TagList дає побічну вигоду: у whereJsonContains() іде рівно той регістр, у якому теги лежать у колонці, бо обидва шляхи проходять через один і той самий конструктор. З валютами такого фокусу немає. Порівнювати вилки в різних валютах запитом не вийде взагалі, потрібна окрема нормалізована колонка, яку заповнюють при записі.

Теоретично castBinding() можна перевизначити у власному Query Builder і підключити його через Model::newBaseQueryBuilder(). Це працює, але ви отримуєте нестандартну поведінку where на всіх моделях заради економії кількох скоупів. Скоупи чесніші: по них видно, які саме колонки читаються.

Тестування кастів

Каст це звичайний клас, і найкорисніші тести для нього не потребують бази. get() приймає масив атрибутів, тому його можна викликати руками і згодувати рівно той сміттєвий стан, який зустрічається в реальних даних:

it('treats a range without currency as undisclosed', function () {
    $range = (new SalaryRangeCast)->get(new JobModel, 'salary', null, [
        'salary_from' => 2000,
        'salary_to' => null,
        'salary_currency' => null,
    ]);

    expect($range->isDisclosed())->toBeFalse();
});

it('swaps reversed bounds on write', function () {
    $row = (new SalaryRangeCast)->set(new JobModel, 'salary', SalaryRange::between(3500, 2000, 'USD'), []);

    expect($row)->toBe([
        'salary_from' => 2000,
        'salary_to' => 3500,
        'salary_currency' => 'USD',
    ]);
});

Часткові стани тут головне. Імпорт з зовнішнього джерела рано чи пізно покладе salary_from без валюти, і поведінка каста в цей момент має бути описана тестом, а не тим, що вийшло.

Зверху потрібен один тест з базою на повний цикл, бо саме там ламаються віртуальні ключі й масове присвоєння:

it('survives a round trip through the database', function () {
    $job = JobModel::factory()->create([
        'salary' => SalaryRange::between(2000, 3500, 'USD'),
    ]);

    expect($job->fresh()->salary)->toEqual(SalaryRange::between(2000, 3500, 'USD'));
});

toEqual, а не toBe: з бази приїде новий інстанс з тими самими полями. Якщо в моделі є $fillable, у нього треба додати віртуальний ключ salary, інакше create() мовчки його викине, а тест покаже undisclosed замість вилки. Одного такого тесту вистачає, решту сценаріїв дешевше ганяти на самому касті.

Межа застосування виходить досить чіткою. Каст доречний, коли значення має форму колонки і не має власного життя: гроші, діапазони, нормалізовані списки, координати, ідентифікатори зовнішніх систем. Щойно в обʼєкта зʼявляються переходи стану з передумовами, каст перестає допомагати, бо він нічого не знає про те, звідки прийшло значення і чи дозволений такий перехід. Тоді розмова вже про агрегат і мапер, і це інша ціна.

ПИШЕТЕ ПРО PHP?Опублікуйте розбір або історію з проєкту на платформіРедактор із чеклістом, редактура, авторська сторінка. Републікація з блогу отримує canonical на оригінал. Відкрити редактор →
РP
Редакція phpukraine
Редакція платформи
Матеріали, які готує команда платформи на основі власних даних: каталогу вакансій, зарплатного звіту й банку питань. Кожна цифра в них рахується з бази, а не береться з голови.
оновлено 30 вересня 2026 · ліцензія CC-BY-SA-4.0
ДАЛІ ПО ТЕМІ
ЧИТАТИ ДАЛІ
← Усі статті