Введение

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

Автор не раз встречал суждение среди коллег, что опыт разработки на Symfony и Laravel равнозначны. Оба хороши, все молодцы. На самом деле Laravel ускоряет разработку, но цена скорости — риск архитектурного расползания логики. Если проект живёт больше года, это становится проблемой. Ниже — 7 ловушек Laravel и решений без отказа от фреймворка.

Ловушки архитектуры Laravel
Ловушки архитектуры 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 — миф. Его архитектура «по умолчанию» склоняет к плохим практикам:

  1. Eloquent смешивает слои → Разделить на доменные модели + репозитории (с учётом ручного менеджмента сложных связей)

  2. FormRequest в UI‑слое → Валидация в Application Layer (Validated Command DTO + DtoFactory)

  3. Policy с бизнес‑логикой → Авторизация отдельно, бизнес‑правила в домене

  4. Jobs с traits → Commands (DTO) + Handlers + конфигурация отдельно

  5. Фасады упрощают раздувание контроллеров → Тонкие Controllers + Handlers (используя паттерн из #4)

  6. Сериализация Eloquent в события → Доменные события (DTO) + тонкие Listeners + запрет трейтов через PHPStan

  7. Нет различий между типами сервисов → Application Services (Handlers) vs Domain Services (типичная проблема проектов, но Laravel не помогает)

Продемонстрированные решения совместимы с DDD, CQRS и Hexagonal архитектурой — без оверхеда полной их имплементации. Вы получаете:

  • Тестируемость: логика независима от инфраструктуры

  • Масштабируемость: чёткие слои и зоны ответственности

  • Переиспользуемость: Commands работают и в HTTP, и в CLI, и в очередях

  • Предсказуемость: ошибки — это данные (Result), а не исключения

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


А как вы решаете архитектурные проблемы в Laravel-проектах? Делитесь идеями.

Комментарии (3)


  1. PelmenBlin
    26.11.2025 19:43

    Исправленное заключение:

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

    P.S. Laravel поэтому и стал популярными, что не выносит голову "какой же я сервис делаю Domain или Application?".

    Как думаете много разработчиков на старте хотят забивать себе голову этими материями?


  1. PelmenBlin
    26.11.2025 19:43

    И да вы пишите о высоких материях, а в Request проверка:

    'required|email|unique:users', // в одну строку словно из викторианской эпохи

    Разделите строку на массив построчный и ревью правок человеку легче будет делать при добавлении или изменении 1 условия.


  1. karrakoliko
    26.11.2025 19:43

    Переходя на Symfony, его удивят незнакомые правила <...> Разделение кода по бандлам

    ну вот еще. это копролиты, бандлы давно уже (с 5 версии?) не рекомендуются для организации кода.

    не позорьтесь