Thursday, 31 July 2025

Symfony forms

G'day:

The next thing on my "things I ought to know about already" todo list is Symfony forms. I'm aware they exist as a thing as I've seen some code using them, but don't know much beyound them being a thing. I actually used the Symfony form lib to create the UI for "Integrating Elasticsearch into a Symfony app", but it was very superficial and I waved-away any discussion on it as I knew this article here was coming up. Plus it was off-topic in that article. I got Github Copilot to write all the code for the forms too, so I am really none-the-wiser as to how it all works.

However this is about to change. I have a baseline Symfony-driven project in Github, and I have opened the docs page for Symfony Forms. I've breezed through about the first half of the first page, but have rewound and decided to write this article as I go.

Installation:

docker exec php composer require symfony/form:7.3.*

Installation feedback ensued, and looks like it worked.

My first experiment is going to be to create a sign-up form: a new user enters their name (given & family parts), email, login ID and password, and we'll save that lot. Conceits to deal with here:

  • The login ID should default to the lowercased & dot-separated full name, eg: "Adam Cameron" => "adam.cameron". Although that's just a suggestion and they can change it to whatevs. This is just client-side stuff but I want to see how I can integrate the JS event handlers.
  • The email address should be validated for well-formedness (via some mechanism I don't need to provide).
  • The password data-entry control should be the usual "password" and "confirm password" inputs.
  • The password should be validated - both client- and server-side - for strength.

First I'm going to need a backing entity for this. I've used the entity maker for this, for which I first needed to install the Symfony Maker Bundle. Then it's easy:

docker exec php composer require --dev symfony/maker-bundle:^1
[installation stuff]

docker exec -it php bin/console make:entity


 Class name of the entity to create or update (e.g. GentlePizza):
 > User

 created: src/Entity/User.php
 created: src/Repository/UserRepository.php

 Entity generated! Now let's add some fields!
 You can always add more fields later manually or by re-running this command.

 New property name (press <return> to stop adding fields):
 > givenName

 Field type (enter ? to see all types) [string]:
 >

 Field length [255]:
 >

 Can this field be null in the database (nullable) (yes/no) [no]:
 >

 updated: src/Entity/User.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 
 [you get the idea]

That results in this user entity:

// src/Entity/User.php

namespace App\Entity;

use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: UserRepository::class)]
class User
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $givenName = null;

    #[ORM\Column(length: 255)]
    private ?string $familyName = null;

    #[ORM\Column(length: 255)]
    private ?string $email = null;

    #[ORM\Column(length: 255)]
    private ?string $loginId = null;

    #[ORM\Column(length: 255)]
    private ?string $password = null;

    // [...]
}

Now we can build the form for this entity. There's two options: do it direct in the controller (wrong place for it), or as a class (which is what I'll do). I see there is a bin/console make:registration-form script, which - having asked Copilot to clarify, because the docs don't - is pretty much what I want as I am indeed creating a user registration form. I'll give it a go to at least see what if scaffolds/generates.

docker exec -it php bin/console make:registration-form
[fairly poor experience ensues]

OK, that was less good than it could be. It asked for my entity (required me to implement Symfony\Component\Security\Core\User\UserInterface first), asked a coupla things about sending emails and stuff (over-reach for a form builder), and then triumphantly came up with this form:

Forget the lack of styling - that's on me - but WTF has that got to do with the entity I gave it?

[DEL] [DEL] [DEL] [DEL]

Right that's that lot gone. Not being one to learn from past experience, there's a second option: docker exec -it php bin/console make:form. I'll try that. I have my [DEL] key primed just in case…

$ docker exec -it php bin/console make:form

 The name of the form class (e.g. FierceChefType):
 > UserType

 The name of Entity or fully qualified model class name that the new form will be bound to (empty for none):
 > App\Entity\User


 [ERROR] Entity "App\Entity\User" doesn't exist; please enter an existing one or create a new one.


 The name of Entity or fully qualified model class name that the new form will be bound to (empty for none):
 > User

 created: src/Form/UserType.php


  Success!


 Next: Add fields to your form and start using it.
 Find the documentation at https://symfony.com/doc/current/forms.html

The only glitch there was me entering the fully-qualified name of the entity, not just its class name. And the results:

class UserType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('givenName')
            ->add('familyName')
            ->add('email')
            ->add('loginId')
            ->add('password')
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => User::class,
        ]);
    }
}

/me stops hovering over the [DEL] key

OK that's more like it: focusing on the job at hand. But obvs it needs some work. Indeed here's the one Copilot made for me before I noticed these build wizards:

// src/Form/Type/UserType.php

class UserType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('givenName', TextType::class)
            ->add('familyName', TextType::class)
            ->add('email', EmailType::class)
            ->add('loginId', TextType::class)
            ->add('password', PasswordType::class)
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => User::class,
        ]);
    }
}

Just a bit more thorough.

I did some back-and-forth with Copilot to tweak some rules and some UI behaviour - and even got it to write some CSS (new-user.css) for me - and we ended-up with this:

public function buildForm(FormBuilderInterface $builder, array $options): void
{
    $builder
        ->add('givenName', TextType::class)
        ->add('familyName', TextType::class)
        ->add('email', EmailType::class)
        ->add('loginId', TextType::class, [
            'label' => 'Login ID',
        ])
        ->add('password', RepeatedType::class, [
            'type' => PasswordType::class,
            'first_options'  => [
                'label' => 'Password',
                'constraints' => [
                    new Assert\Regex([
                        'pattern' => '/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d).{8,}$/',
                        'message' => 'Password must be at least 8 characters long and include an uppercase letter, a lowercase letter, and a number.',
                    ]),
                ],
            ],
            'second_options' => ['label' => 'Confirm Password'],
            'invalid_message' => 'The password fields must match.',
        ])
    ;
}
  • "Login ID" was rendering as "Login id", without a bit of guidance.
  • There's no canned password strength validation rules, so regex it is.
  • And the RepeatedType with first_options / second_options is how to do the password confirmation logic.
  • (not seen here) the code for defaulting the login ID to [givenName].[familyName] needed to be done in JS (new-user.js) which I got Copilot to knock together, and I didn't really pay attention to it as it's nothing to do with the Symfony forms stuff. It works though).

The controller for this is thus:

class UserController extends AbstractController
{
    #[Route('/user/new', name: 'user_new')]
    public function new(Request $request, EntityManagerInterface $em): Response
    {
        $user = new User();
        $form = $this->createForm(UserType::class, $user);

        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $em->persist($user);
            $em->flush();

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

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

    #[Route('/user/success/{id}', name: 'user_success')]
    public function userSuccess(User $user): Response
    {
        return $this->render('user/success.html.twig', [
            'user' => $user,
        ]);
    }
}

Mostly boilerplate. The key form bits are highlighted, and self-explanatory. It's interesting how Symfony is able to infer whether we're dealing with the initial GET or the ensuing POST from the form object. There's some docs which are worth reading: Processing Forms.

I do however wish Symfony's default approach was not to roll the GET and POST handling into the same controller method. They're two different requests, with two different jobs. It strikes me as being poor design to implement things this way.

I quizzed Copilot about this, and we(*) were able to separate out the two concerns quite nicely:

// src/Controller/UserController.php

#[Route('/user/new', name: 'user_new', methods: ['GET'])]
public function showNewUserForm(): Response
{
    $form = $this->createForm(UserType::class, new User());
    return $this->render('user/new.html.twig', [
        'form' => $form->createView(),
    ]);
}

#[Route('/user/new', name: 'user_new_post', methods: ['POST'])]
public function processNewUser(Request $request, EntityManagerInterface $em): Response
{
    $user = new User();
    $form = $this->createForm(UserType::class, $user);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $em->persist($user);
        $em->flush();
        return $this->redirectToRoute('user_success', ['id' => $user->getId()]);
    }

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

This is way better, and I'm gonna stick with this. Looking at it, all symfony is gaining by munging the two methods together is to save two statements being repeated. That is not worth rolling the logic into one method that now does two things.

This is perhaps a timely reminder that DRY does not mean "don't repeat code", it means "don't implement the same concept more than once". I write about this in "DRY: don't repeat yourself". The Symfony approach misunderstand DRY here, I think.

OK so the controller method is no use without a view, so here's the twig. Well. Let's back-up. This is what the twig was initially:

{% extends 'base.html.twig' %}

{% block title %}New User{% endblock %}

{% block body %}
    <h1>Create New User</h1>
    {{ form_start(form) }}
        {{ form_widget(form) }}
        <button class="btn btn-primary">Submit</button>
    {{ form_end(form) }}
{% endblock %}

This was functional, and the happy path even looked OK thanks to the CSS that Copilot wrote:

(My bar for "looks OK" is very low, I admit this).

However validation errors were not rendering well, so I did a bunch of back-and-forth with Copilot to get the mark-up for the form workable with CSS to dolly things up a bit. We ended up having to expand-out all the fields:

{# templates/user/new.html.twig #}

{% extends 'base.html.twig' %}

{% block stylesheets %}
    {{ parent() }}
    <link rel="stylesheet" href="{{ asset('css/new-user.css') }}">
{% endblock %}

{% block javascripts %}
    {{ parent() }}
    <script src="{{ asset('js/new-user.js') }}"></script>
{% endblock %}

{% block title %}New User{% endblock %}

{% block body %}
    <h1>Create New User</h1>
    {{ form_start(form) }}
    <div>
        {{ form_label(form.givenName) }}
        {{ form_widget(form.givenName) }}
        {{ form_errors(form.givenName) }}
    </div>
    <div>
        {{ form_label(form.familyName) }}
        {{ form_widget(form.familyName) }}
        {{ form_errors(form.familyName) }}
    </div>
    <div>
        {{ form_label(form.email) }}
        {{ form_widget(form.email) }}
        {{ form_errors(form.email) }}
    </div>
    <div>
        {{ form_label(form.loginId) }}
        {{ form_widget(form.loginId) }}
        {{ form_errors(form.loginId) }}
    </div>
    <div>
        {{ form_label(form.password.first) }}
        <div class="field-input">
            {{ form_widget(form.password.first) }}
            {{ form_errors(form.password.first) }}
        </div>
    </div>
    <div>
        {{ form_label(form.password.second) }}
        {{ form_widget(form.password.second) }}<br>
        {{ form_errors(form.password.second) }}
        {{ form_errors(form.password) }}
    </div>
    <button class="btn btn-primary">Submit</button>
    {{ form_end(form) }}
{% endblock %}

That's fine: it's simple enough.

Oh I like the way JS and CSS are handled here: this code hoists them up into the head block for me.

And I also need a place to land after a successful submission:

{# templates/user/success.html.twig #}

{% extends 'base.html.twig' %}

{% block body %}
    <h1>Thank you, {{ user.givenName }} {{ user.familyName }}!</h1>
    <p>Your account has been created successfully.</p>
{% endblock %}

And this all works! Hurrah. Well: once I added the DB table it did anyhow:

# docker/mariadb/docker-entrypoint-initdb.d/1.createTables.sql

USE db1;

CREATE TABLE user (
    id INT AUTO_INCREMENT PRIMARY KEY,
    given_name VARCHAR(255) NOT NULL,
    family_name VARCHAR(255) NOT NULL,
    email VARCHAR(255) NOT NULL,
    login_id VARCHAR(255) NOT NULL,
    password VARCHAR(255) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

I'm not using Doctrine Migrations for this as they can get tae hell (see PHP / Symfony: working through "Symfony: The Fast Track", part 4: not really about Symfony, this one › "Doctrine: know your limits!"(*)). But slinging this in /docker-entrypoint-initdb.d in the MariaDB container file system will ensure it gets recreated whenever I rebuild the DB. Which is fine for dev.

I can create a new user now.

However I need some server-side validation. We can't be having a new user reusing the same Login ID as an existing user, so we need to stop that.

Oh for goodness sake, doing this is just a one line addition to the User entity:

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[UniqueEntity(fields: ['loginId'], message: 'This login ID is already in use.')]
class User
{

This is brilliant, but no bloody use as an example. So let's pretend it's not that easy and we have to create a custom validator for this.

First we need a new constraint class:

// src/Validator/UniqueLoginId.php

namespace App\Validator;

use Symfony\Component\Validator\Constraint;

#[\Attribute]
class UniqueLoginId extends Constraint
{
    public string $message = 'This login ID is already in use.';
}

And a validator thereof:

// src/Validator/UniqueLoginIdValidator.php

namespace App\Validator;

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Doctrine\ORM\EntityManagerInterface;
use App\Entity\User;

class UniqueLoginIdValidator extends ConstraintValidator
{
    private EntityManagerInterface $em;

    public function __construct(EntityManagerInterface $em)
    {
        $this->em = $em;
    }

    public function validate($value, Constraint $constraint)
    {
        if (!$value) {
            return;
        }

        $existing = $this->em->getRepository(User::class)->findOneBy(['loginId' => $value]);
        if ($existing) {
            $this->context->buildViolation($constraint->message)->addViolation();
        }
    }
}

This needs to be configured in services.yaml:

# config/services.yaml

services:
    # [...]

    App\Validator\UniqueLoginIdValidator:
      arguments:
        - '@doctrine.orm.entity_manager'
      tags: [ 'validator.constraint_validator' ]

And then applied to the form field:

// src/Form/Type/UserType.php

class UserType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('givenName', TextType::class)
            ->add('familyName', TextType::class)
            ->add('email', EmailType::class)
            ->add('loginId', TextType::class, [
                'label' => 'Login ID',
                'constraints' => [
                    new UniqueLoginId(),
                ],
            ])

And that's it: pretty easy (not quite as easy as the one-liner, but still)! There's some docs to read: Constraints At Field Level.

I was surprised I had to manually wire-in the validator into services.yaml: I've been spoilt recently with the AsDoctrineListener and AsMessageHandler directly on the classes/methods that define them, and Symfony's autowiring picks them up automatically.

I'm also a bit bemused as to why the validation system needs two files: a constraint class and a validator class. This is not clearly explained in the docs that I could see. As far as I can gather the constraint class defines the rules that would mean an object is valid; and the validator actually checks them against an object. I've read how this is a separation of concerns, but I am not entirely convinced we have two separate concerns here to be separated. in the example we have here, it's the validator that is defining the rule, and doing the validation:

public function validate($value, Constraint $constraint)
{
    if (!$value) {
        return;
    }

    $existing = $this->em->getRepository(User::class)->findOneBy(['loginId' => $value]);
    if ($existing) {
        $this->context->buildViolation($constraint->message)->addViolation();
    }
}

One thing I did learn - from Copilot - is that the relationship between the class names - [Constraint] and [Constraint]Validator - is just a convention, and there does not need to be that name-mapping going on. It's the default behaviour of Constraint::validatedBy. I guess in theory one could have one validator class for a suite of same-themed constraints.

I'm not convinced though.

But hey, it's easy and it works well! This is the main practical thing here.

I've breezed down the rest of the docs, and there's a few other interesting things, but nothing that needs looking at right now. So I'll leave it here.

Righto.

--
Adam

(*) Copilot did it all. I just tested it.