LaravelSeniorExperience

Проект на Laravel вырос из MVP в большой production-сервис. Какие проблемы архитектуры и сопровождения вы ожидаете увидеть?

Рост MVP до production проявляет жирные контроллеры, N+1 запросы, отсутствие доменного разделения, монолитные миграции, слабую observability и сложность масштабирования очередей.

Типичная стартовая архитектура MVP

MVP на Laravel обычно — это thin routes, толстые контроллеры с прямыми Eloquent-запросами, минимум тестов и всё в одном модуле. При росте нагрузки и команды это превращается в набор предсказуемых проблем.

Жирные контроллеры и отсутствие слоёв

Бизнес-логика в контроллерах нетестируема и не переиспользуема. Рефакторинг — выделение Service-классов, Action-объектов (паттерн Single Action) или команд CQRS:

<?php

// До рефакторинга: вся логика в контроллере
class OrderController extends Controller
{
    public function store(Request $request)
    {
        $order = Order::create($request->validated());
        $order->items()->createMany($request->items);
        Mail::to($order->user)->send(new OrderConfirmation($order));
        // ... ещё 50 строк
    }
}

// После: Action-класс
class CreateOrderAction
{
    public function execute(CreateOrderDTO $dto): Order
    {
        return DB::transaction(function () use ($dto) {
            $order = Order::create($dto->toArray());
            $order->items()->createMany($dto->items);
            OrderCreated::dispatch($order);
            return $order;
        });
    }
}

N+1 запросы

Самая частая проблема в Eloquent-коде. Диагностика через Laravel Debugbar или Telescope. Решение — with() / load():

<?php

// Проблема: N+1
$orders = Order::all();
foreach ($orders as $order) {
    echo $order->user->name; // SELECT * FROM users WHERE id = ? x N
}

// Решение
$orders = Order::with('user', 'items.product')->get();

// Или с ограничением полей
$orders = Order::with(['user:id,name,email'])->paginate(20);

Монолитные миграции и блокировки таблиц

Добавление NOT NULL колонок без дефолта в таблицы с миллионами строк блокирует таблицу на минуты. Решение — трёхшаговая «zero-downtime migration»:

  1. Добавить колонку nullable.
  2. Заполнить данные в batch-job.
  3. Добавить NOT NULL constraint и дефолт.

Модульность и доменное разделение

Все сущности в одном app/ приводят к circular dependencies и невозможности разделить команды. Переход к модульной структуре:

app/
  Modules/
    Orders/
      Actions/
      Models/
      Events/
      Http/Controllers/
      Providers/OrderServiceProvider.php
    Billing/
    Inventory/

Тесты и CI

MVP обычно не имеет тестов. При рефакторинге — начинать с интеграционных Feature-тестов через RefreshDatabase, затем покрывать Unit-тестами сервисы. Обязательно добавить GitHub Actions / GitLab CI с php artisan test --parallel.

Observability

  • Structured logging (JSON) + centralized log aggregator (Loki, Datadog, New Relic).
  • Sentry для error tracking: composer require sentry/sentry-laravel, конфиг SENTRY_LARAVEL_DSN.
  • Laravel Horizon для мониторинга очередей с метриками throughput и failed jobs.
  • Health check endpoint (/health) для балансировщика нагрузки.

Масштабирование очередей

Один supervisor с одним воркером не справится с ростом. Нужна сегментация очередей по приоритету и изолированные воркеры:

# Supervisord конфиг
[program:laravel-worker-critical]
command=php artisan queue:work redis --queue=critical,default --tries=3 --timeout=60
numprocs=4

[program:laravel-worker-bulk]
command=php artisan queue:work redis --queue=bulk --tries=1 --timeout=300
numprocs=2

Подводные камни

  • God Service — если вынести всю логику в один OrderService на 1000 строк, проблема не решена, просто перемещена из контроллера.
  • Eager loading без ограничений полейwith('user') тянет все колонки users, включая хэши паролей. Всегда указывайте with('user:id,name').
  • Event listeners в синхронном режиме — по умолчанию listeners выполняются синхронно. Реализация ShouldQueue решает это, но требует проверки, что listener идемпотентен при повторном запуске.
  • Миграции без транзакций — MySQL не поддерживает DDL-транзакции, поэтому падение на середине миграции оставляет схему в невалидном состоянии. Используйте $this->schema операции последовательно с rollback-логикой в down().
  • Scopes без индексов — добавление Global Scope с where('tenant_id', $id) без составного индекса (tenant_id, created_at) превращает каждый запрос в full scan.
  • Cache invalidation — кэшировать Eloquent-коллекции без стратегии инвалидации приводит к показу устаревших данных. Привяжите сброс кэша к Model events или используйте теги.

What hurts your answer

  • Говорить только о запуске Laravel, но не об эксплуатации
  • Не упоминать observability, обновления, безопасность и rollback
  • Описывать риски абстрактно, без способов их снижать

What they're listening for

  • Видит production-риски Laravel
  • Говорит про monitoring, rollout, rollback и безопасность
  • Умеет ранжировать риски по вероятности и влиянию

Related topics