Введение
Laravel завоевал авторитет у бизнеса и программистов за эффективность решения задач. По данным BuiltWith (данные на ноябрь 2025), Laravel используется на более чем 700 000 сайтах, а основной репозиторий имеет свыше 75 000 звёзд на GitHub — это один из самых популярных PHP-фреймворков в мире. Дружелюбная подача, удобная среда, гибкость, не слишком строгие требования к коду сделали его выбором для стартапов и enterprise-проектов.
Автор не раз встречал суждение среди коллег, что опыт разработки на Symfony и Laravel равнозначны. Оба хороши, все молодцы. На самом деле Laravel ускоряет разработку, но цена скорости — риск архитектурного расползания логики. Если проект живёт больше года, это становится проблемой. Ниже — 7 ловушек Laravel и решений без отказа от фреймворка.

Два пути разработчика
Первый вариант: разработчик имеет опыт на Laravel. Переходя на Symfony, его удивят незнакомые правила, термины и на первый взгляд непонятно зачем ограничивающие подходы:
Разделение кода по бандлам
Явная DI через
services.yamlСлоистая архитектура (Controller → Service → Repository)
Обязательное использование интерфейсов для зависимостей
Строгая типизация и иммутабельность
Тестируемость через изоляцию зависимостей
Отсутствие фасадов
Многое из этого кажется избыточным, хотя всё это имеет смысл.
Второй вариант: разработчик имеет опыт на Symfony и попадает на Laravel-проект. Ему уже знакомы строгие правила.
Более быстрый цикл разработки в Laravel и более низкие требования к коду сразу бросятся ему в глаза. Плюс заключается в творческой свободе, но риск — в меньшей дисциплине на проекте.
Моя ситуация — вторая. Поделюсь, как из неё выходил.
Терминология: В примерах ниже под Command подразумевается объект-запрос (DTO), описывающий намерение пользователя, а под Handler — объект, реализующий соответствующую бизнес-операцию.
Проблема #1: Eloquent как Active Record
В чём проблема?
Laravel Eloquent смешивает три слоя в одном классе:
Доменные данные (свойства модели)
Бизнес-поведение (методы вроде
changeBalance())Инфраструктуру (SQL, timestamps, mass-assignment,
save(),delete())
// Laravel по умолчанию class User extends Model { public function changeBalance(int $amount): void { $this->balance -= $amount; // Бизнес-логика $this->save(); // Инфраструктура (SQL) } }
Проблемы:
Бизнес-логика привязана к базе данных
Невозможно протестировать
changeBalance()без реального соединения с БДНарушение Single Responsibility Principle
Атрибуты можно присвоить напрямую (магия Eloquent), инварианты обходятся:
$user->balance = -1000
Как преодолеть в теории?
Разделить слои: доменная модель отдельно, репозиторий отдельно:
Примечание: Примеры рассчитаны на PHP 8.2+.
// Доменная модель (чистый PHP, без Laravel) final readonly class User { public function __construct( public UserId $id, public string $email, public Money $balance, ) {} public function changeBalance(Money $amount): self { if ($this->balance->lessThan($amount)) { throw new InsufficientFundsException(); } return new self( id: $this->id, email: $this->email, balance: $this->balance->subtract($amount), ); } } // Репозиторий (инфраструктура) interface UserRepositoryInterface { public function findById(UserId $id): ?User; public function save(User $user): void; } class EloquentUserRepository implements UserRepositoryInterface { public function save(User $user): void { UserEloquentModel::updateOrCreate( ['id' => $user->id->value()], ['balance' => $user->balance->amount()] ); // ⚠️ ВАЖНО: Если у User есть сложные связи (hasMany, belongsToMany), // потребуется ручной менеджмент каждой связи. } }
Что это даёт:
Бизнес-логика независима от инфраструктуры и легко тестируется
Инварианты защищены
Практичный компромисс:
Полное разделение домена и инфраструктуры часто слишком дорого. Если остаётесь с Eloquent, держите модели тонкими:
class User extends Model { // Ограничьте массовое присваивание protected $guarded = ['id', 'balance']; // Value Object через касты (Laravel 11+) protected function casts(): array { return [ 'balance' => Money::class, // Защищает инвариант "баланс >= 0" ]; } // Аксессор/мутатор защищает инвариант protected function balance(): Attribute { return Attribute::make( set: fn (int $value) => $value >= 0 ? $value : throw new InvalidArgumentException('Balance cannot be negative') ); } } // Создавайте через именованные фабричные методы вместо прямого User::create() class UserFactory { public static function createWithInitialBalance(string $email, Money $balance): User { if ($balance->isNegative()) { throw new InvalidArgumentException('Initial balance must be non-negative'); } $user = new User(); $user->email = $email; $user->balance = $balance->amount(); // Мутатор проверит >= 0 $user->save(); return $user; } }
Это не заменяет чистую архитектуру, но снижает риски без полной переписки проекта.
Альтернатива — Doctrine ORM?
В типовом стеке Laravel (Nova, Telescope, Horizon, Scout/Scout-Drivers) ожидается Eloquent; при использовании Doctrine часто возникают несовместимости и рост стоимости интеграции. Немало сторонних пакетов придётся адаптировать или искать альтернативы.
Проблема #2: FormRequest как место бизнес-валидации
В чём проблема?
Laravel предлагает помещать правила валидации в FormRequest, который живёт в UI-слое:
class CreateUserRequest extends FormRequest { public function rules(): array { return [ 'email' => 'required|email|unique:users', // в одну строку словно из викторианской эпохи 'age' => 'required|integer|min:18', // Это бизнес-правило! ]; } } class UserController extends Controller { public function store(CreateUserRequest $request) { // Валидация уже прошла User::create($request->validated()); } }
Проблемы:
Бизнес-правило "возраст >= 18" живёт рядом с контроллером
UI-разработчик может случайно изменить бизнес-логику
Невозможно переиспользовать правила в CLI-командах или очередях
Нарушение Dependency Rule: UI-слой не должен содержать бизнес-правила
Как преодолеть?
Перенести валидацию в Application Layer. Элегантное решение — Validated Command DTO наследует Command:
// Command (чистый DTO, framework-agnostic) readonly class CreateUserCommand { public function __construct( public string $email, public int $age, ) {} } // Validated Command DTO наследует Command и добавляет правила final readonly class CreateUserValidatedDto extends CreateUserCommand implements ValidatableInterface { public static function rules(): array { return [ 'email' => ['required', 'email', 'unique:users,email'], 'age' => ['required', 'integer', 'min:18'], // Бизнес-правило ]; } } // DtoFactory валидирует и создаёт DTO interface DtoFactoryInterface { public function validateAndCreate(string $dtoClass, mixed $validatableData): mixed; public function fromArray(string $dtoClass, array $data): mixed; } final class DtoFactory implements DtoFactoryInterface { public function validateAndCreate(string $dtoClass, mixed $validatableData): mixed { $data = $this->toArray($validatableData); // Command|array → array $validator = Validator::make($data, $dtoClass::rules()); if ($validator->fails()) { throw new ValidationException($validator); } return $this->fromArray($dtoClass, $data); } public function fromArray(string $dtoClass, array $data): mixed { return new $dtoClass(...$data); } private function toArray(mixed $data): array { // Упрощённая реализация без Reflection для демонстрации if (is_array($data)) { return $data; } // Для readonly DTO с публичными типизированными свойствами return get_object_vars($data); } } // Handler использует DtoFactory final class CreateUserHandler { public function __construct( private readonly DtoFactoryInterface $dtoFactory, private readonly UserRepositoryInterface $repository, ) {} public function handle(CreateUserCommand $command): Result { try { // Валидация через Validated Command DTO $validatedDto = $this->dtoFactory->validateAndCreate( CreateUserValidatedDto::class, $command // DtoFactory сам преобразует в array через toArray() ); } catch (ValidationException $e) { // Преобразуем исключение валидации в Result::failure() return Result::failure($e->errors()); } // Создание пользователя с валидированными данными... return Result::success($user); } } // Controller — тонкий, только маршрутизация class UserController extends Controller { public function __construct( private readonly MessageBusInterface $bus, ) {} public function store(Request $request): JsonResponse { $command = new CreateUserCommand( email: $request->input('email'), age: (int) $request->input('age'), ); $result = $this->bus->dispatch($command); return $result->isSuccess() ? response()->json($result->data(), 201) : response()->json(['errors' => $result->errors()], 422); } }
Преимущества Validated Command DTO:
Command остаётся чистым (не знает о валидации)
Rules в Validated Command DTO переиспользуемы в HTTP, CLI, очередях и тестах
Типизация:
CreateUserValidatedDto— это тип-гарантия валидности данныхБизнес-правила живут в Application Layer, где им место
Контроллер тонкий: только создаёт Command и возвращает результат
Используется стандартный Laravel Validator — нет конфликтов с экосистемой
Консистентная модель ошибок: ValidationException перехватывается в Handler и преобразуется в
Result::failure()— единый способ работы с ошибками
Проблема #3: Policy и Gate — смешивание авторизации с бизнес-логикой
В чём проблема?
Laravel Policy часто содержит не только проверку прав, но и бизнес-логику:
class PostPolicy { public function update(User $user, Post $post): bool { // Это авторизация if ($user->id !== $post->author_id) { return false; } // А это бизнес-правило! if ($post->published_at && $post->published_at->diffInHours(now()) > 24) { return false; // Нельзя редактировать через 24 часа после публикации } return true; } }
Проблемы:
Бизнес-правило "нельзя редактировать после 24 часов" живёт в UI-инфраструктуре
Нарушение SRP: Policy делает и авторизацию, и бизнес-проверки
Невозможно переиспользовать правило в других контекстах
Как преодолеть?
Разделить авторизацию и бизнес-правила:
// Доменная модель знает свои правила final readonly class Post { public function __construct( public PostId $id, public UserId $authorId, public ?DateTimeImmutable $publishedAt, ) {} public function canBeEdited(): bool { if (!$this->publishedAt) { return true; // Черновик всегда можно редактировать } $hoursSincePublished = (time() - $this->publishedAt->getTimestamp()) / 3600; return $hoursSincePublished <= 24; } public function isAuthoredBy(UserId $userId): bool { return $this->authorId->equals($userId); } } // Policy — только авторизация class PostPolicy { public function update(User $user, Post $post): bool { return $post->isAuthoredBy(new UserId($user->id)); } } // Handler проверяет бизнес-правила final class UpdatePostHandler { public function handle(UpdatePostCommand $command): Result { $post = $this->repository->findById($command->postId); if (!$post->canBeEdited()) { return Result::failure(['Post cannot be edited after 24 hours']); } // Обновление поста... } }
Что это даёт:
Policy содержит только авторизацию, бизнес-правило
canBeEdited()— в доменной моделиЛегко тестировать без инфраструктуры
Проблема #4: Jobs с traits — логика смешана с транспортом
В чём проблема?
php artisan make:job создаёт класс с Dispatchable, Queueable traits:
class SendWelcomeEmail implements ShouldQueue { use Dispatchable, Queueable; public function __construct( public User $user, // Eloquent модель ) {} public function handle(Mailer $mailer): void { $mailer->send(new WelcomeEmail($this->user)); } } // Вызов SendWelcomeEmail::dispatch($user);
Проблемы:
Job — это и Command (данные), и Handler (логика), и конфигурация очереди
Передача Eloquent-модели в конструктор — антипаттерн (сериализация, N+1)
Привязка к Laravel (
Dispatchable,ShouldQueue)Невозможно отделить транспорт от поведения
Как преодолеть?
Разделить Command, Handler и конфигурацию:
// Command — чистый DTO с конфигурацией очереди final readonly class SendWelcomeEmailCommand implements QueueNameConfigurable, QueueConnectionConfigurable, RetriesConfigurable { public function __construct( public int $userId, // ID, а не модель ) {} public function getQueueName(): string { return 'emails'; } public function getQueueConnection(): string { return 'redis'; } public function getRetries(): int { return 3; } } // Handler — чистая логика final class SendWelcomeEmailHandler { public function __construct( private readonly UserRepositoryInterface $userRepository, private readonly MailerInterface $mailer, ) {} public function handle(SendWelcomeEmailCommand $command): Result { $user = $this->userRepository->findById($command->userId); if (!$user) { return Result::failure(['User not found']); } $this->mailer->send(new WelcomeEmail($user)); return Result::success(); } } // Вызов через MessageBus $this->bus->dispatch(new SendWelcomeEmailCommand(userId: $user->id));
Что это даёт:
Разделение ответственности: Command (данные), Handler (логика), конфигурация (интерфейсы)
Легко тестируется, нет сериализации Eloquent-моделей
Бонус: этот же паттерн решает проблему раздутых контроллеров (см. проблему #5 ниже)
Проблема #5: Фасады и глобальные хелперы упрощают раздувание контроллеров
В чём проблема?
Laravel предоставляет глобальный доступ к инфраструктуре через фасады (Auth, DB, Mail) и хелперы (auth(), request()). Это архитектурная особенность фреймворка, которая ускоряет разработку, но снимает барьеры для раздувания контроллеров:
// Laravel не препятствует этому class OrderController extends Controller { public function store(Request $request) { // Всё доступно глобально — можно писать весь код здесь: $validated = $request->validate([...]); DB::beginTransaction(); try { $order = Order::create($validated); // Active Record foreach ($request->input('items') as $item) { OrderItem::create([...]); $product = Product::find($item['product_id']); $product->decrement('stock', $item['quantity']); } Auth::user()->notify(new OrderCreatedNotification($order)); // Фасад event(new OrderCreated($order)); // Хелпер PaymentGateway::charge(auth()->user(), $order->total); // Хелпер DB::commit(); return response()->json($order, 201); } catch (\Exception $e) { DB::rollBack(); Log::error($e); return response()->json(['error' => 'Failed'], 500); } } }
Почему это происходит:
Active Record (Eloquent) позволяет
Model::create()без репозиторияНет необходимости в явных зависимостях через конструктор
Контраст с Symfony:
В Symfony физически невозможно написать такой контроллер — нужны явные зависимости (EntityManager, Security, EventDispatcher), что естественным образом склоняет к выносу логики в сервисы.
Проблемы:
Контроллер знает о БД, аутентификации, уведомлениях, платежах
Невозможно протестировать логику без HTTP-запроса
Нарушен��е SRP: контроллер делает всё
Junior-разработчики не видят причин для рефакторинга — "и так работает"
Как преодолеть?
Использовать тот же паттерн Commands/Handlers (из решения проблемы #4):
// Controller — тонкий class OrderController extends Controller { public function __construct( private readonly MessageBusInterface $bus, ) {} public function store(Request $request): JsonResponse { $command = new CreateOrderCommand( userId: Auth::id(), items: $request->input('items'), ); $result = $this->bus->dispatch($command); return $result->isSuccess() ? response()->json($result->data(), 201) : response()->json(['errors' => $result->errors()], 422); } } // Handler инкапсулирует логику final class CreateOrderHandler { public function __construct( private readonly OrderRepositoryInterface $orderRepository, private readonly ProductRepositoryInterface $productRepository, private readonly PaymentGatewayInterface $paymentGateway, private readonly EventDispatcherInterface $eventDispatcher, ) {} public function handle(CreateOrderCommand $command): Result { // Вся логика здесь: валидация, создание заказа, списание, оплата // 100% тестируемо без HTTP } }
Что это даёт:
Тонкий контроллер (10 строк), вся логика в Handler
Легко тестируется и переиспользуется (HTTP, CLI, очереди)
Решается обе проблемы одним паттерном: и Jobs с traits, и раздутые контроллеры
Проблема #6: Laravel склоняет к сериализации Eloquent моделей в события
В чём проблема?
Laravel Event System поощряет передачу Eloquent моделей в конструктор событий. Это архитектурная особенность, удобная для быстрой разработки, но ведущая к проблемам:
class OrderCreated { use SerializesModels; public function __construct( public Order $order, // Eloquent модель ) {} } // Генерируем событие event(new OrderCreated($order)); // Listener получает модель со всеми связями class SendOrderConfirmation { public function handle(OrderCreated $event): void { // Listener может делать всё с моделью: $event->order->user->notify(new OrderConfirmationNotification()); // N+1 query $event->order->update(['notified_at' => now()]); // Изменяет БД PaymentGateway::charge($event->order->user, $event->order->total); // Side-effects } }
Почему Laravel склоняет к этому:
Трейт
SerializesModelsавтоматически сериализует Eloquent модели для очередейДокументация Laravel показывает примеры с передачей
$user,$orderв событияУдобно: не нужно вручную мапить данные в DTO
Проблемы:
N+1 запросы:
$event->order->userможет вызвать ленивую загрузкуУстаревание данных: если событие в очереди, модель может быть неактуальной
Нет изоляции: Listener знает об Eloquent, фасадах, инфраструктуре
Сложная отладка: непонятно, кто и когда изменил модель
Как преодолеть?
Использовать доменные события с чистыми данными (DTO):
// Доменное событие (чистый DTO) final readonly class OrderCreatedEvent { public function __construct( public OrderId $orderId, public UserId $userId, public Money $total, public DateTimeImmutable $createdAt, ) {} } // Listener — только делегирует Command class SendOrderConfirmationListener { public function __construct( private readonly MessageBusInterface $bus, ) {} public function handle(OrderCreatedEvent $event): void { $this->bus->dispatch(new SendOrderConfirmationCommand( orderId: $event->orderId->value(), userId: $event->userId->value(), )); } } // Вся логика — в Handler final class SendOrderConfirmationHandler { public function handle(SendOrderConfirmationCommand $command): Result { // Изолированная логика: отправка email, обновление статуса } }
Что это даё��:
Событие — чистый DTO, Listener тонкий, вся логика в Handler
Полностью тестируемо
Защита через статический анализ:
Чтобы гарантировать, что новый код не использует проблемные трейты, следует запрещать их через кастомное правило PHPStan (реализуется как extension в phpstan.neon):
// Пример: список запрещённых трейтов в кастомном правиле $forbiddenTraits = [ 'Illuminate\Queue\SerializesModels', 'Illuminate\Foundation\Bus\Dispatchable', 'Illuminate\Bus\Queueable', 'Illuminate\Queue\InteractsWithQueue', 'Illuminate\Bus\Batchable', 'Illuminate\Foundation\Events\Dispatchable', ];
Это предотвращает:
Сериализацию Eloquent моделей в события/Commands
Смешивание логики с транспортом (Dispatchable, Queueable)
Прямое знание о Laravel инфраструктуре в доменном коде
Проблема #7: Отсутствие различий между типами сервисов
В чём проблема?
Это типичная проблема PHP-проектов, но Laravel тоже не предоставляет структуру для её решения. Документация Laravel тоже не делает различий между Application Services и Domain Services, что приводит к хаотичной папке App\Services:
// Типичный Laravel проект namespace App\Services; class UserService // Что это? Application Service? Domain Service? { public function createUser(array $data): User { // Валидация? Логика? Сохранение? Всё вместе $user = User::create($data); event(new UserCreated($user)); return $user; } public function calculateDiscount(User $user, Order $order): float { // Это доменная логика! Почему в Service? return $user->isVip() ? $order->total * 0.1 : 0; } }
Почему это происходит:
Laravel не предлагает структуру для разных типов сервисов
Документация использует общий термин "Service" без различий
Нет примеров разделения Application vs Domain Services
Контраст с Symfony/DDD:
В зрелых проектах чётко разделяются:
Application Services (Use Cases, Handlers) — оркестрация
Domain Services — бизнес-логика, не принадлежащая сущности
Infrastructure Services — работа с внешними системами
Проблемы:
Нет различий между Application Service и Domain Service
UserServiceсмешивает оркестрацию (createUser) и доменную логику (calculateDiscount)Непонятно, куда добавлять новую логику
Junior-разработчики кладут всё подряд в Services
Как преодолеть?
Разделить на Application Services (Commands/Handlers) и Domain Services:
// Application Service = Handler final class CreateUserHandler { public function handle(CreateUserCommand $command): Result { // Оркестрация: валидация, создание, событие } } // Domain Service (если логика не принадлежит ни одной сущности) final class DiscountCalculator { public function calculate(User $user, Order $order): Money { // Чистая доменная логика без знания об инфраструктуре return $user->isVip() ? $order->total->multiply(0.1) : Money::zero(); } }
Что это даёт:
Чёткое разделение ответственности и мест для добавления кода
Нет "божественных" классов
MessageBus: надстройка над Laravel
Во всех примерах используется MessageBusInterface — тонкая надстройка над родным \Illuminate\Bus\Dispatcher. Что она даёт:
Типизирует Commands через
CommandRepresentationОтделяет Commands от транспорта: не важно, синхронно или асинхронно
Изолирует очереди: конфигурация в интерфейсах Command, не в Laravel traits
Упрощённая реализация
use Illuminate\Bus\Dispatcher; final readonly class MessageBus implements MessageBusInterface { public function __construct(private Dispatcher $dispatcher) {} public function dispatch(CommandRepresentation $command): ResponseRepresentation { // Если Command реализует интерфейсы очередей (ShouldQueue, QueueNameConfigurable) if ($this->shouldQueue($command)) { // Оборачиваем Command в Job и отправляем в очередь $job = new QueueableCommandJob($command); $this->configureQueue($job, $command); $this->dispatcher->dispatchToQueue($job); return new NullResponse(); } // Иначе выполняем синхронно return $this->dispatcher->dispatchNow($command); } public function map(array $map): void { $this->dispatcher->map($map); } }
Регистрация Handlers
Маппинг Commands на Handlers в AppServiceProvider::boot():
$this->app->make(MessageBusInterface::class)->map([ CreateUserCommand::class => CreateUserHandler::class, UserBalanceSendCommand::class => UserBalanceSendCommandHandler::class, // ... ]);
Result-тип: ошибки как данные
Handlers возвращают Result вместо исключений — ошибки становятся явными и предсказуемыми:
// Result — реализация ResponseRepresentation final readonly class Result implements ResponseRepresentation { public function __construct( public bool $isSuccess, public array $messages = [], public mixed $data = null, ) {} public static function success(mixed $data = null): self { return new self(isSuccess: true, data: $data); } public static function failure(array $messages): self { return new self(isSuccess: false, messages: $messages); } public function errors(): array { return $this->messages; } } // Использование в Handler if ($validator->fails()) { return Result::failure($validator->errors()->all()); } return Result::success($user);
Преимущество: контроллер явно обрабатывает успех/ошибку без try-catch блоков.
Заключение
Laravel — отличный фреймворк для быстрого старта. Он не запрещает хорошую архитектуру, но и не стимулирует её.
«Равнозначность» опыта в Laravel и Symfony — миф. Его архитектура «по умолчанию» склоняет к плохим практикам:
Eloquent смешивает слои → Разделить на доменные модели + репозитории (с учётом ручного менеджмента сложных связей)
FormRequest в UI‑слое → Валидация в Application Layer (Validated Command DTO + DtoFactory)
Policy с бизнес‑логикой → Авторизация отдельно, бизнес‑правила в домене
Jobs с traits → Commands (DTO) + Handlers + конфигурация отдельно
Фасады упрощают раздувание контроллеров → Тонкие Controllers + Handlers (используя паттерн из #4)
Сериализация Eloquent в события → Доменные события (DTO) + тонкие Listeners + запрет трейтов через PHPStan
Нет различий между типами сервисов → Application Services (Handlers) vs Domain Services (типичная проблема проектов, но Laravel не помогает)
Продемонстрированные решения совместимы с DDD, CQRS и Hexagonal архитектурой — без оверхеда полной их имплементации. Вы получаете:
Тестируемость: логика независима от инфраструктуры
Масштабируемость: чёткие слои и зоны ответственности
Переиспользуемость: Commands работают и в HTTP, и в CLI, и в очередях
Предсказуемость: ошибки — это данные (Result), а не исключения
С Symfony сама среда располагает к строгому коду. Laravel же, выражаясь метафорически, это чёртик над ухом, который постоянно советует «согрешить», поэтому чтобы писать код для долгосрочной поддержки вам понадобится дополнительный уровень профессионализма.
А как вы решаете архитектурные проблемы в Laravel-проектах? Делитесь идеями.