Первая часть будет рассказывать о том, почему сущности не должны использоваться в формах Symfony. Вторая часть представляет подход, который решает почти все проблемы представленные в первой части.
ps. это мой вольный перевод статьи https://blog.martinhujer.cz/symfony-forms-with-request-objects/ просто потому, что мне захотелось.
Начнем с утверждения, что использование сущностей в Symfony формах является широко распространенным и рекомендуемым подходом. Даже официальная документация рекомендует его.
И я не думаю, что это хорошая идея!
Почему?
1. Сущность должна быть всегда валидной.
Сущность должна быть всегда валидной. Для сущности не должно быть возможным оказаться в невалидном состоянии.
И это в точности то, что делает валидация форм. Когда форма отправляется, данные внедряются (через геттеры или публичные свойства) в сущность и валидируются. И даже если валидация не проходит, невалидные данные остаются на месте и мы получаем невалидную сущность.
Можно почитать эти слайды от Ocramius или посмотреть видео, чтобы получить прекрасное объяснение, что означает иметь валидную сущность (и многое другое).
Так же не трудно представить ситуацию в которой можно получить серьезные проблемы. Если сущность уже добавлена в менеджер сущностей (т.к. это экшн updateAction) и если где-то в коде присутствует вызов $entityManager->flush(), то в конечном итоге невалидные данные будут сохранены в базу данных.
2. Изменения! Изменения! Изменения!
Мы можем быть уверены только в одной вещи в разработке – это изменения. Внезапно будет нужно поменять структуру формы или может разделить её на 2 шага. Тогда поля формы не будут на 100% соответствовать свойствам сущности.
3. Разделение слоев.
Подход с сущностями в формах нарушает разделение слоев. Каждый слой должен зависеть только от нижележащего, а не наоборот.
Что мы можем делать вместо использования сущностей в формах? Документация Symfony описывает подход с использованием массивов. Этот подход основан на переменной и имеет свои недостатки. Один из которых то, что разработчк не будет иметь автодополнение в IDE для данных формы. И будет трудно анализировать массив анализатором кода, таким как PHPStan.
Есть еще один подход, который я предпочитаю.
Собственные классы данных для победы.
Чтобы преодолеть описанные выше неудобства, я предлагаю использовать класс для представления данных с формы.
Взглянем на этот пример
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
use Symfony\Component\Validator\Constraints as Assert; class CreateArticleRequest { /** * @Assert\NotBlank() * @Assert\Length(min="10", max="100") * @var string */ public $title; /** * @Assert\NotBlank() * @var string */ public $content; /** * @Assert\DateTime() * @var \DateTimeImmutable */ public $publishDate; } |
Это простой класс, который имеет некоторые публичные свойства и аннотации для валидации. Главное преимущество в том, что он никак не связан с сущностью. Класс CreateArticleRequest может содержать сколько угодно невалидных данных и не причинять проблем.
Следующий шаг – использовать этот объект в контроллере. Его можно применять так же, как сущность (следующий пример говорит сам за себя)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
/** * @Route("/article/create/", name="article_create") */ public function createAction(Request $request) { // создание экземпляра пустого CreateArticleRequest $createArticleRequest = new CreateArticleRequest(); // создание формы со своим классом вместо сущности $form = $this->createForm(ArticleFormType::class, $createArticleRequest); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { // ArticleFacade создает сущность Article, // и сохраняет ее. // (детали вне темы этой статьи) $article = $this->articleFacade->createArticle( $createArticleRequest->title, $createArticleRequest->content, $createArticleRequest->publishDate ); // ... использовать $article для добавления названия в флеш сообщение или др. return $this->redirectToRoute('articles_list'); } // показать форму если это первый запрос или валидация не прошла return $this->render('article/add-article.html.twig', [ 'form' => $form->createView(), ]); } |
И для завершенности вот код для ArticleFormType
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
class ArticleFormType extends \Symfony\Component\Form\AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { $builder ->add('title', TextType::class, [ 'label' => 'Article title', ]) ->add('content', TextareaType::class, [ 'label' => 'Article title', ]) ->add('publishDate', DateTimeType::class, [ 'label' => 'Publish on', ]) ->add('save', SubmitType::class, [ 'label' => 'Save', ]); } } |
Класс называется CreateArticleRequest потому, что он представляет запрос на создание Article. Возможно так же понадобится класс UpdateArticleRequest для обновления с другим набором сущностей (в некоторых случаях эти запросы могут быть одинаковые и достаточно одного ArticleRequest класса).
Суффикс *Request может вызвать некий конфуз с классом Request, который представляет собой HTTP запрос. Если это ваш случай, просто переименуйте суффикс на *Data и класс будет называться CreateArticleData.
А что на счет формы для обновления ?
Одна из особенностей обновления в том, что запрос не обязательно будет иметь тот же набор полей, что и запрос на создание. В примере, мы не хотим обновлять поле publishDate в сущности. Класс UpdateArticleRequest будет выглядеть так
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
use Symfony\Component\Validator\Constraints as Assert; class UpdateArticleRequest { /** * @Assert\NotBlank() * @Assert\Length(min="10", max="100") * @var string */ public $title; /** * @Assert\NotBlank() * @var string */ public $content; public static function fromArticle(Article $article): self { $articleRequest = new self(); $articleRequest->title = $article->getTitle(); $articleRequest->content = $article->getContent(); return $articleRequest; } } |
Можно заметить, что поле $publishDate отсутствует. Но, что более важно, у нас есть новый метод fromArticle(Article $article);. Он позволяет получить данные из существуюшего источника.
Посмотрим на пример экшена updateAction() чтобы увидеть как это будет в контроллере
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
/** * @Route("/article/update/{id}/", name="article_update") */ public function updateAction(Article $article, Request $request) { // $article получается из {id} по механизму ParamConverter // предзаполнение UpdateArticleRequest экземпляра из загруженной сущности $updateArticleRequest = UpdateArticleRequest::fromArticle($article); $form = $this->createForm(UpdateArticleFormType::class, $updateArticleRequest); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { // ArticleFacade обновляет сущность. // (детали за пределами темы статьи) $this->articleFacade->updateArticle( $article, $updateArticleRequest->title, $updateArticleRequest->content ); // ... используется $article для чего-нибудь return $this->redirectToRoute('articles_list'); } return $this->render('article/edit-article.html.twig', [ 'form' => $form->createView(), ]); } |
Можно подумать, что тут гораздо больше кода для написания. Я соглашусь, но все же уверен, что это стоит того в долгой перспективе. Если приложение содержит некую бизнес логику и это не просто CRUD, могут понадобится разные поля и наборы валидации для создания и обновления. И вот тут то и пригодится весь код, написанный заранее.
Заключение
В этой статье я предположил, почему использование сущностей в формах Symfony не лучшая идея. Вторая часть статьи предлагает подход для решения этих проблем через использование своего класса данных, вместо сущности, для хранения и валидации данных.
И здесь две следующие основные мысли:
- Всегда сохраняйте разделение слоев
- Не стоит слепо следовать документации или за другими разработчиками
Если вы используете сущности в своих формах, то с какими проблемами вы сталкивались?
Так же вот ссылки на две статьи по теме: Avoiding Entities in Forms и Rethinking Form Development (Iltar van der Berg).
з.ы. от себя добавлю, что очень много проблем с тайпхинтами начиная с php 7.0. Как раз потому, что в сущности могут быть указаны типы параметров на сеттеры, а форма пытается положить string в int или null приходит там, где маппинг это запрещает. В 7.1 пришлось везде использовать ?string, ?int и т.д, чтобы только сущности работали в Symfony формах. Подход с CUSTOM DATA OBJECT полностью решит эту проблему, т.к. данные из CUSTOM DATA OBJECT будут передаваться в сущность уже в сервисе после валидации. Все это напомнило мне старый добрый подход с DTO.