Слово «модель» у цьому питанні означає не те, що показує IDE. У початковому MVC моделлю називали весь прикладний шар: дані, поведінку, правила предметної області. Фреймворки звузили термін до базового класу для рядка таблиці, і звідси береться розгубленість junior-розробника: у моделі є $fillable і зв'язки, більше туди нічого не влазить, отже логіку пишемо в контролері. Питання насправді стоїть інакше, ніж «модель чи контролер»: який шар нічого не знає про HTTP.
Аргумент проти контролера цілком прикладний. Метод контролера отримує Request, повертає Response і викликається роутером, тобто тримається на трьох прив'язках до транспорту. Щойно всередині опиняється сценарій, ви не можете виконати його з php artisan, з обробника черги, з іншого контролера чи з тесту без підробленого запиту. Далі приходить друга точка входу (адмінка, вебхук платіжки, імпорт), і логіка копіюється. Через півроку правило змінюється в одній копії з трьох. Тестованість страждає з тієї ж причини: щоб перевірити правило «повернення не більше суми платежу», доводиться піднімати роут, сесію, авторизацію і базу, замість того щоб створити об'єкт і викликати метод.
Куди саме класти винесене, залежить від характеру логіки. Правила, які стосуються однієї сутності і її власних даних, належать самій сутності: guardCanRefund(), isOverdue(), total(), перехід статусу. Такі методи не мають залежностей, читаються поруч із даними, якими оперують, і не потребують мокання. Зі сценарієм інакше: він зачіпає кілька сутностей, відкриває транзакцію, ходить у платіжний шлюз, публікує подію. Назвати це «поведінкою замовлення» вже не вийде, перед вами use case «повернути кошти за замовленням», і йому місце в окремому класі з однією публічною точкою входу. Такий клас приймає DTO або value object, а не Request, і повертає доменний результат, а не JsonResponse. Різниця між Laravel і Symfony тут відчутна: Eloquent-модель за Active Record уже суміщає домен і персистенцію, тому кожен новий обов'язок робить її важчою, а Doctrine-сутність про сховище не знає і тримає більше поведінки без шкоди.
Контролеру після цього лишається чотири короткі кроки: узяти провалідовані дані, перевірити доступ політикою чи вотером, викликати один use case, перетворити результат або доменний виняток у відповідь. П'ятнадцять рядків, у яких немає нічого, що захотілося б перевикористати. Зверніть увагу на деталь у прикладі: DB::transaction живе в use case, а не в контролері. Інакше кожна нова точка входу зобов'язана пам'ятати про транзакцію, і рано чи пізно хтось забуде.
Небезпека протилежного берега реальна, і junior заходить туди швидше, ніж у товстий контролер. UpdateTagService, що робить один $tag->update(), нічого не ізолює, зате додає файл, конструктор і рівень непрямості. Виносьте, коли є привід: більш ніж одна сутність, транзакція, зовнішній ефект, правила, які хочеться протестувати окремо, або друга точка входу. OrderService із сорока методами провалюється з іншого боку: у нього немає зв'язності, його всі бояться редагувати, а конструктор тягне пів контейнера. Один клас на сценарій із __invoke знімає обидві проблеми майже безкоштовно, а на здоровому проєкті таких класів десяток на сотню контролерів, і це нормальна пропорція.
// Було: сценарій живе в контролері і прив'язаний до HTTP
public function store(Request $request, Order $order)
{
$data = $request->validate(['amount' => 'required|integer|min:1']);
if ($order->status !== 'paid') {
return back()->withErrors('Замовлення не оплачене');
}
// ...транзакція, виклик шлюзу, лист - усе тут же
}
// Стало: use case - одна точка входу, нічого не знає про запит
final readonly class RefundOrder
{
public function __construct(
private PaymentGateway $gateway,
private DatabaseManager $db,
) {}
/** @throws RefundNotAllowed */
public function __invoke(OrderId $orderId, Money $amount): Refund
{
return $this->db->transaction(function () use ($orderId, $amount) {
$order = Order::lockForUpdate()->findOrFail($orderId->value);
// Інваріант домену: рішення ухвалює сама сутність
$order->guardCanRefund($amount);
$refund = $this->gateway->refund($order->charge_id, $amount);
$order->registerRefund($refund);
$order->save();
return $refund;
});
}
}
// Контролер: дані на вхід, use case, відповідь на вихід
public function store(RefundRequest $request, Order $order, RefundOrder $refundOrder)
{
$this->authorize('refund', $order);
$refund = $refundOrder(new OrderId($order->id), Money::fromMinor($request->integer('amount')));
return new RefundResource($refund); // виняток RefundNotAllowed мапиться на 409 у Handler
}
Дайте перевірку одним реченням: візьміть свій метод контролера і спробуйте викликати ту саму дію з artisan-команди. Усе, що при цьому доводиться підробляти (`Request`, сесію, редирект), і є логікою, яку треба винести. Далі поділіть винесене на дві купки: правила однієї сутності йдуть у модель, сценарій із кількома сутностями і транзакцією - в окремий клас з одним `__invoke`.