Service Layer в Laravel — выносим логику из контроллеров

Паттерн Service Layer помогает структурировать код: контроллеры остаются тонкими, бизнес-логика переиспользуется, тестирование упрощается.

Дмитрий Мещеряков
Дмитрий Мещеряков
📅 15 июля 2026 г.📖 8 мин чтения

Сервисный слой — первое, что советуют при разросшемся контроллере, и обычно этот совет верен. Но у него есть неудачная формулировка, которая делает больше вреда, чем пользы: «выносите логику в сервисы». Следуя ей буквально, команда получает папку Services с классами на тысячу строк — тот же толстый контроллер, только переехавший.

Разберём, что именно выносить, и по каким признакам понять, что сервис стал свалкой.

Почему толстый контроллер это проблема

Типичная картина в Laravel-проекте:

php
// 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 — слой между контроллером и моделью, содержащий бизнес-логику. Контроллер только принимает запрос и возвращает ответ.

Что выносить в сервис, а что нет

php
// 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);
    }
}

Тонкий контроллер

php
// 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

php
// 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

php
// 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() или соответствующее свойство задачи).

Регистрация в контейнере

php
// 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'),
            );
        });
    }
}

Тестирование

php
// 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 нужен для сложной бизнес-логики, которая переиспользуется или требует тестирования.

Структура папок

text
app/
├── Http/
│   └── Controllers/          # Тонкие контроллеры
├── Services/
│   ├── Order/
│   │   ├── OrderService.php
│   │   ├── OrderCalculator.php
│   │   └── OrderExporter.php
│   ├── Payment/
│   │   ├── PaymentService.php
│   │   └── RefundService.php
│   └── Integrations/
│       ├── CrmService.php
│       └── DeliveryService.php
├── Exceptions/
│   └── InsufficientStockException.php
└── ...

Итоги

СлойОтветственность
ControllerHTTP: валидация, ответы, редиректы
ServiceБизнес-логика, координация
ModelДанные, связи, скоупы
RepositoryСложные запросы (опционально)

Правила, которые действительно помогают.

Критерий выноса — не количество строк, а природа кода. «Больше десяти строк» — плохой признак: десять строк работы с HTTP-ответом останутся в контроллере, а три строки бизнес-правила должны переехать в сервис. Правильный вопрос: изменится ли этот код, если завтра ту же операцию нужно будет выполнить из консольной команды или из обработчика вебхука? Если нет — это бизнес-логика, ей место в сервисе.

Сервис не знает про HTTP. Ни Request, ни Response, ни redirect(), ни session(). Не ради чистоты, а потому что иначе ту же операцию нельзя вызвать из команды или из очереди — а её обязательно понадобится вызвать.

Сервис управляет транзакцией. Он единственный видит границы операции целиком.

Признаки свалки, по которым видно, что пора делить: конструктор с шестью зависимостями; в названии класса есть «And» или «Manager»; методы класса не используют одни и те же зависимости (значит, это два разных класса, живущих под одной крышей). Последний признак самый надёжный и проверяется за минуту.

Сервисы возвращают результат, а не выбрасывают HTTP-исключения. abort(404) внутри сервиса делает его непригодным вне веб-контекста; выбрасывайте доменное исключение, а контроллер пусть переводит его в код ответа.

💡 Совет

Когда сервис не нужен. Для простого CRUD, где контроллер только валидирует и сохраняет, дополнительный слой не даёт ничего, кроме файла. Не бойтесь оставить Model::create($request->validated()) прямо в контроллере — это честнее, чем сервис из одной строки, созданный ради единообразия.

Частые вопросы

Что выносить в сервис, а что оставить в контроллере?

В контроллере остаётся всё, что касается HTTP: разбор запроса, валидация формы, формирование ответа, редиректы. В сервис уходит бизнес-логика и координация нескольких сущностей — оформление заказа, применение скидки, резервирование товара. Модель отвечает за данные, связи и скоупы. Репозиторий нужен только для сложных запросов и только если он действительно нужен.

Почему сервис превращается в тот же толстый контроллер?

Из-за буквального следования совету «выносите логику в сервисы». Если переносить код механически, получается папка Services с классами на тысячу строк, где всё так же перемешано. Признак, по которому это видно: сервис знает про HTTP-запрос, сам решает, что вернуть пользователю, и обслуживает несколько несвязанных сценариев. Сервис должен описывать одну операцию предметной области, а не быть свалкой методов одного контроллера.

Где должна открываться транзакция?

В сервисе. Он единственный слой, который знает границы операции целиком: контроллер про них не знает, потому что работает с HTTP, репозиторий не знает, потому что работает с одной сущностью. Оформление заказа — это несколько связанных изменений: создать заказ, зарезервировать товар, применить скидку, списать бонусы. Либо происходит всё, либо ничего, потому что заказ без резерва товара хуже, чем отсутствие заказа.

Почему нельзя отправлять уведомления внутри транзакции?

Потому что они необратимы. Отправленное письмо не откатится вместе с транзакцией, поэтому при откате клиент получит уведомление о заказе, которого нет. Отправка писем, вызовы внешних API и постановка задач в очередь должны происходить после коммита — через соответствующий механизм фреймворка или свойство задачи, отправляющее её только после успешного завершения транзакции.

Нужен ли сервис для каждого действия?

Нет, и это частая ошибка в обратную сторону. Service Layer нужен для сложной бизнес-логики, которая переиспользуется или требует тестирования. Простой CRUD-метод, который читает модель и отдаёт её в ответ, не выигрывает от лишнего слоя — тот только добавляет файл и лишний переход при чтении кода.

Как тестировать сервисный слой?

Подменяя зависимости через контейнер и проверяя поведение, а не вызовы. Регистрация сервисов с привязкой к интерфейсам позволяет заменить внешние зависимости в тесте, а сама бизнес-логика проверяется по результату: после оформления заказа товар зарезервирован, скидка применена, статус верный. Тест, проверяющий, что сервис вызвал такой-то метод репозитория, ломается при рефакторинге и ничего при этом не находит.