У таблиці 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 замість вилки. Одного такого тесту вистачає, решту сценаріїв дешевше ганяти на самому касті.
Межа застосування виходить досить чіткою. Каст доречний, коли значення має форму колонки і не має власного життя: гроші, діапазони, нормалізовані списки, координати, ідентифікатори зовнішніх систем. Щойно в обʼєкта зʼявляються переходи стану з передумовами, каст перестає допомагати, бо він нічого не знає про те, звідки прийшло значення і чи дозволений такий перехід. Тоді розмова вже про агрегат і мапер, і це інша ціна.