Сервисный слой — первое, что советуют при разросшемся контроллере, и обычно этот совет верен. Но у него есть неудачная формулировка, которая делает больше вреда, чем пользы: «выносите логику в сервисы». Следуя ей буквально, команда получает папку Services с классами на тысячу строк — тот же толстый контроллер, только переехавший.
Разберём, что именно выносить, и по каким признакам понять, что сервис стал свалкой.
Проблема толстых контроллеров
Типичная картина в Laravel-проекте:
// app/Http/Controllers/OrderController.php
class OrderController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'items' => 'required|array',
'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|integer|min:1',
'delivery_address' => 'required|string',
'payment_method' => 'required|in:card,cash',
]);
// Проверяем наличие товаров
foreach ($validated['items'] as $item) {
$product = Product::find($item['product_id']);
if ($product->stock < $item['quantity']) {
return back()->withErrors(['items' => "Недостаточно товара: {$product->name}"]);
}
}
// Считаем сумму
$total = 0;
foreach ($validated['items'] as $item) {
$product = Product::find($item['product_id']);
$total += $product->price * $item['quantity'];
}
// Применяем скидку
$user = auth()->user();
if ($user->orders()->count() > 10) {
$total *= 0.95; // 5% скидка постоянным клиентам
}
// Создаём заказ
$order = Order::create([
'user_id' => $user->id,
'total' => $total,
'status' => 'pending',
'delivery_address' => $validated['delivery_address'],
]);
// Добавляем позиции
foreach ($validated['items'] as $item) {
$product = Product::find($item['product_id']);
$order->items()->create([
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'price' => $product->price,
]);
// Списываем со склада
$product->decrement('stock', $item['quantity']);
}
// Уведомления
$user->notify(new OrderCreated($order));
// Отправляем в CRM
Http::post('https://crm.example.com/orders', $order->toArray());
return redirect()->route('orders.show', $order);
}
}Проблемы:
- 60+ строк в одном методе
- Невозможно переиспользовать логику
- Сложно тестировать
- Смешаны HTTP, бизнес-логика, интеграции
Service Layer — слой между контроллером и моделью, содержащий бизнес-логику. Контроллер только принимает запрос и возвращает ответ.
Создание сервиса
// app/Services/OrderService.php
namespace App\Services;
use App\Models\Order;
use App\Models\Product;
use App\Models\User;
use App\Notifications\OrderCreated;
use App\Exceptions\InsufficientStockException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
class OrderService
{
public function __construct(
private DiscountService $discountService,
private StockService $stockService,
private CrmService $crmService,
) {}
/**
* Создание заказа
*
* @param User $user
* @param array $items [{product_id, quantity}, ...]
* @param string $deliveryAddress
* @return Order
* @throws InsufficientStockException
*/
public function createOrder(User $user, array $items, string $deliveryAddress): Order
{
// Проверяем наличие
$this->stockService->validateAvailability($items);
// Считаем сумму
$total = $this->calculateTotal($items);
// Применяем скидки
$total = $this->discountService->apply($user, $total);
// Создаём заказ в транзакции
$order = DB::transaction(function () use ($user, $items, $total, $deliveryAddress) {
$order = Order::create([
'user_id' => $user->id,
'total' => $total,
'status' => 'pending',
'delivery_address' => $deliveryAddress,
]);
$this->attachItems($order, $items);
$this->stockService->decrementStock($items);
return $order;
});
// Побочные эффекты после транзакции
$this->afterOrderCreated($order, $user);
return $order;
}
private function calculateTotal(array $items): float
{
$total = 0;
foreach ($items as $item) {
$product = Product::find($item['product_id']);
$total += $product->price * $item['quantity'];
}
return $total;
}
private function attachItems(Order $order, array $items): void
{
foreach ($items as $item) {
$product = Product::find($item['product_id']);
$order->items()->create([
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'price' => $product->price,
]);
}
}
private function afterOrderCreated(Order $order, User $user): void
{
// Уведомление
$user->notify(new OrderCreated($order));
// CRM (в очередь)
$this->crmService->sendOrderAsync($order);
}
}Тонкий контроллер
// app/Http/Controllers/OrderController.php
namespace App\Http\Controllers;
use App\Http\Requests\StoreOrderRequest;
use App\Services\OrderService;
use App\Exceptions\InsufficientStockException;
class OrderController extends Controller
{
public function __construct(
private OrderService $orderService
) {}
public function store(StoreOrderRequest $request)
{
try {
$order = $this->orderService->createOrder(
user: auth()->user(),
items: $request->validated('items'),
deliveryAddress: $request->validated('delivery_address'),
);
return redirect()
->route('orders.show', $order)
->with('success', 'Заказ создан');
} catch (InsufficientStockException $e) {
return back()
->withErrors(['items' => $e->getMessage()])
->withInput();
}
}
}Вспомогательные сервисы
StockService
// app/Services/StockService.php
namespace App\Services;
use App\Models\Product;
use App\Exceptions\InsufficientStockException;
class StockService
{
public function validateAvailability(array $items): void
{
foreach ($items as $item) {
$product = Product::find($item['product_id']);
if ($product->stock < $item['quantity']) {
throw new InsufficientStockException(
"Недостаточно товара «{$product->name}». В наличии: {$product->stock}"
);
}
}
}
public function decrementStock(array $items): void
{
foreach ($items as $item) {
Product::where('id', $item['product_id'])
->decrement('stock', $item['quantity']);
}
}
public function incrementStock(array $items): void
{
foreach ($items as $item) {
Product::where('id', $item['product_id'])
->increment('stock', $item['quantity']);
}
}
}DiscountService
// app/Services/DiscountService.php
namespace App\Services;
use App\Models\User;
use App\Models\PromoCode;
class DiscountService
{
public function apply(User $user, float $total, ?string $promoCode = null): float
{
// Скидка постоянным клиентам
$total = $this->applyLoyaltyDiscount($user, $total);
// Промокод
if ($promoCode) {
$total = $this->applyPromoCode($promoCode, $total);
}
return round($total, 2);
}
private function applyLoyaltyDiscount(User $user, float $total): float
{
$ordersCount = $user->orders()->where('status', 'completed')->count();
return match (true) {
$ordersCount >= 50 => $total * 0.90, // 10%
$ordersCount >= 20 => $total * 0.95, // 5%
$ordersCount >= 10 => $total * 0.97, // 3%
default => $total,
};
}
private function applyPromoCode(string $code, float $total): float
{
$promo = PromoCode::where('code', $code)
->where('active', true)
->where('expires_at', '>', now())
->first();
if (!$promo) {
return $total;
}
return $promo->type === 'percent'
? $total * (1 - $promo->value / 100)
: max(0, $total - $promo->value);
}
}Обратите внимание на границу транзакции. Оформление заказа — это несколько связанных изменений: создать заказ, зарезервировать товар, применить скидку, списать бонусы. Либо происходит всё, либо ничего: заказ без резерва товара хуже, чем отсутствие заказа.
Отсюда правило: транзакцией управляет сервис, а не контроллер и не репозиторий. Сервис — единственный слой, который знает границы операции целиком. Контроллер про них не знает (он работает с HTTP), репозиторий не знает (он работает с одной сущностью).
И следствие, о котором забывают: внутри транзакции нельзя делать необратимые внешние действия. Отправленное письмо не откатится вместе с транзакцией. Отправка уведомлений, вызовы внешних API и постановка задач в очередь — после коммита (DB::afterCommit() или соответствующее свойство задачи).
Регистрация в контейнере
// app/Providers/AppServiceProvider.php
namespace App\Providers;
use App\Services\OrderService;
use App\Services\DiscountService;
use App\Services\StockService;
use App\Services\CrmService;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// Обычно не нужно — Laravel автоматически резолвит через Reflection
// Но можно явно настроить:
$this->app->singleton(CrmService::class, function ($app) {
return new CrmService(
baseUrl: config('services.crm.url'),
apiKey: config('services.crm.key'),
);
});
}
}Тестирование
// tests/Unit/Services/OrderServiceTest.php
namespace Tests\Unit\Services;
use Tests\TestCase;
use App\Models\User;
use App\Models\Product;
use App\Services\OrderService;
use App\Services\DiscountService;
use App\Services\StockService;
use App\Services\CrmService;
use App\Exceptions\InsufficientStockException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
class OrderServiceTest extends TestCase
{
use RefreshDatabase;
private OrderService $service;
protected function setUp(): void
{
parent::setUp();
// Мокаем CRM — не хотим реальных запросов
$crmMock = Mockery::mock(CrmService::class);
$crmMock->shouldReceive('sendOrderAsync')->andReturn(null);
$this->service = new OrderService(
new DiscountService(),
new StockService(),
$crmMock,
);
}
public function test_creates_order_successfully(): void
{
$user = User::factory()->create();
$product = Product::factory()->create([
'price' => 1000,
'stock' => 10,
]);
$order = $this->service->createOrder(
user: $user,
items: [
['product_id' => $product->id, 'quantity' => 2],
],
deliveryAddress: 'ул. Тестовая, 1',
);
$this->assertEquals(2000, $order->total);
$this->assertEquals('pending', $order->status);
$this->assertCount(1, $order->items);
// Проверяем списание со склада
$this->assertEquals(8, $product->fresh()->stock);
}
public function test_throws_exception_when_insufficient_stock(): void
{
$user = User::factory()->create();
$product = Product::factory()->create([
'stock' => 5,
]);
$this->expectException(InsufficientStockException::class);
$this->service->createOrder(
user: $user,
items: [
['product_id' => $product->id, 'quantity' => 10],
],
deliveryAddress: 'ул. Тестовая, 1',
);
}
}Не увлекайтесь! Не создавайте сервис для каждого метода. Service Layer нужен для сложной бизнес-логики, которая переиспользуется или требует тестирования.
Структура папок
app/
├── Http/
│ └── Controllers/ # Тонкие контроллеры
├── Services/
│ ├── Order/
│ │ ├── OrderService.php
│ │ ├── OrderCalculator.php
│ │ └── OrderExporter.php
│ ├── Payment/
│ │ ├── PaymentService.php
│ │ └── RefundService.php
│ └── Integrations/
│ ├── CrmService.php
│ └── DeliveryService.php
├── Exceptions/
│ └── InsufficientStockException.php
└── ...Итоги
| Слой | Ответственность |
|---|---|
| Controller | HTTP: валидация, ответы, редиректы |
| Service | Бизнес-логика, координация |
| Model | Данные, связи, скоупы |
| Repository | Сложные запросы (опционально) |
Правила, которые действительно помогают.
Критерий выноса — не количество строк, а природа кода. «Больше десяти строк» — плохой признак: десять строк работы с HTTP-ответом останутся в контроллере, а три строки бизнес-правила должны переехать в сервис. Правильный вопрос: изменится ли этот код, если завтра ту же операцию нужно будет выполнить из консольной команды или из обработчика вебхука? Если нет — это бизнес-логика, ей место в сервисе.
Сервис не знает про HTTP. Ни Request, ни Response, ни redirect(), ни session(). Не ради чистоты, а потому что иначе ту же операцию нельзя вызвать из команды или из очереди — а её обязательно понадобится вызвать.
Сервис управляет транзакцией. Он единственный видит границы операции целиком.
Признаки свалки, по которым видно, что пора делить: конструктор с шестью зависимостями; в названии класса есть «And» или «Manager»; методы класса не используют одни и те же зависимости (значит, это два разных класса, живущих под одной крышей). Последний признак самый надёжный и проверяется за минуту.
Сервисы возвращают результат, а не выбрасывают HTTP-исключения. abort(404) внутри сервиса делает его непригодным вне веб-контекста; выбрасывайте доменное исключение, а контроллер пусть переводит его в код ответа.
Когда сервис не нужен. Для простого CRUD, где контроллер только валидирует и сохраняет, дополнительный слой не даёт ничего, кроме файла. Не бойтесь оставить Model::create($request->validated()) прямо в контроллере — это честнее, чем сервис из одной строки, созданный ради единообразия.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.