Проект на 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»:
- Добавить колонку nullable.
- Заполнить данные в batch-job.
- Добавить 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 и безопасность
- Умеет ранжировать риски по вероятности и влиянию