Service layer потрібен не “для краси”, а щоб проєкт не перетворився на набір випадкових контролерів із копіпастом. Контролер — це шар HTTP, він має працювати як диспетчер: прийняти запит, запустити правильний сценарій і повернути відповідь. Сервіс — це шар сценаріїв (use-cases): “створити тег”, “оновити секцію”, “опублікувати новину”, “прикріпити медіа”, “записати активність”. Якщо ти чітко розділиш ці ролі, код стає прогнозованим: будь-яка нова фіча додається по одному шаблону.
Перше правило: у контролері не повинно бути бізнес-рішень. Контролер не вирішує, “чи можна публікувати”, “як рахувати суму”, “як генерувати slug”, “які статуси допустимі”. Він може перевірити доступ (через middleware/policy), але самі правила зміни стану належать сервісу. Якщо правило буде в контролері, завтра ти додаси ще один маршрут або CLI-команду — і ти почнеш дублювати логіку або отримаєш різну поведінку.
Друге правило: усе, що залежить від HTTP, лишається в контролері. Це означає: Request, FormRequest, query string, файли форми як об’єкти HTTP, редиректи, статус-коди, cookie, session flash, вибір view або JSON-відповіді. Сервіс не повинен знати, чи це прийшло з браузера, API, черги або консолі. Він має приймати звичайні дані (масив/DTO) і повертати результат (модель/структуру), не будучи прив’язаним до вебу.
Третє правило: валідація формальних правил — у FormRequest, бізнес-правила — у сервісі. Формальні правила — це “required/max/unique/date/in”. Їх зручно і правильно тримати в FormRequest. Бізнес-правила — це “не можна змінювати approval_status без ролі”, “після публікації не можна міняти ресурс”, “якщо ввімкнули subscription_required, перевір тариф”. Такі речі мають бути в сервісі, бо вони описують поведінку домену, а не форму.
Четверте правило: транзакції завжди в сервісі, якщо операція зачіпає більше ніж одну таблицю або має побічні ефекти. Контролер не повинен вирішувати, де починається і закінчується “атомарна” операція. Це відповідальність сервісу, бо він бачить весь сценарій: створили запис → синхронізували pivot → записали activity → оновили лічильник → відправили нотифікацію. Якщо щось падає посередині, сервіс повинен відкотити або коректно завершити операцію.
П’яте правило: “тонкий контролер, товстий сервіс” — але без перебору. Товстий сервіс — не означає “один метод на 500 рядків”. Правильний сервіс має 2–5 публічних методів під use-cases (create/update/delete/publish/approve) і кілька приватних методів для кроків. Контролер від цього виграє: кожен його метод перетворюється на 5–15 рядків читабельного коду.
Шосте правило: сервіс приймає лише те, що реально дозволено. Навіть якщо FormRequest віддав $validated, сервіс часто робить whitelist полів або явну мапу. Це особливо важливо для модулів з доступами: одні ролі можуть міняти одні поля, інші — інші. Контролер не повинен містити цю математику. Сервіс повинен сам визначати “ефективні” дані для запису, виходячи з ролі/політик/поточних станів.
Сьоме правило: робота з моделями і запитами — так, але не “все в контролері”. Запити на читання для списків (index) часто нормально залишити в контролері або винести в QueryBuilder/Repository, якщо стає складно. А от запис і сценарії зміни стану — це зона сервісу. Практично: index може зібрати фільтри і зробити paginate, але store/update мають делегувати створення/оновлення сервісу.
Восьме правило: побічні ефекти живуть поруч зі сценарієм, а не розкидані по коду. Побічні ефекти — це: activity log, нотифікації, черги, оновлення кешу, робота з файлами, інтеграції з соцмережами. Якщо вони розкидані по контролерах, ти не можеш гарантувати однакову поведінку. Якщо вони в сервісі, ти знаєш: “цей сценарій завжди пише activity і завжди валідно завершиться”.
Дев’яте правило: сервіс повертає результат, а форматування — в контролері/ресурсах. Якщо це web — контролер вирішує, який view і які flash. Якщо це API — контролер повертає JSON і статуси, можливо через Resource/Transformer. Сервіс не повинен повертати redirect() чи view(). Він повертає модель або DTO/масив з даними, і контролер вирішує, як це показати.
Де проходить “чітка межа” на прикладі CRUD. Контролер робить: взяв FormRequest → отримав $validated → викликав $service->create($validated, $actor) → редирект на index з with('success', ...). Сервіс робить: нормалізував → створив запис → при потребі транзакція → синхронізував зв’язки → записав activity → повернув модель. У такій схемі ти можеш легко додати ще один вхід: наприклад, CLI-команду для імпорту тегів — вона просто викличе той самий сервіс, і логіка буде однакова.
Типові анти-патерни, які треба відрізати одразу. Перший — “сервіс як обгортка над Model::create”. Якщо сервіс нічого не робить, він зайвий. Другий — “контролер виконує половину сценарію, сервіс — іншу половину”, коли логіка змішана. Третій — “сервіс приймає Request і повертає Response” — так ти знищуєш розділення шарів. Четвертий — “один сервіс на все” з десятками несумісних методів, де ніхто не розуміє, що відбувається.
Висновок: Service layer — це простий контракт. Контролер керує HTTP і UX (вхід/вихід), сервіс керує сценарієм і цілісністю даних (правила, транзакції, побічні ефекти). Якщо ти тримаєш цю межу, проєкт росте без болю: нові модулі додаються по одному шаблону, тести пишуться легше, а ризик “зламали існуюче” різко падає, бо вся логіка зосереджена в одному місці і працює однаково з будь-якого входу.