I have a postal code field in my form, which' value should match this regex: /^[1-9][0-9]{3}\s?[a-zA-Z]{2}$/
In my entity I would like all postal codes to have the same format: 4 digits, a space and 2 uppercase letters, so the incoming value needs to be normalized somewhere.
Question: where do I do this conversion? I'm using Symfony's form system and Symfony version 5.4.9.
Entity:
class Address
{
/**
* @ORM\Column(type="string", length=7)
* @Assert\NotBlank
* @Assert\Regex(
* pattern="/^[1-9][0-9]{3}\s?[a-zA-Z]{2}$/",
* message="Deze waarde is geen geldige postcode."
* )
*/
private $postcode;
public function setPostcode(string $postcode): self
{
$this->postcode = $postcode;
return $this;
}
// other fields
}
FormType:
class AddressType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('postcode', null, [
'label' => 'Postcode'
])
// other fields
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Address::class,
]);
}
}
If I understand correctly, the form system sets incoming valid and invalid values from the form directly onto my entity (in the isSubmitted
method in the controller), and after that the entity is validated (in the isValid
method).
So if I add the normalization in my entity's setter (which is called in isSubmitted
), I would have to manually validate the incoming value before I can normalize it, but this duplicates the regex that's later executed by isValid
. Same goes if I were to use an event listener or a transformer on the form, so neither of these seem to be a great solution.
How is this usually done?
CodePudding user response:
I ended up using an event listener as that required the least amount of extra code.
Restricted the regex on the entity like this:
class Address
{
/**
* @ORM\Column(type="string", length=7)
* @Assert\NotBlank
* @Assert\Regex(
* pattern="/^[1-9][0-9]{3} [A-Z]{2}$/",
* message="Deze waarde is geen geldige postcode."
* )
*/
private $postcode;
public function setPostcode(string $postcode): self
{
$this->postcode = $postcode;
return $this;
}
// other fields
}
And added an event listener for the PRE_SUBMIT event on the FormType:
class AddressType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('postcode', null, [
'label' => 'Postcode'
])
// other fields
;
$builder->get('postcode')->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
$data = $event->getData();
if (!\is_string($data)) {
return;
}
if (preg_match('/^([1-9][0-9]{3})\s?([a-zA-Z]{2})$/', $data, $matches)) {
$event->setData($matches[1] . ' ' . strtoupper($matches[2]));
}
});
}
}
This does change the formatting of the value that is displayed in the form in case of a submit invalid, but I did not mind that.
If you want to keep the displayed value exactly as the user inputted it, but transform the value in the background before it is set onto the entity, you can use a ModelTransformer instead:
class AddressType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('postcode', null, [
'label' => 'Postcode'
])
// other fields
;
$builder->get('postcode')
->addModelTransformer(new CallbackTransformer(
function ($modelData) {
return $modelData;
},
function ($normData) {
if (!\is_string($normData)) {
return;
}
if (preg_match('/^([1-9][0-9]{3})\s?([a-zA-Z]{2})$/', $normData, $matches)) {
return $matches[1] . ' ' . strtoupper($matches[2]);
}
return $normData;
}
))
;
}
}
Using a ViewTransformer (->addViewTransformer
) with the same code as the ModelTransformer above also changes the formatting of the value that is displayed in the form, but with more code than the event listener.
CodePudding user response:
First off, yes, you could end up with an entity in an invalid state.
Normalization in this case shouldn't matter, since if the input is valid the value is already normalized (except maybe the case, you could just run it through strtoupper
and relax the insensitive match). Unless I'm missing some specific requirement.
However, if you are trying to be helpful to the user you could be as relaxed as possible during validation and normalize the input yourself.
There are two transformation steps. Validation is performed after normalization. In this case, you can disregard the user-entered formatting (in viewToNorm
) and apply your own and you'll end up with an unformatted string to validate against the regex. Once it's been validated, you can normalize it in the normToModel
stage for storage and display in non-form parts of the app that don't use data transformation.
Both steps are the same but in opposite directions, so you can use ReversedTransformer
to reduce the amount of code.
There is a small caveat with this approach, though: if the input is invalid it will still apply the transformation for display to the user, wich migh be confusing by not matching what was originally entered. There is one way to avoid this: check the format as you did in your solution, but reducing the need to duplicate the regex by coupling the logic a little bit and also makes reusability easier: a custom FormType
. This will allow you to have more entities using it without having to annotate them.
This is how it would look like:
src/Form/DataTransformer/PostalCodeViewTransformer.php
:
<?php
namespace App\Form\DataTransformer;
use Symfony\Component\Form\DataTransformerInterface;
class PostalCodeViewTransformer implements DataTransformerInterface
{
public const PATTERN = '^[1-9][0-9]{3}[A-Z]{2}$';
public function reverseTransform(string $value)
{
return $this->removeFormatting($value);
}
public function transform($value)
{
return $this->applyFormatting($value);
}
/**
* Remove all spureous characters from input (within reason):
* 1.234 ab => 1234AB
*/
private function removeFormatting(?$input)
{
return \strtoupper(\preg_replace('/[\.,\s]/', '', $input));
}
/**
* Apply the formatting, at this point the input _should_ be valid:
* 1234AB => 1234 AB
*/
private function applyFormatting($input)
{
if (\preg_match('/'.self::PATTERN.'/i', $input)) {
return \substr($input, 0, 4).' '.\strtoupper(\substr($input, -2));
}
// Let whatever input pass through
return $input;
// Something was saved in the wrong state, you would normally:
// throw new TransformationFailedException();
}
}
src/Form/Type/PostalCodeType.php
:
<?php
namespace App\Form\Type;
use App\Form\DataTransformer\PostalCodeViewTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class PostalCodeType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$transformer = new PostalCodeViewTransformer();
$builder
->addViewTransformer($transformer)
->addModelTransformer(new ReversedTransformer($transformer))
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'constraints' => [
new NotNull(),
new Regex([
// Insensitive match
'pattern' => '/'.PostalCodeViewTransformer::PATTERN.'/i',
'message' => 'Deze waarde is geen geldige postcode.',
]),
],
/* Optional, since somehow defeats the purpose of not enforcing
the regex. */
'attr' => [
'minlength' => 6,
'maxLength' => 7,
'placeholder' => '1234 AB',
],
]);
}
public function getParent(): string
{
return TextType::class;
}
}
Your form:
use App\Form\Type\PostalCodeType;
class AddressType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('postcode', PostalCodeType::class)
// Other stuff
;
}
}
And as I said, you can drop the @Assert
annotations in the entity, since they are embedded in the form.