I have several complex Domain objects. And I use Symfony forms for validating REST requests over them. Let's assume, I have domain Banner. And I have endpoints for creating/updating the concrete banner by ID with BannerFormType. Banner has limits: daily and total. The business rule is "both limits are required, but daily should be less or equal than total". So we have a situation, where we should use a custom class-constraint for validating two fields linked with each other.
So, my form looks like:
<?php
declare(strict_types=1);
namespace App;
class BannerFormType extends \Symfony\Component\Form\AbstractType
{
public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, array $options): void
{
$builder
->add('daily_limit', \Symfony\Component\Form\Extension\Core\Type\IntegerType::class, [
'constraints' => [
new \Symfony\Component\Validator\Constraints\NotBlank(),
],
])
->add('total_limit', \Symfony\Component\Form\Extension\Core\Type\IntegerType::class, [
'constraints' => [
new \Symfony\Component\Validator\Constraints\NotBlank(),
],
])
;
}
public function configureOptions(\Symfony\Component\OptionsResolver\OptionsResolver $resolver): void
{
parent::configureOptions($resolver);
$resolver->setDefaults([
'data_class' => Banner::class,
'constraints' => [
new BannerLimits(),
],
]);
}
}
We code of that constraint looks like:
<?php
declare(strict_types=1);
namespace App;
class BannerLimitsValidator extends \Symfony\Component\Validator\ConstraintValidator
{
public function validate($banner, \Symfony\Component\Validator\Constraint $constraint): void
{
if (!$banner instanceof Banner) {
throw new \Symfony\Component\Validator\Exception\UnexpectedTypeException($banner, Banner::class);
}
if (!$constraint instanceof BannerLimits) {
throw new \Symfony\Component\Validator\Exception\UnexpectedTypeException($constraint, BannerLimits::class);
}
/** @var \Symfony\Component\Form\FormInterface $form */
$form = $this->context->getObject();
$totalLimit = $form->get('total_limit')->getNormData();
$dailyLimit = $form->get('daily_limit')->getNormData();
if ($totalLimit < $dailyLimit) {
$this->context
->buildViolation('Daily limit should be less or equal than total.')
->atPath('daily_limit')
->addViolation();
}
}
}
Everything works fine, except that we have a very coupled validator. If we want it to reuse for other form, there will be a problem. For example, I would like to add bulk endpoint for updating banners, so it will use BannerListFormType.
Of course, I could pass fields data inside constraint via options, but it's hard to do for a nesting forms. Also, I could call a validator directly inside form event, but it looks strange.
Also, with my approach, it's hard to unit test validator with \Symfony\Component\Validator\Test\ConstraintValidatorTestCase because of coupling constraint with form structure.
What could you advice to me? How you guys solve such a problem?