I recently published a small Symfony bundle called Request To Form Bundle.
I have been using Symfony for a while now, and in several REST API projects I have used Symfony Forms.
Forms are powerful and have worked well for me, but I did not want to repeat the same logic in every controller:
- read the current request
- transform the request data into something that can be submitted to a form
- create the form
- submit it
- check validation
- get the mapped data
So I created a small internal service that did this work for me:
$form = $formHandler->handleCurrentRequest($post);
The service reads the current request, resolves the form type from the provided data object, submits the request data to the form, and throws an exception when the form is not valid.
I used that service in multiple projects, and it made the controller code much cleaner.
For example, a controller could look like this:
#[Route('/posts', methods: ['POST'])]
public function create(FormRequestHandler $formHandler): JsonResponse
{
$post = new Post();
$formHandler->handleCurrentRequest($post);
$this->postService->create($post);
return $this->json($post);
}
Later, I thought: Symfony already has controller argument attributes like
#[MapRequestPayload] and #[MapQueryString]. What if I could keep using
Symfony Forms, but with a similar controller argument experience?
That is where the bundle idea came from.
Why I Use Forms in APIs
I usually prefer DTOs for payloads that are not directly related to an entity or to a complex data structure. Search queries, filters, login payloads, or generic actions are good examples where a lightweight DTO works well.
But when the request closely matches an entity, or when the input has a more complex structure, I think Symfony Forms are very useful.
The Form component already supports nested forms, collections, and powerful form types like EntityType and CollectionType. It also supports data transformers, form events, type extensions, custom options, and submitting data directly into existing objects. I also value how easily form types can be reused and extended through inheritance and embedded sub-forms.
In these scenarios, using a form removes a good amount of boilerplate: deserializing the request, validating a separate DTO, and then manually mapping that DTO back to an entity or another object. If a Symfony Form already describes the input and knows how to map it to the target object, I prefer to use that directly.
The Basic Idea
The bundle adds a #[MapRequestToForm] attribute.
With it, the current request is submitted to a Symfony Form. If the form is valid, the controller receives the mapped form data. If the form is not valid, an HTTP error is thrown before the controller is called, with the failed form available through FormValidationFailedException.
use App\Entity\Post;
use AzYouness\RequestToFormBundle\Attribute\MapRequestToForm;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/posts', methods: ['POST'])]
public function create(
#[MapRequestToForm]
Post $post,
): JsonResponse {
$this->entityManager->persist($post);
$this->entityManager->flush();
return $this->json($post);
}
The form type is inferred from the data_class configured in the form type. If PostType has data_class set to Post::class, the bundle resolves that automatically. No need to specify it explicitly in the simple case.
If the form type cannot be inferred, pass it explicitly:
#[MapRequestToForm(formType: PostType::class)]
Post $post,
Existing Objects
One important use case is update and edit endpoints.
Symfony may already resolve an entity from the route before the form is submitted — for example, with EntityValueResolver or #[MapEntity]. The bundle handles the form mapping after Symfony has resolved the controller arguments, so the existing object is used as the initial form data and the request is submitted into it.
#[Route('/posts/{id<\d+>}', methods: ['PUT'])]
public function update(
#[MapRequestToForm]
Post $post,
): JsonResponse {
// $post is first resolved from {id} by EntityValueResolver,
// then submitted through the form with the current request data.
$this->entityManager->flush();
return $this->json($post);
}
It also works with #[MapEntity] for custom mappings:
#[Route('/posts/{slug}', methods: ['PUT'])]
public function update(
#[MapRequestToForm]
#[MapEntity(mapping: ['slug' => 'slug'])]
Post $post,
): JsonResponse {
$this->entityManager->flush();
return $this->json($post);
}
For PATCH requests, missing fields are kept by default (clearMissing: false). For other methods, missing fields are cleared. You can override this:
#[MapRequestToForm(clearMissing: false)]
Post $post,
Form Options
Sometimes you need to pass options to the form — for example, to use specific validation groups:
#[MapRequestToForm(formOptions: ['validation_groups' => ['Default', 'publish']])]
Post $post,
Any option accepted by FormFactoryInterface::create() can be passed here.
Receiving The Form Instead Of The Data
Sometimes the controller needs the submitted form itself rather than just its data.
use App\Form\PostType;
use AzYouness\RequestToFormBundle\Attribute\MapRequestToForm;
use Symfony\Component\Form\FormInterface;
public function create(
#[MapRequestToForm(formType: PostType::class)]
FormInterface $form,
): JsonResponse {
return $this->json($form->getData());
}
You can also use another controller argument as the initial form data with dataArgument. This is useful when you want the entity resolved separately and the form to receive it explicitly:
public function update(
Post $post,
#[MapRequestToForm(formType: PostType::class, dataArgument: 'post')]
FormInterface $form,
): JsonResponse {
// The form is submitted with $post as its initial data.
// $post is updated in place with the current request data.
return $this->json($form->getData());
}
Accepted Request Formats
By default, the bundle accepts both json and form request formats. The form format includes multipart/form-data, so file uploads are supported out of the box.
You can restrict this per action:
#[MapRequestToForm(acceptFormat: 'json')]
Post $post,
Manual Usage
Sometimes the controller needs to prepare the data or form options before submitting the request to the form.
For that, the bundle provides a RequestToFormMapper service:
use AzYouness\RequestToFormBundle\RequestToFormMapper;
public function create(RequestToFormMapper $mapper): JsonResponse
{
$post = new Post();
// Prepare the object before submitting the request.
$mapper->handleCurrentRequest($post);
// $post is now submitted and validated.
return $this->json($post);
}
There is also a lower-level handle() method when you want to pass the request explicitly:
$form = $mapper->handle(
request: $request,
formType: PostType::class,
data: $post,
throwOnInvalid: false,
);
if (!$form->isValid()) {
// handle the invalid form
}
Some Design Notes
The bundle has two main parts:
- a mapper service that handles request data and submits it to a form
- a controller argument integration built on top of it
The controller argument part works after Symfony has resolved all other controller arguments. That ordering is intentional — it is what makes the existing data flow possible:
Symfony resolves route / entity arguments
↓
The bundle submits the request data to the form
↓
The controller receives the final mapped value
The bundle also tries to resolve form types automatically from data_class, so the controller argument type alone is enough in many simple cases.
Links
For more details, see the GitHub repository:
https://github.com/azyouness/request-to-form-bundle
Feedback
I would appreciate feedback from Symfony developers — on the API design, edge cases, naming, or whether this approach fits real Symfony applications.
Top comments (0)