Using Symfony forms with PHP typed properties

Published on 2023-05-20 • Modified on 2023-05-20

In this post, we see how to use Symfony forms with PHP typed properties and non-nullable one. Let's see the problems that can occur and how to handle them. Let's go! 😎

» Published in "A week of Symfony 856" (22-28 May 2023).

Prerequisite

I assume you have at least a basic knowledge of Symfony and know how to create a form with a class extending the Symfony AbstractType class.

Configuration

  • PHP 8.3
  • Symfony 6.4.6

Introduction

PHP types properties are in the language as of PHP 7.4, it's one of the features you ask yourself: "How could we work without this before?". But it also comes with some surprises, for example, the new not initialized state of properties and the associated error we all encountered.

Goal

The goal is to show how to create a form for an entity with non-nullable typed properties and check several ways to make things work altogether.

The problem

Let's create two entities Product and Category, both have a required name which is a string and not nullable. The names' properties look like this:

    private const NAME_LENGTH = 255;

    /**
     * Short and main name of the category.
     */
    #[ORM\Column(type: Types::STRING, length: self::NAME_LENGTH, nullable: false)]
    #[Assert\NotBlank]
    #[Assert\Length(max: self::NAME_LENGTH)]
    private string $name;

The property is a string, it isn't nullable and doesn't have a default value. It isn't nullable in the database either thanks to the nullable: false option of the Column attribute. Note that we take advantage of PHP attributes to share the same length value in both the Column attribute and the Length validation constraint thanks to a constant. Now, let's create the corresponding form for the category:

<?php

declare(strict_types=1);

namespace App\Form;

use App\Entity\Category;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Article 254: category form, name only.
 */
final class CategoryType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('name', TextType::class, [
            'required' => false,
            'empty_data' => '',
        ]);
    }

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

Which gives:

With this setup, it works without a problem. If we leave the category empty in the form below we have the error: "This value should not be blank.". Remember that by default the NotBlank constraint also forbids null values. Now let's create a dummy custom validator. We can use the Callback constraint:

    #[Assert\Callback]
    public function validate(ExecutionContextInterface $context): void
    {
        if ($this->name === '') {
            return;
        }

        // check if the name is actually a fake name
        if (u($this->name)->containsAny(['coil'])) {
            $context->buildViolation('This name is fordidden')
                ->atPath('name')
                ->addViolation();
        }
    }

We create a fake constraint where we prevent some names to be used (a similar example is used in the documentation). If we submit the form without value, this time, we get the famous error:

Typed property App\Entity\Category::$name must not be accessed before initialization

Indeed, as the name is not initialised, when we call $this->name in the callback validator, this error is raised. How to fix this? πŸ€”

PHP uninitialized properties

This new state was introduced in PHP 7.4. What does it means exactly? If we read the RFC, it says:

If a typed property does not have a default value, no implicit null default value is implied (even if the property is nullable). Instead, the property is considered to be uninitialized. Reads from uninitialized properties will generate a TypeError (unless __get() is defined, see next section).

If we var_dump() a new Category, we have the following output:

var_dump(new Category());
object(App\Entity\Category)#2427 (0) {
  ["id":"App\Entity\Category":private]=>
  uninitialized(Symfony\Component\Uid\Uuid)
  ["name":"App\Entity\Category":private]=>
  uninitialized(string)
}

We can see the uninitialized properties. Now, let's back to the custom validator.

Testing uninitialized PHP properties

To test if the name property is initialized, we can use the isset() function. It can seem weird as this function is there as of PHP 4! And, its original purpose is to test if a variable is set or not. Its behaviour has been extended for PHP 7.4 to handle this case. Before PHP 7.4, if a property (without typehint) is not initialized, the isset() function returns true. You can test this in this snippet where we also see how to do this with reflection.

So let's back to our custom validator. We must therefore use the isset() function before doing the empty string test:

if (!isset($this->name) || $this->name === '') {

Let's submit the form with a blank value, yes, it works, we don't have the error anymore. But, don't forget that isset() also returns false if the property is null. This isn't a good practice, so can we do better?

The empty_data form option

Do you know this form option? What says the documentation:

The empty_data option allows you to specify an empty data set for your form class. This empty data set would be used if you submit your form, but haven't called setData() on your form or passed in data when you created your form.

Remember that a form is a collection of form elements. That means that when we add the name child, it also has this generic option. Let's try:

$builder->add('name', TextType::class, [
    'required' => false,
    'empty_data' => '',
]);

Let's remove the isset() check in the callback validator and submit a blank value. Yes, it works now, we don't have the uninitialized error anymore.

But can't we just fix this problem using a default object that would have its name initialized? Let's try:

$newCategory = (new Category())->setName('');
$form1 = $this->formFactory->create(CategoryType::class, $newCategory)->handleRequest($request);
$data['form1'] = $form1->createView();

We submit the form with an empty value, this time we have the error:

Expected argument of type "string", "null" given at property path "name".

What happens here? Because we removed the empty_data option of the name form element, when the form is submitted, the form component tries to assign a null value to the name property, here is the code (vendor/symfony/property-access/PropertyAccessor.php):

$object->{$mutator->getName()}($value);

$object is the category object. $mutator->getName() returns setName and the value is null. This triggers the error we just had. So, we must keep the empty_data option for the name property. Ok, but what about objects? For example, if we have a product linked to a mandatory category?

What about Doctrine associations?

So let's create a product entity, it also has a name and a ManyToOne relation to the category we used before:

    /**
     * Main category of the product.
     */
    #[ORM\ManyToOne(targetEntity: Category::class)]
    #[ORM\JoinColumn(referencedColumnName: 'id', nullable: false)]
    #[Assert\Sequentially([
        new Assert\NotBlank(),
        new Assert\Callback([self::class, 'validate']),
    ])]
    private Category $category;

To create a product, the form type can look like this, we make the fields not required so we have the empty option for the category:

<?php

declare(strict_types=1);

namespace App\Form;

use App\Entity\Category;
use App\Entity\Product;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Article 254: product form, name + category.
 */
final class ProductType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('name', TextType::class, [
            'required' => false,
            'empty_data' => '',
        ]);

        $builder->add('category', EntityType::class, [
            'required' => false,
            'class' => Category::class,
        ]);
    }

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

Which gives:

Once again, with this setup, it will be OK and we won't have any uninitialized property or invalid type errors. I thought at first that we could use the same trick with the empty_data option with the EntityType, but that's not as straightforward as if we try to put a dummy Category object in the empty_data option, in the callback validator we don't receive the dummy object and the category is still uninitialized. It is required to create a data transformer (check the Stackoverflow link at the end), but let's try to keep the form type light with an alternative method.

So this time, let's use something else: if you look at category property, the validators are wrapped in a Assert\Sequentially constraint. As its name indicates, it allows to test validation sequentially and it stops the process as soon as an error is raised. So, in our case, the blank validator is checked, then the callback one, but only if the first one was OK. Let's see the corresponding code:

    public static function validate(Category $object, ExecutionContextInterface $context): void
    {
        // check if the name is actually a fake name
        if ($object->getName() === 'Category 1') {
            $context->buildViolation('This category is fordidden')
                ->atPath('category')
                ->addViolation();
        }
    }

What is nice here, is, as the NotBlankvalidator already did its job before, we are sure that a category was choosen and the user didn't leave the select box empty. So, the callback receives a valid category object, and then we can make our dummy test. We raise an error only if the user chooses the first one. Of course, if we comment off the NotBlank validator we would have the following error:

App\Entity\Product::validate(): Argument #1 ($object) must be of type App\Entity\Category, null given, called in /strangebuzz.com/vendor/symfony/validator/Constraints/CallbackValidator.php on line 43

PS: This last section is not as I imagined it at first, and it is OK, as I learned new things. That's exactly why I write, to make things clear for me and have a better global understanding of the features provided by Symfony or PHP. πŸ™‚

Conclusion

Yes, we can use non-nullable properties with Symfony forms, there are some traps and things to know, but it works and the modifications to do are quite straightforward. Of course, you can avoid all these problems by making your properties nullable. Some people think it's OK to do that. I don't, because it allows the creation of invalid objects for your domain and you can get an error 500 when trying to persist such an entity as the nullable: false option of Doctrine is used (SQL integrity constraint violation).

An exception occurred while executing a query: SQLSTATE[23000]: Integrity constraint violation: 1048 Column 'name' cannot be null

Of course, to avoid this, you need a constructor that would have the name as a parameter.
We can also use specific DTO instead of directly using the Doctrine entities.
Don't forget that if you make your properties nullable, you have to deal with this in your whole codebase. Using non-nullable properties makes your code more robust and more readable. Do it when possible! ✨

That's it! I hope you like it. Check out the links below to have additional information related to the post. As always, feedback, likes and retweets are welcome. (see the box below) See you! COil. 😊

  Read the doc  More on the web  More on Stackoverflow

They gave feedback and helped me to fix errors and typos in this article; many thanks to Laurent, Ben Davies, Dan Champion. πŸ‘

  Work with me!


Call to action

Did you like this post? You can help me back in several ways: (use the Tweet on the right to comment or to contact me )

Thank you for reading! And see you soon on Strangebuzz! πŸ˜‰

COil