Главная/Статьи/Service Layer в Laravel — выносим логику из контроллеров

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()) прямо в контроллере — это честнее, чем сервис из одной строки, созданный ради единообразия.

🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.