Forms and Objects

Bind a form to a class with data_class, use a DTO when the shapes differ, and convert values with a data transformer.

Syntax$resolver->setDefaults(['data_class' => ArticleDraft::class]);

A form has to know what to do with the values it collects. Bind it to a class and it reads each field's starting value from the matching property and writes the submitted value back into it.

data_class and the object you pass

The data_class option declares that class. Passing an object as the second argument of createForm() supplies the instance. The two are complementary: the option says what kind of object this form is for and is used to build one when you pass null; the argument hands over the object to fill. Writing both is normal.

When the form does not match the entity

Forms often collect something that is not one row in one table: a tag list typed as text, a confirmation checkbox, two inputs that combine into one stored value. Binding such a form to the entity forces the entity to grow properties it has no business owning. Use a DTO instead - a data transfer object, a plain class whose only job is to hold the submitted values. Validate the DTO, then copy from it onto the entity in the controller or a service. The entity keeps its own shape.

Data transformers

A data transformer is a two-method object that converts between the value a field shows and the value your object holds. transform() runs outwards, towards the input. reverseTransform() runs inwards, on what was submitted. Attach one with $builder->get('field')->addModelTransformer($t). When the input cannot be converted, throw TransformationFailedException from reverseTransform(): the form turns it into a validation error on that field instead of a 500.

Example

Example · php
<?php
// src/Form/ArticleDraftType.php

declare(strict_types=1);

namespace App\Form;

use App\Form\DataTransformer\TagListTransformer;
use App\Form\Model\ArticleDraft;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class ArticleDraftType extends AbstractType
{
    // Form types are services, so the transformer is injected, not fetched.
    public function __construct(
        private readonly TagListTransformer $tagListTransformer,
    ) {
    }

    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('title', TextType::class)
            ->add('body', TextareaType::class)
            // The input is one string; the DTO property is a list of strings.
            ->add('tags', TextType::class, [
                'required' => false,
                'help' => 'Comma separated.',
            ])
            ->add('notifySubscribers', CheckboxType::class, ['required' => false]);

        $builder->get('tags')->addModelTransformer($this->tagListTransformer);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        // The form is bound to a DTO, not to the Article entity.
        $resolver->setDefaults(['data_class' => ArticleDraft::class]);
    }
}

When to use it

  • Collect a comma-separated tag list in one input and store it as an array.
  • Accept a submission that creates two related records without adding form-only properties to either entity.
  • Show a stored amount in pounds while the column holds integer pence.
  • Reject an unconvertible value as a field error rather than letting it become a 500.

More examples

The DTO the form is bound to

Every property is writable and has a default, so the form can build the object and fill it even when nothing is submitted. Constraints sit on the DTO because that is what the form validates. Reach for a DTO when the form collects a different shape from the entity, when one submission touches several entities, or when you want the entity to stay impossible to construct in an invalid state.

Example · php
<?php
// src/Form/Model/ArticleDraft.php

declare(strict_types=1);

namespace App\Form\Model;

use Symfony\Component\Validator\Constraints as Assert;

// A plain class. It holds submitted values and nothing else:
// no database mapping, no behaviour, no identity.
final class ArticleDraft
{
    #[Assert\NotBlank]
    #[Assert\Length(max: 120)]
    public string $title = '';

    #[Assert\NotBlank]
    public string $body = '';

    /** @var list<string> */
    #[Assert\Count(max: 10)]
    public array $tags = [];

    public bool $notifySubscribers = false;
}

The transformer, in isolation

The two methods are mirror images, and the pair is the whole interface. transform() must survive null, because it runs on an empty form before anything is submitted. reverseTransform() must survive anything a browser can send. Throwing TransformationFailedException is how you reject input the type system cannot express: the form shows a validation error on that field and the rest of the submission still reports its own errors.

Example · php
<?php
// src/Form/DataTransformer/TagListTransformer.php

declare(strict_types=1);

namespace App\Form\DataTransformer;

use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;

final class TagListTransformer implements DataTransformerInterface
{
    // Model value (a list) to view value (one string).
    public function transform(mixed $value): string
    {
        return implode(', ', $value ?? []);
    }

    // Submitted string back to a list.
    public function reverseTransform(mixed $value): array
    {
        if (null === $value || '' === trim((string) $value)) {
            return [];
        }

        // trim(...) is the first-class callable form of 'trim'.
        $tags = array_map(trim(...), explode(',', (string) $value));
        $tags = array_values(array_unique(array_filter($tags, static fn (string $t): bool => '' !== $t)));

        foreach ($tags as $tag) {
            if (mb_strlen($tag) > 32) {
                throw new TransformationFailedException(sprintf('Tag "%s" is too long.', $tag));
            }
        }

        return $tags;
    }
}

// (new TagListTransformer())->transform(['php', 'symfony']);
// Output: 'php, symfony'

// (new TagListTransformer())->reverseTransform('php, , symfony, php');
// Output: ['php', 'symfony']

Copying the DTO onto entities

The translation step is explicit and lives in one place. The DTO is already valid by the time this code runs, so the controller only maps values. Note the tag lookup: the whole list is fetched in one findBy() call, because calling the repository inside the loop would issue one query per tag. The route answers a valid POST with 302 to the new article and an invalid one with 422 and the redisplayed form.

Example · php
<?php
// src/Controller/ArticleDraftController.php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Article;
use App\Form\ArticleDraftType;
use App\Form\Model\ArticleDraft;
use App\Repository\TagRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class ArticleDraftController extends AbstractController
{
    #[Route('/articles/draft', name: 'app_article_draft', methods: ['GET', 'POST'])]
    public function draft(
        Request $request,
        EntityManagerInterface $em,
        TagRepository $tags,
    ): Response {
        $draft = new ArticleDraft();
        $form = $this->createForm(ArticleDraftType::class, $draft);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $article = new Article($draft->title, $draft->body);

            // One query for every name, not one query per name.
            foreach ($tags->findBy(['name' => $draft->tags]) as $tag) {
                $article->addTag($tag);
            }

            $em->persist($article);
            $em->flush();

            return $this->redirectToRoute('app_article_show', ['id' => $article->getId()]);
        }

        return $this->render('article/draft.html.twig', ['form' => $form]);
    }
}

Discussion

  • Be the first to comment on this lesson.