Питання «Eloquent чи доменна модель» зазвичай обговорюють як ідеологічне, хоча воно бухгалтерське. Мапер між агрегатом і рядком таблиці коштує рядків коду, які треба писати й тримати синхронними. Відсутність мапера коштує інвариантів, які рано чи пізно обійдуть через update() з контролера. Рахунок виходить різний у різних частинах системи, тому й рішення ухвалюють для кожного контексту окремо, а не один раз на весь проєкт.
У phpukraine (PHP 8.4, Laravel 13) шість контекстів у src/, і межа в них проведена по-різному. Hiring має повноцінний агрегат Job з мапером. У Content домену в цьому сенсі майже немає: документ приходить з файлу, рядок у базі це проєкція. Contact посередині: домен є, мапера немає. Далі про те, звідки взялась різниця.
Ціна мапера, порахована в рядках
src/Hiring/Domain/Job/Job.php це 451 рядок. JobMapper 115, EloquentJobRepository 105, інтерфейс JobRepository 43. На 451 рядок доменної логіки припадає 263 рядки інфраструктури, які нічого не вирішують, а лише перекладають з формату у формат.
Ось як виглядає рахунок, коли додається одна колонка expires_at:
- Міграція.
@propertyу докблоціJobModelі, якщо треба, запис уcasts().- Параметр у приватному конструкторі
Job. - Параметр у
Job::reconstitute()(там уже 24 параметри) і передача його далі. - Читання в
JobMapper::toDomain(). - Запис у
JobMapper::toRow().
Шість місць замість одного. Забудете п'яте, і поле мовчки стане null після кожного save(). PHPStan ловить це лише частково: у reconstitute() параметр з дефолтом не є обов'язковим, і виклик без нього компілюється.
Друга половина ціни помітна не одразу. Ось save() цілком:
public function save(Job $job): void
{
$row = $this->mapper->toRow($job);
if ($job->id === null) {
$model = JobModel::query()->withoutGlobalScope(HidesTestData::class)->create($row);
$job->assignId($model->id);
return;
}
JobModel::query()->withoutGlobalScope(HidesTestData::class)->whereKey($job->id)->update($row);
}
Це повний UPDATE усіх 35 колонок щоразу, бо агрегат не веде dirty-стан. Eloquent з коробки оновив би дві змінені. Заодно зникають обсервери, touch(), saveQuietly() і софт-делети: усе, що прив'язане до подій моделі, більше не спрацьовує, бо бізнес-код моделі не бачить. Для вакансій це прийнятно, синхронізація джерел і так пише пакетами вночі. Для сутності, яку правлять з форми по одному полю, вийшов би регрес без жодної вигоди.
Що дає мапер натомість, видно в тесті:
it('goes stale after three missed syncs and expires a week later', function () {
$clock = FrozenClock::at('2026-09-02 10:00');
$job = aJob($clock);
$job->missedInSync($clock->now());
// ...
});
Жодної бази, жодного RefreshDatabase, жодної фабрики. tests/Unit/Hiring/JobTest.php ганяє життєвий цикл вакансії на голому PHP, і це десятки мілісекунд замість секунд. Правила missedInSync() і expireIfDue() перевірені всі, включно з комбінаціями, які в базі відтворювати довго.
Коли Eloquent і є моделлю
ContactMessage має домен: submit() тримає перевірки довжини й формату, кидає InvalidArgumentException з українським текстом, вирішує, чи питати компанію, залежно від ContactReason. А репозиторій виглядає так:
final class EloquentContactMessageRepository implements ContactMessageRepository
{
public function save(ContactMessage $message): void
{
$model = ContactMessageModel::query()->create([
'reason' => $message->reason->value,
'name' => $message->name,
// ...
]);
$message->assignId($model->id);
}
}
Окремого класу-мапера немає, бо нема чого мапити в зворотний бік. Повідомлення з контактної форми пишеться один раз і далі читається власником у панелі. Немає toDomain(), немає reconstitute(), половина ціни зникла сама собою. Мапер потрібен там, де об'єкт піднімається з бази, щоб над ним виконали операцію; коли запис односпрямований, вистачить save().
З Content ще простіше. Статті й документація живуть у content/*.md, імпортер розкладає їх по таблицях:
public function upsert(ArticleDocument $document, string $bodyHtml, array $toc, string $contentHash, string $origin = self::ORIGIN_FILE): void
{
ArticleModel::query()->updateOrCreate(
['category' => $document->category->value, 'slug' => $document->slug],
['title' => $document->title, /* ... */ 'content_hash' => $contentHash, 'origin' => $origin],
);
}
Агрегату тут немає взагалі, бо база не є джерелом правди. Стан статті живе в git, а таблиця лишається кешем для запитів. Реконструювати ArticleDocument з рядка не потрібно нікому, і будь-який шар зверху був би мертвим кодом. Так само з довідниками, налаштуваннями і всім, що приходить із зовнішнього джерела цілими блоками.
Сигнал, що агрегат таки потрібен
Job отримав окрему модель через статуси. Вакансія ходить між active, stale, expired, merged, і переходи мають умови: три пропущені синхронізації поспіль дають stale, сім днів у stale або 60 днів від публікації дають expired. Ці константи живуть на агрегаті (STALE_AFTER_MISSED_SYNCS, EXPIRES_AFTER_DAYS), а поля закриті асиметричною видимістю PHP 8.4:
public private(set) JobStatus $status;
public private(set) int $missedSyncs;
Читати може будь-хто, писати тільки сам Job. Eloquent такої гарантії не дає в принципі: $model->status = 'expired' доступне з будь-якого місця, а $guarded = [] у JobModel робить масове присвоєння відкритим. Для моделі, яку чіпає лише репозиторій, це нормально; для моделі, з якою працює половина застосунку, це діра.
Ознаки, за якими домен справді треба відділяти:
- стан із переходами, що мають передумови, а не просто колонка
status; - інваріант, який має триматись у кожній точці входу, а не тільки у формі;
- правила, які хочеться прогнати сотнею сценаріїв швидко;
- логіка, що вже продубльована в команді й контролері.
Зворотні ознаки теж прості. Якщо сутність заповнюється формою й показується в таблиці, якщо вся «логіка» це updated_at і кілька скоупів, якщо всі перевірки вже є у FormRequest, шар нічого не додасть.
Читання не проходить через репозиторій
Найпоширеніша помилка при введенні репозиторіїв: пропускати через них читання для екранів. Сторінка каталогу показує 20 карток і сім груп фасетів. Піднімати 20 агрегатів через toDomain() заради назви й вилки марно, а фасети через агрегати не порахуються взагалі.
Тому в Application живе окремий інтерфейс:
/**
* Read side of the catalog. Implemented directly over the database; no aggregate mapping on reads.
*/
interface JobCatalog
{
public function count(JobFilter $filter): int;
public function page(JobFilter $filter, int $page, int $perPage = 20): CatalogPage;
public function facets(JobFilter $filter): Facets;
}
Реалізація EloquentJobCatalog користується Eloquent на повну: with('company:id,name,slug,kind,logo_url'), orderByRaw('salary_usd_to DESC NULLS LAST'), groupBy для фасетів. Межу тримає не заборона на Query Builder, а тип на виході: назовні йде JobCard, readonly-DTO з методами path() і isNew(). Ні моделі, ні білдера контролер і Livewire-компонент не бачать.
Таке розділення дешеве. Звичайні запити пишете там, де вони ефективні, а мапінг в агрегат лишається для операцій, які змінюють стан. У JobRepository сім методів, і кожен обслуговує запис: findBySourceRef, needingRewrite, visibleFromSourceNotSeenSince, save. Жоден не існує заради виведення на екран.
Півзаходи, які нічого не дають
Найчастіший: репозиторій, що повертає Eloquent-моделі. interface JobRepository { public function findById(int $id): ?JobModel; } виглядає як шар, але лишає виклик $job->update([...]) доступним з будь-якого місця, додає інтерфейс і не додає жодної гарантії. Або мапер є, або репозиторій не потрібен; проміжний варіант дає лише зайвий файл.
Другий: доменний клас, що успадковує Model. Разом з ним у домен приїжджає Illuminate, а об'єкт уже не інстанціюєш без завантаженого фреймворка. Правило перевіряється тестом:
arch('domain layers stay framework-free')
->expect(['PhpUkraine\Hiring\Domain', 'PhpUkraine\Content\Domain', 'PhpUkraine\Contact\Domain'])
->not->toUse(['Illuminate', 'Livewire', 'App']);
Тест дешевий і ловить випадковий use Illuminate\Support\Str; у домені в момент написання, а не через півроку. До речі, Str::slug() у проєкті викликається саме в JobMapper::toRow(), бо слаг це властивість рядка в базі та URL, а не вакансії як такої.
Третій півзахід: DTO, що один в один повторює колонки. Якби JobCard мав ті самі 35 полів, що й таблиця, він був би перейменованим JobModel. Сенс read-моделі саме в тому, що вона вужча за рядок і зібрана під конкретний екран.
Де проходить межа
Робоче формулювання, за яким ухвалювались рішення в цьому репозиторії: мапер виправданий рівно тоді, коли об'єкт має стан, що змінюється правилами, і ці правила варто перевіряти без бази. Все інше обходиться Eloquent-моделлю.
З цього виходить розкладка, яку легко пояснити новій людині. Вакансія має життєвий цикл і класифікацію, тому Job окремий клас з мапером. Контактне повідомлення має валідацію, але не має життя після запису, тому домен є, а мапера немає. Стаття приходить із файлу, тому в базі лежить проєкція, а редагується оригінал у git. Каталог і будь-яка агрегація читаються запитами й віддаються як DTO. Адмінка на Filament працює з моделями напряму і є свідомим винятком, обмеженим одним неймспейсом.
Найгірше виходить, коли шар вводять «на всякий випадок» скрізь одразу. Тоді на кожні три поля форми припадає п'ять файлів, швидкість розробки падає, а перший же дедлайн породжує DB::table() в контролері в обхід усього. Візьміть контекст, де ціна помилки реальна, зробіть повний комплект саме там, а решту залиште на Eloquent, поки не з'явиться причина.