Hola Amigos! На связи Евгений Шмулевский, PHP-разработчик в Amiga. Начал заниматься программированием с 2001 года, привет Basic и Express/Turbo Pascal. Веб-разработкой — с 2011 года, а профессионально в вебе с 2013 года. Работал продолжительное время с Битрикс, а с 2018 начал осваивать Laravel. 

В статье я расскажу, как организую свой код в проектах, использующих Laravel. Решил немного структурировать, с чем удалось познакомиться после перехода в мир фреймворков из мира чудного (ударение можете сами поставить) Битрикс. Многие вещи стали для меня открытием и особенно переоткрыл для себя ООП. Начнем рассмотрение с практик организации кода проекта. Статья адресована начинающим разработчикам.

Давайте посмотрим, какие есть подходы к организации кода.

Организация кода в контроллерах

Когда я начал изучать Laravel (до этого еще знакомился с Zend/Yii/Phalcon), то писал всю бизнес логику в контроллерах. Так сделал пару своих пет-проектов (своя LMS, и CRM для HR) и особенно плохого в этом ничего не видел.

Действительно, организация кода в контроллерах является наиболее быстрым решением, но имеет ряд недостатков:

  • сложность переиспользования кода;

  • вносит некоторую запутанность в код, т.к. у контроллера появляется множество ответственностей, помимо его основной роли; 

  • читать и отлаживать код состоящий из большого количества строк достаточно проблематично (напомню, что вся бизнес логика в контроллерах);

  • из первых 2-х пунктов вытекает третий с проблемами масштабирования.

Почему же этот подход остается популярным? Первая причина — это то, что во время изучения фреймворка во многих курсах преподается данный подход, т.к. его легче усваивать. И далее уже разработчик идет по накатанной. Вторая причина — то, что данный подход прост в реализации и подходит для небольших приложений.

Использование команд (actions)

Далее познакомился с подходом с использованием команд. Команда — выполнение некоторого ОДНОГО действия. В таком случае бизнес-логика выносится в отдельный класс-команду.

В некоторых проектах сталкивался с тем, что весь код был вынесен из контроллера в команду просто через copy-paste. На входе у такого action объект Request. Считаю, что при использовании команд разработчику следует отвязаться от Request, либо через параметры, либо через DTO (о DTO напишу чуть ниже). Это позволяет использовать action вне контроллера и не зависеть от Request.

Второй момент, класс команды должен быть разбит на небольшие методы. Это позволит легче поддерживать код в будущем. Примеры наименования команд:

  • AuthAction

  • RegisterAction

  • StoreCommentAction

Ниже пример использования Action в упрощенном варианте (далее посмотрим, как сделать лучше):

  1. Controller обращается к action передавая DTO на вход.

  2. Action обрабатывает данные и возвращает ResultDTO, либо ничего.

  3. Грубо говоря, мы весь код из контроллера вынесли в action и поделили на методы.

Использование сервисов (services)

Дальнейшее изучение вопроса навело меня на Сервисы. С понятием «сервиса» я познакомился, изучая курс от Povilas Korop «How to Structure Laravel Projects».

Сервис, в отличии от action, может иметь несколько методов и является более крупным строительным блоком. Маттиас Нобак в книге «Объекты. Стильное ООП» приводит такое определение: сервисы — объекты, которые либо выполняют задачу, либо предоставляют информацию. Также автор отмечает, что сервисы не хранят свое состояние, т.к. для повторного использования сервиса придется заново его инициализировать, сбрасывая состояние.

Также сервисы могут реализовывать интерфейсы и внедряться через интерфейс. Сервис также как и команда должен иметь единственную ответственность.

Ниже схема использования сервисов:

  1. Controller обращается к Service/Repository передавая DTO на вход (либо просто параметры, если их количество менее 3).

  2. Service обрабатывает данные и возвращает ResultDTO, либо ничего.

  3. Service может внутри себя использовать другие Services (расчеты и сохранение данных) и Repository (выборка).

Если нам необходима просто выборка, то можно не использовать сервисы, а обращаться напрямую к классу репозитория.

Использование паттерна репозиторий

С репозиториями также познакомился на курсе от Povilas Korop. Паттерн репозиторий служит цели отделить логику работы с БД от бизнес-логики приложения. Лично для себя выделяю основной плюс в переиспользовании методов выборки. Примерами методов репозитория могут быть такие названия методов как:

  • getById()

  • getSellers

  • getUserList()

  • etc

Также репозиторий может использоваться для create/update/delete операций. Я предпочитаю в репозиторий выносить только все операции выборки, а для операций изменяющих БД использую сервисы.

Плюсы паттерна:

  • достаточно просто переиспользовать код;

  • простота миграции на другие БД (на практике мной не встречалось), т.к. у нас есть дополнительный слой абстракции.

Использование DTO

Еще один кирпичик это DTO, те специальный тип объекта предназначенный для передачи данных между другими объектами. Чаще всего DTO имеют набор публичных полей и методы для своего создания.

В чем плюсы использования DTO:

  • type-hint;

  • инкапсуляция;

  • разделение слоев приложения;

  • модификация данных.

DTO могут создаваться через обычный конструктор, либо через вызова метода который возвращает сам DTO. Примерами может служить вызов:

  • UserDTO::fromRequest($request)

  • UserDTO::fromArray($data

Начиная с php 8.0, можно задавать свойства прямо в конструкторе, определяя область видимости. Также для облегчения создания DTO есть пакет от spatie.

Складываем все вместе

Теперь посмотрим, как в приложении используются все вышеперечисленные блоки. Ниже примерная схема взаимодействия (читается справа налево).

  1. Controller обращается к action передавая DTO на вход.

  2. Action обрабатывает данные и возвращает ResultDTO, либо ничего.

  3. Action может внутри себя использовать Services (расчеты и сохранение данных) и Repository (выборка), о них подробнее ниже.

  4. Может быть вариант не использовать Repository непосредственно внутри Action, а вызывать только из Service, но считаю это излишним усложнением.

Организация структуры проекта

Немного слов о структуре. Поначалу пользовался штатным подходом, как изначально рекомендует нам документация Laravel. Проект разбивается на папки, которым соответствует функционал хранящихся в них классов. Типичная структура Laravel проекта:

  • app

    • Http

      • Controllers

      • Middlewares

      • Requests

    • Jobs

    • Models

    • Polices

    • ServiceProviders

    • Rules

    • итд

Данный подход хорош для быстрой разработки небольших приложений. В противовес этому подходу существует подход в организации классов, используя модули.

В идеальном случае, модуль — это независимая часть бизнес-логики. При модульной организации кода структура у нас получается примерно следующей:

 app

  • Modules

    • Admin

    • Order

      • Models

      • Services

      • Repositories

      • Requests

      • Controllers

      • итд

    • User

    • SMS

    • Security

    • итд

  • ServiceProviders

  • итд

Код разбивается, исходя из логики принадлежности к Домену. Есть также упрощенный вариант, когда Controllers не выносится в папку с модулями, а остается в изначальной директории app/Http/Controllers.

Плюсами данного подхода является:

  • Проще переиспользовать код на других проектах. При необходимости переносится модуль целиком и подключается на новом проекте.

  • У кода меньше зависимостей, т.к. модули имеют минимум связей.

  • Разные модули могут разрабатывать разные разработчики.

  • Проще в поддержке и масштабировании.

Минусы:

  • Основным минусом является увеличение количества директорий.

  • Возможно допустить ошибку при разделении кода на модули. И в будущем придется рефакторить данный код.

Мой способ организации кода

  1. Классы находятся в папке app и разбиты по модулям. Каждый модуль относится к определенной сущности либо бизнес-процессу.

  2. Для выборок данных использую паттерн репозиторий.

  3. Бизнес-логика и операции создания/изменения моделей выношу в сервис-классы. Сервис классы не хранят свое состояние, что позволяет их переиспользовать без повторной инициализации.

  4. Для того чтобы не зависеть от Request в сервисы передаю либо одиночные параметры, либо DTO. Это позволяет переиспользовать код вне контроллеров (например, команда создания нового пользователя и т.д.).

  5. Стараюсь, чтобы модели оставались максимально тонкими. В основном содержат в себе связи (relations).

  6. Контроллеры остаются на своих местах, но создаю папку согласно наименованию модуля.

  7. Для создания объектов на лету использую статическую фабрику (static factory).

  8. Если используем сервис, и он выполняет единственное действие.

  9. Как именую методы:

  • getSomething — получение информации. Важный момент — отсутствие side эффектов.

  • isSomething — метод для проверки, должен возвращать.  

Вот основная логика организации проекта. Для примера создал на github репозиторий с примером простого проекта (авторизация + выборка сущностей) организованный по модулям. Его можно использовать в качестве шаблона для новых проектов. Надеюсь, будет полезно!

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


  1. IgorAlentyev
    10.09.2023 17:14
    +2

    Имхо, логичнее было бы из контроллера вызывать сервис, который делает какую то свою бизнес-логику, вызывает Actionы (для записи) и репозитории (для чтения) или другие сервис классы. В таком случае контроллер занимается валидацией, преобразованием реквеста в DTO и ответом на запрос и код можно переиспользоваться в веб/апи роутах, консольных командах.

    А в вашем варианте нужно по сути копировать код метода контролера для этого, да и в целом нарушается принцип использования Action классов. Они придуманы для выполнения какого то одного действия, а не вызовов сервисов и репозиториев.

    Рекомендую к прочтению вот этого чела - https://martinjoo.dev/


    1. shmoulevsky Автор
      10.09.2023 17:14

      Спасибо Вам за информацию. Обязательно ознакомлюсь.

      Насчет единственного действия action здесь зависит как посмотреть.
      Например есть бизнес-процесс оформления заказа который может состоять из шагов:

      • расчет скидок и пр.,

      • сохранение сущностей,

      • рассылка уведомлений и др.

      Этот бизнес-процесс можно как раз и рассматривать как некоторое единое действие.

      В таком случае у нас есть action который оформляет заказ, но содержит в себе сервисы и репозитории нужные для каждого из шагов. Далее этот же action можно использовать в консольных командах (тк данные на вход через DTO подаются). Кроме того эти же сервисы мы можем использовать отдельно в других местах. Как вариант можно заменить action сервисом который также будет в себе агрегировать другие сервисы соответствующие шагам оформления заказов.


      1. IgorAlentyev
        10.09.2023 17:14

        Согласен что не сильно принципиально)


    1. Serganbus
      10.09.2023 17:14

      Не обязательно рассматривать Actions как средства для записи в БД. Здесь речь идет скорее об Actions-слое(в терминал Фаулера - Transactions Script), который реализует бизнес-логику. Если вам не по нраву названия Actions, можете переименовать в Handlers или Invokers - без разницы, важна суть) То есть у DDD есть несколько разных реализаций, одна из которых приведена здесь. Автор говорит о том, что Actions не должны иметь зависимостей от БД(нужно использовать репозитории) и должны использовать сервисы, которые реализуют опять-таки интерфейсы.

      Главный плюс такого подхода - это совершенно удобное и простое написание юнит-тестов на бизнес-логику, расположенную в Actions.


  1. pfffffffffffff
    10.09.2023 17:14

    А как вы решаете бизнес процессы пересекаются? Например есть заказ и покупатель. В каком модуле должен происходить процесс создания заказа? В user или order? Или вы создаете отдельный связующий модуль?


    1. shmoulevsky Автор
      10.09.2023 17:14

      В Вашем примере я бы вынес процесс оформления заказа в модуль Order в сервис OrderCreateService (заказ может создаваться например не только из под обычного пользователя, но и менеджером магазина, через обмен с внешними системами и др.).

      Можно еще объединить модули которые имеют тесные связи, например, так для модулей магазина: Sale -> Order|Discount|etc, для модулей каталога Catalog -> Product|Feature|etc

      Те есть модули агрегирующие в себе другие модули.


  1. SaintSet
    10.09.2023 17:14
    +2

    Пришел почти к такому же виду после нескольких лет работы с laravel, очень похожее получилось. И на самом деле схема очень удачная на мой взгляд. Но советую посмотреть в сторону вынесения бизнес логики в директорию source, что бы можно было отделить сервисы к примеру от неймспейса приложения и тогда в контроллерах use будет выглядеть след. образом use Source\Modules\User\CreateService;. Это первый момент а второй момент, в файлах директории source сразу будут видны зависимости из app и там можно пресечь протекание, не нужных зависимостей в бизнес логику, или допустим исключить попадание реквестов в бизнес логику. Но поработав с такими схемами пришел к выводу 1 - накопилось много кода на разных проектах который нужно выносить в отдельные репозитории, например абстрактный репозиторий там есть что сделать на самом деле, 2 - появились мысли о том как сделать еще более независимой логику, а это уже переход на подобие DDD, но вот пока не доберусь до реализации. А вообще радует что у кого то схожие мысли появились)))


  1. Maffinca
    10.09.2023 17:14
    +1

    На мой взгляд, подход здравый и оптимальный

    Если нам необходима просто выборка, то можно не использовать сервисы, а обращаться напрямую к классу репозитория.

    Зависит от бизнес-логики. В некоторых случаях выборку из репозитория стоит вызвать в сервисе, если предполагается дополнительная логика (какой-либо маппинг, рантайм фильтрация и т.п)

    Controller обращается к action передавая DTO на вход

    DTO могут создаваться через обычный конструктор, либо через вызова метода который возвращает сам DTO. Примерами может служить вызов

    UserDTO::fromRequest($request)

    UserDTO::fromArray($data

    По хорошему DTO должен содержать только те поля, которые ему действительно нужны. Либо вместо примитивов другие DTO. DTO - это просто объект с данными. Собирать в нем самого себя из реквеста не стоит. Для таких целей удобнее всего создавать классы-ассемблеры для сборки необходимых вам DTO. Либо в пространстве App\Http\Assemblers, либо в App\Services\User\Assembler. Например: App\Services\User\Assembler\UserDTOAssembler.php

    Код разбивается, исходя из логики принадлежности к Домену

    Это уже касается скорее DDD, при таком подходе всю вашу структуру нужно переделывать, но далеко не везде и не всегда DDD нужен

    В идеальном случае, модуль — это независимая часть бизнес-логики. При модульной организации кода структура у нас получается примерно следующей:

    Если говорить о модульности, то все эти папки в целом не нужны - это отдельные под-проекты/микросервисы/библиотеки. Если на старте проекта изначально ясно, что будет какая-то админка - нужно создавать отдельный репозиторий для нее. Если же код для основного проекта и админки общий - выносить в отдельную библиотеку, желательно с DDD подходом. В таком случае код админки отвечает только за админку, шума и мусора в проектах становится меньше, поддерживаемость улучшается. Так же, стоит не забывать, что за таким подходом нужно тщательнее следить, тратить доп.время и т.д, поэтому все это индивидуально и зависит от требований конкретного бизнеса

    P.S. Для простых проектов ваша структура хорошо подходит, но для более крупных уже нужно будет задумываться о реструктуризации


  1. ExPande
    10.09.2023 17:14
    +1

    Более продвинутый вариант https://github.com/Mahmoudz/Porto


  1. SpinyMan
    10.09.2023 17:14
    +2

    Зачем в Laravel использовать дополнительный слой с патерном репозиторий, если Eloquent уже его использует?


    1. shmoulevsky Автор
      10.09.2023 17:14

      Основная идея в том чтобы создать слой абстракции для переиспользования кода + удобнее читать код (для примера есть выборка 80+ строк кода или та же выборка но уже в классе $productRepository->getUserProducts(...). Думаю что когда эта выборка обернута в репозиторий то такой код легче читается.


      1. SpinyMan
        10.09.2023 17:14

        Все это обосновано, когда речь идет, например, о symfony. Для laravel достаточно иметь несколько сервисов, в которых и будет выборка. Тем самым вы сокращаете путь, убирая лишний промежуток. Если смотреть в разрезе ваш подход, то получится следующее: Controller -> Service -> Repository -> Eloquent (Repository)... Переиспользование кода с Eloquent уже реализовано из коробки. Там достаточно сделать функции-заготовки, которые возвращают Query Builder - переиспользуйте сколько захотите!


        1. shmoulevsky Автор
          10.09.2023 17:14

          В своей практике Repository использую не только на уровне сервисов, но из в Controller обращаюсь. Если сложные выборки функции-заготовки (насколько понял Вы имеете ввиду универсальные заготовки по типу getList итд) частично решат проблему, но все же это будет менее читаемый код по-моему мнению.


          1. SpinyMan
            10.09.2023 17:14

            Т.е. понятия "тонкий контроллер" вам не известно?..

            ВЫ сейчас пытаетесь меня убедить в удобстве для вас, а не в общепринятых практиках. Ну как говорится "Каждый сходит с ума по-своему". Предлагаю закрыть этот thread и пусть каждый останется при своем.


            1. shmoulevsky Автор
              10.09.2023 17:14

              Честно говоря не совсем понимаю почему такие выводы. Контроллеры все равно получаются тонкими. Например, есть выборка списка чего-либо. В контроллере есть репозиторий который отдает свой ответ оборачивая в Collection. В итоге получается максимум 5 строчек кода в контроллере.

              По второму пункту полностью с Вами согласен)


    1. n1k_crimea
      10.09.2023 17:14
      +1

      удобнее для будущих изменений
      делаем интерфейс Репозитория, реализуем через класс EloquentModelRepository.
      если понадобиться работать с нереляционной БД, то реализуем другой класс и подменяем, ну или через зависимость интерфейс указываем.


      1. SpinyMan
        10.09.2023 17:14

        Вы о5, так же как и автор, смотрите в сторону symfony... Вы пытаетесь воспроизвести то, что не свойственно Laravel! Надстройку репозитория над репозиторием! Не нужно этого делать в laravel! Зависимости уже реализованы из коробки! Все, что вам нужно, это поменять настройки и выбрать нужную вам базу. Это похвально, что вы понимаете инверсию зависимостей, но не там вы ее применяете.


  1. dmx00
    10.09.2023 17:14

    На самом деле если порезать Services на методы, то получатся Actions, а если порезать Repositories на методы получатся Queries. Action (пишет в базу) может вызывать другие Actions и Queries для выполнения своей бизнес задачи. Query (читает из базы) может вызывать только другие Query, Поскольку action может вызывать другой action может возникнуть кольцевая зависимость с чем и борется создатель упомянутого Porto. Но на практике на мелких проектах это должно ловится на деве и тестах и усложнять описанную схему я не хочу, Проблема в моём подходе пока одна - слишком много мелких классов т..к на getById нужен отдельный класс и мне придется объединять их в папки, которые по сути и будут репозиториеми, а папок и так уже много с учетом модулей, дто и прочего.

    Кстати, всегда было интересно, почему все делают вот так

    UserDTO::fromRequest($request)

    вместо того чтобы делать вот так

    $request->getUserDto()

    ведь реквест уже содержит все данные и валидацию для них, а получение из него DTO уникально и больше нигде кроме места с реквестом не пригодится, так почему метод формирования у находится в DTO?


    1. shmoulevsky Автор
      10.09.2023 17:14
      +1

      Спасибо за комментарий. По поводу UserDTO::fromRequest($request) Если мы привязываемся к $request, то когда нам понадобиться сделать DTO из чего-то отличного от объекта Request придется думать как это сделать (например из массива, файла итд).

      При использовании UserDTO мы просто создадим еще несколько методов вида UserDTO::fromArray(...), UserDTO::fromFile(...) итд


      1. dmx00
        10.09.2023 17:14

        Спасибо за ответ. Если дто делается из массива, то метод его создания кладется рядом, да если в другом месте будет точно такой же массив - будет неудобно) Но часто у Вас встречается fromArray из двух разных мест? у меня - никогда (тут стоит уточнить что дто я создаю относительно часто вот только для этого всегда лучше подходит конструктор, а чтобы применить fromArray массив должен быть прям один в один), хотя это может сильно зависеть от проекта... Использовать же fromFile т.е. делать парсинг файла внутри дто (я правильно понял?) мне не позволит совесть (по мне это нарушение srp)