Привет! Я, Андрей, Symfony разработчик - мы делаем сайты. Каждый день у нас уходит много ресурсов на администрирование и базовые настройки проектов. В этой статье поделюсь опытом, как можно адаптировать фреймворк Symfony для оптимизации таких затрат, какие настройки мы проводим для обеспечения быстрого функционирования, и как мы взаимодействуем с REST клиентами. Поехали (много кода).
Namespace и структура проекта
Так как наши сервисы содержат больше одной сущности User, мы решили разбить нашу структуру проекта на «домены» или директории, которые сгруппировали по интересам. Так удобнее ориентироваться в коде и проще подключать и отключать сервисы.

На примере выше Api — это отдельная директория, которая отвечает за взаимодействие с REST клиентами и расширяет базовую структуру из src. Корневые разделы в этой директории по структуре похожи на src, за исключением, что там находится всё, что необходимо для работы с клиентами.
Чтобы поддерживать такую структуру, разделы services и routes мы настраиваем схожим образом отдельно для каждой директории.

doctrine: orm: mappings: User: is_bundle: false type: attribute dir: '%kernel.project_dir%/src/User/Entity' prefix: 'User\Entity' framework: messenger: routing: 'User\Messenger\Message\ClientSettingsMessage': async services: _defaults: autowire: true autoconfigure: true public: false # base section User\: resource: '../../src/User' exclude: '../../src/User/{Exception,Entity,View,Messenger/Message}' # api section Api\User\: resource: '../../src/Api/User' exclude: '../../src/Api/User/{Exception,Entity,View}'
Подобное разделение на сервисы в конфигурации не поддерживаются по умолчанию.
Для этого мы переопределили Kernel класс, чтобы подключать настройки на основе своих файлов.
<?php declare(strict_types=1); class Kernel extends Symfony\Component\HttpKernel\Kernel { use MicroKernelTrait; protected function configureContainer(ContainerConfigurator $container): void { $container->import('../config/{packages}/*.yaml'); $container->import('../config/{packages}/'.$this->environment.'/*.yaml'); $container->import('../config/{services}/*.yaml'); $container->import('../config/{services}/'.$this->environment.'/*.yaml'); } protected function configureRoutes(RoutingConfigurator $routes): void { $routes->import('../config/{routes}/'.$this->environment.'/*.yaml'); $routes->import('../config/{routes}/*.yaml'); if (\is_file(\dirname(__DIR__).'/config/routes.yaml')) { $routes->import('../config/routes.yaml'); } else { $routes->import('../config/{routes}.php'); } } }
Мы используем src директорию как корневой namespace вместо App, предложенного по умолчанию. Т.е. обращение к классам внутри src выглядит следующим образом: \User\Entity\User, \Api\User\Action\MeAction и т.д.
Для этого нужно изменить секцию autoloader в composer.json и немного скорректировать bin/console.php и public/index.php, поменяв использование namespace.
"autoload": { "psr-4": { "": "src/" } }
При использовании оптимизированной версии composer в prod среде это не влияет на производительность:
composer install --no-dev --optimize-autoloader --classmap-authoritative --apcu-autoloader
Actions (ADR) вместо Controller
Мы работаем преимущественно с REST API и не любим большие файлы. Поэтому, придумали для себя правило: один запрос от клиента отвечает за набор определенных действий, каждый запрос — это отдельный класс, унаследованный от наших базовых экшенов GetAction, MutationAction, GetWithFormAction.
Action - это контролер, заданный, как сервис. https://symfony.com/doc/current/controller/service.html#invokable-controllers
Доступные сервисы в Action мы переопределяем через getSubscribedServices. Такое переопределение более компактно и контейнер, который передаётся, минимален и оптимизирован.
Базовый класс изображён ниже. Мы продолжаем использовать AbstractController от Symfony, чтобы пользоваться методами-хелперами, которые предлагает фреймворк.
<?php declare(strict_types=1); use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; abstract class AbstractAction extends AbstractController { public static function getSubscribedServices(): array { return [ // You can add your services here // 'request_stack' => RequestStack::class, ]; } }
Для всех GET запросов на получение данных мы используем отдельный GetAction. В примере он пустой, но в зависимости от нужд он может расширяться:
<?php declare(strict_types=1); namespace Api\Action; use Action\AbstractAction; class GetAction extends AbstractAction { public static function getSubscribedServices(): array { return [ // You can add your services here // 'request_stack' => RequestStack::class, ]; } }
Класс для POST запросов - MutationAction - аналогичен GET классу, но с некоторыми изменениями. MutationAction очевидно, что метод ожидает данные в теле POST запроса, поэтому по умолчанию он поддерживает обработку форм. Про ApiFormTrait будет ниже или даже в отдельном посте.
<?php /** . */ class MutationAction extends \AbstractAction { use ApiFormTrait; public static function getSubscribedServices(): array { return parent::getSubscribedServices() + [ 'form.factory' => FormFactoryInterface::class, 'request_stack' => RequestStack::class, ]; } }
В дополнение к базовым GET/POST запросам, мы часто используем ListAction, который, в отличие от простого GetAction, поддерживает пагинацию данных, вынесенную в отдельный сервис.
В результате, каждый новый запрос выглядит, как на примере ниже.
Данный запрос отвечает за получение данных пользователя, например, для редактирования настроек (очень просто):
<?php /** . */ #[Route('/me', methods: 'GET')] #[IsGranted('ROLE_USER')] class MeAction extends GetAction { public function __invoke(): Response { return new PrivateUserView($this->getUser()); } }
Пример для POST запросов:
<?php /** . */ #[Route("/user/settings/update", methods: ['POST'])] #[IsGranted("ROLE_USER")] class UpdateUserSettingsAction extends MutationAction { use \EntityManagerAwareTrait; use \MessageBusAwareTrait; use \LoggerAwareTrait; public function __invoke(): ViewInterface { return $this->handleApiCall(UserSettingsForm::class, function (UserSettingsDto $dto) { return new PrivateUserView($this->update($this->getUser(), $dto)); }); } private function update(User $user, UserSettingsDto $data): void { $this->em->beginTransaction(); try { $this->em->lock($user, LockMode::PESSIMISTIC_WRITE); $this->em->refresh($user); $user->updateSettings($data); $this->em->persist($user); $this->em->flush(); /** * SettingsMessage uses transactional routing, * Therefore, the message could be processed only if the transaction has been successfully committed, * ie, after all, real updates to the database. */ $this->bus->dispatch(new SettingsMessage($user)); $this->em->commit(); } catch (\Throwable $e) { $this->logger->error('Error occurred while update user settings', ['error' => $e]); $this->em->rollback(); throw $e; } } }
Мы используем свои базовые трейты \EntityManagerAwareTrait, \MessageBusAwareTrait и другие, о которых расскажу позже. Они расположены в корне проекта и позволяют подключать нужные базовые зависимости.
Mutation запросы на изменение данных
Большинство запросов по изменению данных мы обрабатываем через Symfony формы. Для каждого запроса, где требуется валидация, у нас есть Data Transfer Object (DTO) + сама форма унаследования от Symfony\Component\Form\AbstractType, где мы задаём требования по валидации.
В отличие от официальной документации, мы:
Не используем сущности, как данные для форм при обработке запросов. Потому что использование сущностей может вызвать различного рода артефакты. Именно для этих целей у нас есть DTO.
Не используем атрибуты для валидации. Нам не хватает их функционала, так как форма может быть сложной и с зависимостями.
Как и с Action, у нас есть базовый набор форм. Например, для MutationAction — запроса на обновление данных, мы реализовали MutationForm, которая ожидает POST запрос и не разрешает дополнительные поля allow_extra_fields. Для GetAction и ListAction напротив - ожидает GET запрос и разрешены дополнительные поля.
Преимущественно, мы работаем только с JSON запросами и убрали поддержку базовых namespace у корневых форм, которые добавляли названия полей к каждому параметру, переопределив это через getBlockPrefix в абстрактных формах.
<?php /** . */ class PostForm extends AbstractType { public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'method' => 'POST', ]); } public function getBlockPrefix(): string { return ''; } }
PostFormнужен, чтобы разрешить использование configureOptions без вызова родительского метода в наследниках MutationForm
<?php /** . */ abstract class MutationForm extends AbstractType { public function getBlockPrefix(): string { return ''; } public function getParent(): string { return PostForm::class; } }
Возвращаясь к примеру с настройками пользователя, он выглядит следующим образом:
<?php /** . */ class UserSettingsForm extends MutationForm { public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'data_class' => UserSettingsDto::class, ]); } public function buildForm(FormBuilderInterface $builder, array $options): void { $builder ->add('firstName', TextType::class, [ 'constraints' => [ new Length(['max' => $max = 255]), ], ]) ->add('lastName', TextType::class, [ 'constraints' => [ new Length(['max' => $max]), ], ]); } }
Пример DTO объекта приведён ниже. Это простая конструкция объекта, которая может включать другие DTO зависимости. В принципе, мы бы могли обойтись массивами, но мы используем DTO с точки зрения удобства:
<?php /** . */ class UserSettingsDto { public string|null $firstName; public string|null $lastName; }
ApiFormTrait
Для удобства обработки POST запросов мы вынесли общий код обработки форм в trait. Он подключен в MutationAction по умолчанию.
Валидационные ошибки мы отдаём в определенном формате - для клиентов мы реализовали методы, которые позволяют выводить серверные ошибки у полей формы автоматически, это сильно упрощает взаимодейтствие. Ошибки соответствуют названию и вложенности переданных параметров.
Ниже приведен пример нашего trait, он решает большинство проблем с обработкой запроса: берёт на себя валидацию и отправку ошибок в нужном формате, если это требуется.
В примере видно разделение MutationForm с простым JSON запросом от всех остальных, это нужно для поддержки базовых Symfony форм, если это потребуется, например, для обработки файлов:
<?php /** . */ trait ApiFormTrait { use FormTrait; protected function handleApiCall(FormInterface|string $form, callable|null $callable = null): ViewInterface { if (!$form instanceof FormInterface && \is_string($form)) { $form = $this->container->get('form.factory')->create($form); } $request = $this->container->get('request_stack')->getCurrentRequest(); return $this->onFormSubmitted( $form->getConfig()->getType()->getInnerType() instanceof MutationForm ? $form->submit($this->convertRequestToArray($request)) : $form->handleRequest($request), $callable ); } private function convertRequestToArray(Request $request): array { $data = []; if ('json' === $request->getContentTypeFormat() && $request->getContent()) { try{ $data = $request->toArray(); } catch (\Throwable $e){ throw new BadRequestHttpException('Could not convert content into a valid JSON.', $e); } } return $data; } }
<?php /** . */ trait FormTrait { protected function createSuccessResponse(array $data = []): DataView|ResponseView { return $data ? new DataView($data) : new ResponseView(); } protected function createExceptionResponse(): FailureView { return $this->createFailureResponse(Response::HTTP_INTERNAL_SERVER_ERROR); } protected function createSuccessHtmlResponse(string $view, array $parameters = []): Response|SuccessHtmlView { $request = $this->container->get('request_stack')->getCurrentRequest(); if ($request->isXmlHttpRequest()) { return new SuccessHtmlView([ 'html' => $this->renderView($view, $parameters), ]); } return $this->render($view, $parameters); } protected function createFailureResponse(int $status = Response::HTTP_BAD_REQUEST): FailureView { return new FailureView($status); } protected function createValidationFailedResponse(FormInterface $form): ValidationFailedView { return new ValidationFailedView($this->serializeFormErrors($form)); } protected function handleFormCall(FormInterface|string $form, callable|null $callable = null): Response|ViewInterface { if (!\is_string($form) && !$form instanceof FormInterface) { throw new \TypeError(\sprintf('Passed $form must be of type "%s", "%s" given.', \implode(',', ['string', FormInterface::class]), \get_debug_type($form))); } if (\is_string($form)) { $form = $this->container->get('form.factory')->create($form); } $request = $this->container->get('request_stack')->getCurrentRequest(); $form->handleRequest($request); if (!$form->isSubmitted()) { throw $this->createSubmittedFormRequiredException(\get_class($form)); } return $this->createSubmittedFormResponse($form, $callable); } protected function createSubmittedFormResponse(FormInterface $form, callable|null $callable = null): Response|ViewInterface { return $this->onFormSubmitted($form, $callable); } /** * @param null|callable $callable must return @see \Dev\ViewBundle\View\ViewInterface, array or null */ protected function onFormSubmitted(FormInterface $form, callable|null $callable = null): Response|ViewInterface { if (!$form->isValid()) { return $this->createValidationFailedResponse($form); } if (null === $callable || null === $response = \call_user_func($callable, $form->getData())) { return $this->createSuccessResponse(); } if (!\is_array($response) && !$response instanceof ViewInterface && !$response instanceof Response) { throw new \TypeError(\sprintf('Passed closure must return %s, returned %s', \implode('|', [ViewInterface::class, Response::class, 'array']), \get_debug_type($response))); } return \is_array($response) ? $this->createSuccessResponse($response) : $response; } protected function serializeFormErrors(FormInterface $form): array { return $this->serialiseErrors($form->getErrors(true, false)); } protected function createNotXmlHttpRequestException(): XmlHttpRequestRequiredException { return new XmlHttpRequestRequiredException(); } protected function createSubmittedFormRequiredException(string $type): SubmittedFormRequiredException { return new SubmittedFormRequiredException($type); } private function serialiseErrors(FormErrorIterator $iterator, array $paths = []): array { if ('' !== $name = $iterator->getForm()->getName()) { $paths[] = $name; } $id = \implode('_', $paths); $path = \implode('.', $paths); $violations = []; foreach ($iterator as $formErrorIterator) { if ($formErrorIterator instanceof FormErrorIterator) { $violations = \array_merge($violations, $this->serialiseErrors($formErrorIterator, $paths)); continue; } /* @var FormError $formErrorIterator */ $violationEntry = new ViolationView( $id, $formErrorIterator->getMessage(), $formErrorIterator->getMessageParameters(), $path ); $cause = $formErrorIterator->getCause(); if ($cause instanceof ConstraintViolation) { if (null !== $code = $cause->getCode()) { $violationEntry->type = \sprintf('urn:uuid:%s', $code); } } $violations[] = $violationEntry; } return $violations; } }
В примере используются различные*View классы, о них я расскажу ниже, но в целом к ним можно относиться как к массивам с заданной ст��уктурой.
Валидация данных
Как я упоминал ранее, мы не используем аннотации для валидации данных. Вместо этого описываем все валидационные правила внутри форм. Простой пример для обновления настроек:
<?php /** . */ public function buildForm(FormBuilderInterface $builder, array $options): void { $builder ->add('firstName', TextType::class, [ 'constraints' => [ new Length(['max' => $max = 255]), ], ]) ->add('lastName', TextType::class, [ 'constraints' => [ new Length(['max' => $max]), ], ]); } }
Ниже - усложненный вариант, где мы добавляем отчество и проверяем, что имя и фамилия не могут быть одновременно пустыми:
<?php /** . */ class UserSettingsForm extends FormType { public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'data_class' => UserSettingsDto::class, 'constraints' => [ new Callback(static function (UserSettingsDto|null $dto, ExecutionContextInterface $context) { if (null === $dto) { return; } if (!$dto->lastName && !$dto->firstName) { $constraint = new NotBlank(); $context ->buildViolation('First and last names must not be simultaneously empty.') ->setCode($constraint::IS_BLANK_ERROR) ->setCause($constraint) ->atPath('firstName') ->addViolation(); } }), ], ]); } public function buildForm(FormBuilderInterface $builder, array $options): void { $builder ->add('firstName', TextType::class, [ 'constraints' => [ new Length(['max' => $max = 255]), ], ]) ->add('lastName', TextType::class, [ 'constraints' => [ new Length(['max' => $max]), ], ]); $builder->get('lastName')->addEventListener(FormEvents::POST_SUBMIT, static function (PostSubmitEvent $event): void { if ($event->getData()) { $form = $event->getForm()->getParent(); $form->add('middleName', TextType::class, [ 'constraints' => [ new NotBlank(), new Length(['max' => 255]), ], ]); } }); } }
Достичь такой гибкости в настройке валидации, используя аннотации или аттрибуты, наверное возможно, но это займёт больше времени.
