Kamil P.
Kamil P.

Reputation: 157

Symfony conditional validation for Valid() constraint with groups

I have ran into, at least for me, quite strange Symfony behaviour. I have form type with nested form. I need to validate this nested form only in some circumstances.

That's why I added validation groups to Valid() constraint. If I use same group for eg. TextType, it works as expected. If I remove groups from Valid() it also works. So it means Valid() constraint itself works, it just don't work when validation groups are used.

      $builder
               ->add('test', TextType::class, [
                    'mapped' => false,
                    'constraints' => [new NotBlank(['groups' => 'SenderAddress'])],    
                ])
                ->add('senderAddress', AddressType::class, [
                    'label' => false,
                    'constraints' => [new Valid(['groups' => 'SenderAddress'])],
                ])
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'validation_groups' => function (FormInterface $form) {
                if (!$form->get('senderAddresses')->getData()) {
                    return ['Default', 'SenderAddress'];
                }
                return ['Default'];
            },
        ]);
    }

So if nothing is filled in senderAddresses and

  1. test field is empty, test field is correctly validated and NotBlank() vioaliton is used
  2. senderAddress doesn't show any vioaliton even that fields are empty ?!

But when I try to remove group validation for senderAddress it works as expected. What am I doing wrong?

->add('senderAddress', AddressType::class, [
   'label' => false,
   'constraints' => [new Valid()],  //but this is not how I want it!
])

EDIT: These are the objects I work with

Entity Order for CalculationType

<?php

namespace App\Entity;

use App\Repository\OrderRepository;
use App\Traits\IdentifierTrait;
use DateTime;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Entity(repositoryClass=OrderRepository::class)
 * @ORM\Table(name="`order`")
 */
class Order
{
    use IdentifierTrait;

    public const STATUSES = [
        'order.statuses.calculation' => self::STATUS_CALCULATION,
        'order.statuses.created' => self::STATUS_CREATED,
    ];

    public const STATUS_CALCULATION = 1;
    public const STATUS_CREATED = 2;

    /**
     * @ORM\ManyToOne(targetEntity="Address", cascade={"persist"})
     *
     * @var Address
     */
    private Address $senderAddress;

    /**
     * @ORM\ManyToOne(targetEntity="Address", cascade={"persist"})
     *
     * @var Address
     */
    private Address $recipientAddress;

    /**
     * @ORM\ManyToOne(targetEntity="Package", cascade={"persist"})
     * @Assert\Valid()
     *
     * @var Package
     */
    private Package $package;

    /**
     * @ORM\Column(type="datetime")
     *
     * @var DateTime
     */
    private DateTime $createdAt;

    /**
     * @ORM\Column(type="integer", length=1, options={"unsigned": true})
     *
     * @var int
     */
    protected int $status = self::STATUS_CALCULATION;

    /**
     * @ORM\Column(type="text", length=255, nullable=TRUE)
     *
     * @var null|string
     */
    private ?string $serviceType = null;

    /**
     * @ORM\ManyToOne(targetEntity=User::class)
     *
     * @var User
     */
    private User $user;

    /**
     * Order constructor.
     */
    public function __construct()
    {
        $this->createdAt = new DateTime('now');
    }

    /**
     * @return Address
     */
    public function getSenderAddress(): Address
    {
        return $this->senderAddress;
    }

    /**
     * @param Address $senderAddress
     */
    public function setSenderAddress(Address $senderAddress): void
    {
        $this->senderAddress = $senderAddress;
    }

    /**
     * @return Address
     */
    public function getRecipientAddress(): Address
    {
        return $this->recipientAddress;
    }

    /**
     * @param Address $recipientAddress
     */
    public function setRecipientAddress(Address $recipientAddress): void
    {
        $this->recipientAddress = $recipientAddress;
    }

    /**
     * @return Package
     */
    public function getPackage(): Package
    {
        return $this->package;
    }

    /**
     * @param Package $package
     */
    public function setPackage(Package $package): void
    {
        $this->package = $package;
    }

    /**
     * @return string|null
     */
    public function getServiceType(): ?string
    {
        return $this->serviceType;
    }

    /**
     * @param string|null $serviceType
     */
    public function setServiceType(?string $serviceType): void
    {
        $this->serviceType = $serviceType;
    }

    /**
     * @return DateTime
     */
    public function getCreatedAt(): DateTime
    {
        return $this->createdAt;
    }

    /**
     * @param DateTime $createdAt
     */
    public function setCreatedAt(DateTime $createdAt): void
    {
        $this->createdAt = $createdAt;
    }

    /**
     * @return int
     */
    public function getStatus(): int
    {
        return $this->status;
    }

    /**
     * @param int $status
     */
    public function setStatus(int $status): void
    {
        $this->status = $status;
    }

    /**
     * @return User
     */
    public function getUser(): User
    {
        return $this->user;
    }

    /**
     * @param User $user
     */
    public function setUser(User $user): void
    {
        $this->user = $user;
    }
}

Entity Address for AddressType

<?php

namespace App\Entity;

use App\Repository\AddressRepository;
use App\Traits\IdentifierTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Intl\Countries;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Entity(repositoryClass=AddressRepository::class)
 */
class Address
{
    use IdentifierTrait;

    public const TYPES = [
        'address.types.sender' => self::TYPE_SENDER,
        'address.types.recipient' => self::TYPE_RECIPIENT,
    ];

    public const TYPE_SENDER = 1;
    public const TYPE_RECIPIENT = 2;

    /**
     * @ORM\ManyToOne(targetEntity=User::class, inversedBy="addresses")
     *
     * @var User|null
     */
    private ?User $user;

    /**
     * @ORM\Column(type="string", length=100, nullable=true)
     * @Assert\Length(
     *     max="100"
     * )
     *
     * @var string|null
     */
    private ?string $name = null;

    /**
     * @ORM\Column(type="string", length=150)
     * @Assert\NotBlank()
     * @Assert\Length(
     *     max="150"
     * )
     *
     * @var string|null
     */
    private ?string $street = null;

    /**
     * @ORM\Column(type="string", length=100)
     * @Assert\NotBlank()
     * @Assert\Length(
     *     max="100"
     * )
     *
     * @var string|null
     */
    private ?string $city = null;

    /**
     * @ORM\Column(type="string", length=15)
     * @Assert\NotBlank()
     * @Assert\Length(
     *     max="15"
     * )
     *
     * @var string|null
     */
    private ?string $postalCode = null;

    /**
     * @ORM\Column(type="string", length=3)
     * @Assert\NotBlank()
     * @Assert\Length(
     *     max="3"
     * )
     *
     * @var string|null
     */
    private ?string $country = null;

    /**
     * @ORM\Column(type="string", length=3, nullable=true)
     *
     * @var string|null
     */
    private ?string $province = null;

    /**
     * @ORM\Column(type="integer", length=1, options={"unsigned": true})
     *
     * @var int
     */
    protected int $type = self::TYPE_SENDER;

    /**
     * Address constructor.
     *
     * @param int $type
     * @param User|null $user
     */
    public function __construct(int $type = self::TYPE_SENDER, User $user = null)
    {
        $this->type = $type;
        $this->user = $user;
    }

    /**
     * @return User|null
     */
    public function getUser(): ?User
    {
        return $this->user;
    }

    /**
     * @param User|null $user
     */
    public function setUser(?User $user): void
    {
        $this->user = $user;
    }

    /**
     * @return string|null
     */
    public function getName(): ?string
    {
        return $this->name;
    }

    /**
     * @param string|null $name
     */
    public function setName(?string $name): void
    {
        $this->name = $name;
    }

    /**
     * @return string|null
     */
    public function getStreet(): ?string
    {
        return $this->street;
    }

    /**
     * @param string|null $street
     */
    public function setStreet(?string $street): void
    {
        $this->street = $street;
    }

    /**
     * @return string|null
     */
    public function getCity(): ?string
    {
        return $this->city;
    }

    /**
     * @param string|null $city
     */
    public function setCity(?string $city): void
    {
        $this->city = $city;
    }

    /**
     * @return string|null
     */
    public function getPostalCode(): ?string
    {
        return $this->postalCode;
    }

    /**
     * @param string|null $postalCode
     */
    public function setPostalCode(?string $postalCode): void
    {
        $this->postalCode = $postalCode;
    }

    /**
     * @return string|null
     */
    public function getCountry(): ?string
    {
        return $this->country;
    }

    /**
     * @param string|null $country
     */
    public function setCountry(?string $country): void
    {
        $this->country = $country;
    }

    /**
     * Get country name
     */
    public function getCountryName(): string
    {
        return Countries::getName($this->country);
    }

    /**
     * @return string|null
     */
    public function getProvince(): ?string
    {
        return $this->province;
    }

    /**
     * @param string|null $province
     */
    public function setProvince(?string $province): void
    {
        $this->province = $province;
    }

    /**
     * Get full description
     *
     * @return string
     */
    public function getFullDescription(): string
    {
        return ($this->name ? ($this->name . ' - ') : '') . $this->street . ', ' . $this->postalCode . ' ' . $this->city . ', ' . $this->getCountryName();
    }

    /**
     * @return int
     */
    public function getType(): int
    {
        return $this->type;
    }

    /**
     * @param int $type
     */
    public function setType(int $type): void
    {
        $this->type = $type;
    }

}

CalculationType

<?php

namespace App\Form;

use App\Entity\Address;
use App\Entity\User;
use Doctrine\ORM\EntityRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints\Valid;

class CalculationType extends AbstractType
{

    /**
     * @var UserInterface|User|null
     */
    private ?UserInterface $user;

    /**
     * CalculationFormType constructor.
     *
     * @param Security $security
     */
    public function __construct(Security $security)
    {
        $this->user = $security->getUser();
    }

    /**
     * @param FormBuilderInterface $builder
     * @param array $options
     *
     */
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('senderAddress', AddressType::class, [
                'label' => false,
                'constraints' => [new Valid(['groups' => 'NotNullAddress'])],  //todo doesnt work
            ])
            ->add('recipientAddress', AddressType::class, [
                'label' => false,
                'constraints' => [new Valid(['groups' => 'NotNullAddress'])],  //todo doesnt work
                'show_name' => false,
            ])
            ->add('package', PackageType::class, ['label' => false])
            ->add('calculate', SubmitType::class, [
                'label' => 'order.calculate',
            ]);

        $this->addressesField($builder, 'senderAddresses', Address::TYPE_SENDER);
        $this->addressesField($builder, 'recipientAddresses', Address::TYPE_RECIPIENT);

    }

    /**
     * @param OptionsResolver $resolver
     */
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'validation_groups' => static function (FormInterface $form) {
                if (!$form->get('senderAddresses')->getData()) {
                    return ['Default', 'NotNullAddress'];
                }
                return ['Default'];
            },
        ]);
    }

    /**
     * Addresses field
     *
     * @param FormBuilderInterface $builder
     * @param string $childName
     * @param int $type
     */
    private function addressesField(FormBuilderInterface $builder, string $childName, int $type): void
    {
        $builder->add($childName, EntityType::class, [
            'class' => Address::class,
            'mapped' => false,
            'required' => false,
            'placeholder' => 'order.newAddress',
            'choice_label' => 'fullDescription',
            'query_builder' => function (EntityRepository $er) use ($type) {
                return $er->createQueryBuilder('address')
                    ->andWhere('address.user = :user')
                    ->andWhere('address.type = ' . $type)
                    ->setParameter('user', $this->user);
            },
            'attr' => [
                'class' => 'select-existing-address',
            ],
        ]);
    }
}

AddressType

<?php

namespace App\Form;

use App\Entity\Address;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class AddressType extends AbstractType
{
    /**
     * @param FormBuilderInterface $builder
     * @param array $options
     *
     * @throws \JsonException
     */
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        if ($options['show_name']) {
            $builder->add('name');
        }

        $builder->add('street')
            ->add('city')
            ->add('postalCode')
            ->add('country', CountryType::class, [
                'preferred_choices' => ['CZ', 'DE', 'IT', 'SK'],
            ])
            ->add('province');
    }

    /**
     * @param OptionsResolver $resolver
     */
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Address::class,
            'show_name' => true,
        ]);
    }
}

Upvotes: 1

Views: 2129

Answers (1)

BogdanG
BogdanG

Reputation: 111

I just solved this problem for a form that I am building. I am adding this answer here for future users that might need a solution to this problem. When validating an embedded object, you have to add the validation group to that object's properties. I use annotations for this, so the Address entity should look like this (notice the groups option added to each property that needs validation):

<?php

namespace App\Entity;

use App\Repository\AddressRepository;
use App\Traits\IdentifierTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Intl\Countries;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Entity(repositoryClass=AddressRepository::class)
 */
class Address
{
    use IdentifierTrait;

    public const TYPES = [
        'address.types.sender' => self::TYPE_SENDER,
        'address.types.recipient' => self::TYPE_RECIPIENT,
    ];

    public const TYPE_SENDER = 1;
    public const TYPE_RECIPIENT = 2;

    /**
     * @ORM\ManyToOne(targetEntity=User::class, inversedBy="addresses")
     *
     * @var User|null
     */
    private ?User $user;

    /**
     * @ORM\Column(type="string", length=100, nullable=true)
     * @Assert\Length(
     *     max="100",
     *     groups={"SenderAddress"}
     * )
     *
     * @var string|null
     */
    private ?string $name = null;

    /**
     * @ORM\Column(type="string", length=150)
     * @Assert\NotBlank(groups={"SenderAddress"})
     * @Assert\Length(
     *     max="150",
     *     groups={"SenderAddress"},
     * )
     *
     * @var string|null
     */
    private ?string $street = null;

    /**
     * @ORM\Column(type="string", length=100)
     * @Assert\NotBlank(groups={"SenderAddress"})
     * @Assert\Length(
     *     max="100",
     *     groups={"SenderAddress"}
     * )
     *
     * @var string|null
     */
    private ?string $city = null;

    /**
     * @ORM\Column(type="string", length=15)
     * @Assert\NotBlank(groups={"SenderAddress"})
     * @Assert\Length(
     *     max="15"
     *     groups={"SenderAddress"}
     * )
     *
     * @var string|null
     */
    private ?string $postalCode = null;

    /**
     * @ORM\Column(type="string", length=3)
     * @Assert\NotBlank(groups={"SenderAddress"})
     * @Assert\Length(
     *     max="3"
     *     groups={"SenderAddress"}
     * )
     *
     * @var string|null
     */
    private ?string $country = null;

    /**
     * @ORM\Column(type="string", length=3, nullable=true)
     *
     * @var string|null
     */
    private ?string $province = null;

    /**
     * @ORM\Column(type="integer", length=1, options={"unsigned": true})
     *
     * @var int
     */
    protected int $type = self::TYPE_SENDER;
    

    // the rest of the entity....

}

The Order entity should only have the Valid annotation for the sender address:

/**
 * @ORM\Entity(repositoryClass=OrderRepository::class)
 * @ORM\Table(name="`order`")
 */
class Order
{
    use IdentifierTrait;

    public const STATUSES = [
        'order.statuses.calculation' => self::STATUS_CALCULATION,
        'order.statuses.created' => self::STATUS_CREATED,
    ];

    public const STATUS_CALCULATION = 1;
    public const STATUS_CREATED = 2;

    /**
     * @Assert\Valid()
     * @ORM\ManyToOne(targetEntity="Address", cascade={"persist"})
     * @var Address
     */
    private Address $senderAddress;
    
    // the rest of the entity

The form should work with the modifications. More details here

Upvotes: 0

Related Questions