From 620289237de11944cda0e259f8c9283e19329ddf Mon Sep 17 00:00:00 2001 From: Vincent QUATREVIEUX Date: Thu, 19 Mar 2026 16:08:57 +0100 Subject: [PATCH 01/14] chore: Compatibility with bdf-form v2.0 (#FRAM-222) --- .github/workflows/php.yml | 44 +++++++++++++++++++++++++++ Tests/Forms/MyConstraintValidator.php | 2 +- composer.json | 2 +- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 841bf16..ce8470b 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -65,3 +65,47 @@ jobs: - name: Run test suite run: composer run-script tests + + bdf_form_compatibility: + runs-on: ubuntu-latest + strategy: + matrix: + form-versions: + - package: '1.0' + php: '8.0' + - package: '1.1' + php: '8.0' + - package: '1.2' + php: '8.0' + - package: '1.3' + php: '8.0' + - package: '1.4' + php: '8.0' + - package: '1.6' + php: '8.0' + - package: '1.7' + php: '8.4' + - package: '2.0' + php: '8.4' + name: Compatibility with b2pweb/bdf-form ${{ matrix.form-versions.package }} + + steps: + - uses: actions/checkout@v2 + + - name: Set Timezone + uses: szenius/set-timezone@v1.0 + with: + timezoneLinux: "Europe/Paris" + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.form-versions.php }} + extensions: json + ini-values: date.timezone=Europe/Paris + + - name: Install dependencies + run: composer req --dev 'b2pweb/bdf-form:~${{ matrix.form-versions.package }}.0' --prefer-dist --no-progress + + - name: Run test suite + run: composer run-script tests diff --git a/Tests/Forms/MyConstraintValidator.php b/Tests/Forms/MyConstraintValidator.php index 59719d3..af78342 100644 --- a/Tests/Forms/MyConstraintValidator.php +++ b/Tests/Forms/MyConstraintValidator.php @@ -19,7 +19,7 @@ public function __construct(A $a) $this->a = $a; } - public function validate($value, Constraint $constraint) + public function validate($value, Constraint $constraint): void { self::$injectedParameter = $this->a; } diff --git a/composer.json b/composer.json index bb70106..575ac59 100755 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ "minimum-stability": "dev", "require": { "php": "~7.2 | ~8.0", - "b2pweb/bdf-form": "~1.0", + "b2pweb/bdf-form": "~1.0|~2.0", "symfony/config": "~5.0|~6.0|~7.0", "symfony/dependency-injection": "~5.0|~6.0|~7.0", "symfony/framework-bundle": "~5.0|~6.0|~7.0" From 3022ea497ddc91766e1c9026dbb26d3f0bf2e763 Mon Sep 17 00:00:00 2001 From: Vincent QUATREVIEUX Date: Thu, 19 Mar 2026 16:17:03 +0100 Subject: [PATCH 02/14] ci: disable fail-fast --- .github/workflows/php.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index ce8470b..995c2f3 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -69,6 +69,7 @@ jobs: bdf_form_compatibility: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: form-versions: - package: '1.0' From e3fb4b79b1bf67b18808c965997114857b80638f Mon Sep 17 00:00:00 2001 From: Vincent QUATREVIEUX Date: Thu, 19 Mar 2026 16:19:23 +0100 Subject: [PATCH 03/14] ci: try fix bdf form compatibility job --- .github/workflows/php.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 995c2f3..ec58259 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -106,6 +106,9 @@ jobs: ini-values: date.timezone=Europe/Paris - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Install bdf form run: composer req --dev 'b2pweb/bdf-form:~${{ matrix.form-versions.package }}.0' --prefer-dist --no-progress - name: Run test suite From ed89052113ad3ea78003889a1cf7404eb90a9429 Mon Sep 17 00:00:00 2001 From: Vincent QUATREVIEUX Date: Thu, 19 Mar 2026 16:27:22 +0100 Subject: [PATCH 04/14] ci: try something --- .github/workflows/php.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index ec58259..2e18d59 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -109,7 +109,7 @@ jobs: run: composer install --prefer-dist --no-progress - name: Install bdf form - run: composer req --dev 'b2pweb/bdf-form:~${{ matrix.form-versions.package }}.0' --prefer-dist --no-progress + run: composer req --dev 'b2pweb/bdf-form:~${{ matrix.form-versions.package }}.0' - name: Run test suite run: composer run-script tests From 5c5b077977ff16437c65f61cb59ceb7ff6e4ce61 Mon Sep 17 00:00:00 2001 From: Vincent QUATREVIEUX Date: Thu, 19 Mar 2026 16:29:13 +0100 Subject: [PATCH 05/14] ci: try something --- .github/workflows/php.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 2e18d59..b9f42ed 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -109,7 +109,7 @@ jobs: run: composer install --prefer-dist --no-progress - name: Install bdf form - run: composer req --dev 'b2pweb/bdf-form:~${{ matrix.form-versions.package }}.0' + run: composer req --dev -W 'b2pweb/bdf-form:~${{ matrix.form-versions.package }}.0' - name: Run test suite run: composer run-script tests From 8af1ad65f511d47434f347514161d2d190c128d7 Mon Sep 17 00:00:00 2001 From: Vincent QUATREVIEUX Date: Thu, 19 Mar 2026 16:30:37 +0100 Subject: [PATCH 06/14] ci: debug --- .github/workflows/php.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index b9f42ed..f893be7 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -108,8 +108,8 @@ jobs: - name: Install dependencies run: composer install --prefer-dist --no-progress - - name: Install bdf form - run: composer req --dev -W 'b2pweb/bdf-form:~${{ matrix.form-versions.package }}.0' +# - name: Install bdf form +# run: composer req --dev -W 'b2pweb/bdf-form:~${{ matrix.form-versions.package }}.0' - name: Run test suite run: composer run-script tests From 6ceb8b672fb05912a01bc680a2325599427a055b Mon Sep 17 00:00:00 2001 From: Vincent QUATREVIEUX Date: Thu, 19 Mar 2026 16:33:25 +0100 Subject: [PATCH 07/14] ci: set bdf-form version using sed --- .github/workflows/php.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index f893be7..d4b833c 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -105,11 +105,12 @@ jobs: extensions: json ini-values: date.timezone=Europe/Paris + - name: Set bdf-form version + run: | + sed -i "s/\"b2pweb\/bdf-form\": \".*\"/\"b2pweb\/bdf-form\": \"~${{ matrix.form-versions.package }}.0\"/g" composer.json + - name: Install dependencies run: composer install --prefer-dist --no-progress -# - name: Install bdf form -# run: composer req --dev -W 'b2pweb/bdf-form:~${{ matrix.form-versions.package }}.0' - - name: Run test suite run: composer run-script tests From f7a73ea195b99704e1bfc0b2e95d283040fd9dcd Mon Sep 17 00:00:00 2001 From: Vincent QUATREVIEUX Date: Mon, 20 Jul 2026 13:56:59 +0200 Subject: [PATCH 08/14] feat: Handle struct forms (#FRAM-222) --- Attribute/GeneratedConfiguratorResolver.php | 8 +- .../Compiler/CompileAttributeForms.php | 2 +- .../Compiler/RegisterCustomBuilders.php | 2 +- .../Compiler/RegisterCustomForms.php | 2 +- .../Compiler/UseCsrfTokenManager.php | 2 +- DependencyInjection/FormExtension.php | 36 ++++++++- Http/Submit/SubmitForm.php | 5 +- Http/Submit/SubmitFormValueResolver.php | 31 +++++++- Registry/SymfonyRegistry.php | 21 +++++ Tests/FormBundleWithAttributeTest.php | 48 ++++++++++-- Tests/FormsAttributes/StructDto.php | 18 +++++ Tests/Http/Form/PersonStruct.php | 20 +++++ Tests/Http/FunctionSubmitFormTest.php | 76 +++++++++++++++++++ .../Submit/SubmitFormValueResolverTest.php | 5 ++ Tests/Http/TestKernel.php | 17 +++++ composer.json | 14 ++-- 16 files changed, 283 insertions(+), 24 deletions(-) create mode 100644 Tests/FormsAttributes/StructDto.php create mode 100644 Tests/Http/Form/PersonStruct.php diff --git a/Attribute/GeneratedConfiguratorResolver.php b/Attribute/GeneratedConfiguratorResolver.php index 078ff88..41e731d 100644 --- a/Attribute/GeneratedConfiguratorResolver.php +++ b/Attribute/GeneratedConfiguratorResolver.php @@ -2,7 +2,6 @@ namespace Bdf\Form\Bundle\Attribute; -use Bdf\Form\Attribute\AttributeForm; use Bdf\Form\Attribute\Processor\GenerateConfiguratorStrategy; /** @@ -40,9 +39,12 @@ public function __construct(string $prefix, string $suffix, string $basePath) * Prefix and suffix will be added on the form class name * In case of anonymous class, all forbidden chars will be removed to generate a correct class name */ - public function resolveClassName(AttributeForm $form): string + public function resolveClassName($formClass): string { - $formClass = get_class($form); + if (\is_object($formClass)) { + $formClass = \get_class($formClass); + } + $parts = preg_split('#[^a-z\\\\]#i', $formClass); if (count($parts) > 1) { diff --git a/DependencyInjection/Compiler/CompileAttributeForms.php b/DependencyInjection/Compiler/CompileAttributeForms.php index 238dccc..1aa164c 100644 --- a/DependencyInjection/Compiler/CompileAttributeForms.php +++ b/DependencyInjection/Compiler/CompileAttributeForms.php @@ -12,7 +12,7 @@ */ class CompileAttributeForms implements CompilerPassInterface { - public function process(ContainerBuilder $container) + public function process(ContainerBuilder $container): void { /** @var CompileAttributesProcessor $compiler */ $compiler = $container->get(CompileAttributesProcessor::class); diff --git a/DependencyInjection/Compiler/RegisterCustomBuilders.php b/DependencyInjection/Compiler/RegisterCustomBuilders.php index 0ba85e8..c840417 100644 --- a/DependencyInjection/Compiler/RegisterCustomBuilders.php +++ b/DependencyInjection/Compiler/RegisterCustomBuilders.php @@ -11,7 +11,7 @@ */ class RegisterCustomBuilders implements CompilerPassInterface { - public function process(ContainerBuilder $container) + public function process(ContainerBuilder $container): void { $registry = $container->findDefinition(SymfonyRegistry::class); diff --git a/DependencyInjection/Compiler/RegisterCustomForms.php b/DependencyInjection/Compiler/RegisterCustomForms.php index b014c98..05fdcc1 100644 --- a/DependencyInjection/Compiler/RegisterCustomForms.php +++ b/DependencyInjection/Compiler/RegisterCustomForms.php @@ -10,7 +10,7 @@ */ class RegisterCustomForms implements CompilerPassInterface { - public function process(ContainerBuilder $container) + public function process(ContainerBuilder $container): void { foreach ($container->findTaggedServiceIds('form.custom_form') as $id => $tags) { $container->findDefinition($id) diff --git a/DependencyInjection/Compiler/UseCsrfTokenManager.php b/DependencyInjection/Compiler/UseCsrfTokenManager.php index 96ec650..c312de2 100644 --- a/DependencyInjection/Compiler/UseCsrfTokenManager.php +++ b/DependencyInjection/Compiler/UseCsrfTokenManager.php @@ -12,7 +12,7 @@ */ class UseCsrfTokenManager implements CompilerPassInterface { - public function process(ContainerBuilder $container) + public function process(ContainerBuilder $container): void { if ($container->hasDefinition('security.csrf.token_manager')) { $container->findDefinition(CsrfElementBuilder::class) diff --git a/DependencyInjection/FormExtension.php b/DependencyInjection/FormExtension.php index 6b10f40..bc37301 100644 --- a/DependencyInjection/FormExtension.php +++ b/DependencyInjection/FormExtension.php @@ -7,19 +7,24 @@ use Bdf\Form\Attribute\Processor\CompileAttributesProcessor; use Bdf\Form\Attribute\Processor\ReflectionProcessor; use Bdf\Form\Bundle\Attribute\GeneratedConfiguratorResolver; +use Bdf\Form\Bundle\Registry\SymfonyRegistry; use Bdf\Form\Custom\CustomForm; use Bdf\Form\ElementBuilderInterface; +use Bdf\Form\Struct\StructAttributesProcessorFactory; +use Bdf\Form\Struct\StructForm; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\Compiler\PriorityTaggedServiceTrait; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; +use Symfony\Component\DependencyInjection\Reference; class FormExtension extends Extension { use PriorityTaggedServiceTrait; - public function load(array $configs, ContainerBuilder $container) + public function load(array $configs, ContainerBuilder $container): void { $loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); $loader->load('form.yaml'); @@ -52,6 +57,10 @@ public function load(array $configs, ContainerBuilder $container) if (class_exists(AttributeForm::class)) { $this->configureAttributes($container, $config['attributes']); } + + if (class_exists(StructForm::class)) { + $this->configureStructForm($container, $config['attributes']); + } } /** @@ -78,4 +87,29 @@ private function configureAttributes(ContainerBuilder $container, array $config) $container->setAlias(AttributesProcessorInterface::class, ReflectionProcessor::class); } } + + private function configureStructForm(ContainerBuilder $container, array $config): void + { + $container->register(StructAttributesProcessorFactory::class); + + if ($config['compile']) { + $container->register(SymfonyRegistry::STRUCT_FORM_PROCESSOR_SERVICE_ID, AttributesProcessorInterface::class) + ->setFactory([new Reference(StructAttributesProcessorFactory::class), 'generated']) + ->setArguments([ + (new Definition(\Closure::class)) + ->setFactory([\Closure::class, 'fromCallable']) + ->setArgument(0, [new Reference(GeneratedConfiguratorResolver::class), 'resolveClassName']), + (new Definition(\Closure::class)) + ->setFactory([\Closure::class, 'fromCallable']) + ->setArgument(0, [new Reference(GeneratedConfiguratorResolver::class), 'resolveFilename']), + ]) + ->setPublic(true) + ; + } else { + $container->register(SymfonyRegistry::STRUCT_FORM_PROCESSOR_SERVICE_ID, AttributesProcessorInterface::class) + ->setFactory([new Reference(StructAttributesProcessorFactory::class), 'runtime']) + ->setPublic(true) + ; + } + } } diff --git a/Http/Submit/SubmitForm.php b/Http/Submit/SubmitForm.php index 61a242d..9b6d92e 100644 --- a/Http/Submit/SubmitForm.php +++ b/Http/Submit/SubmitForm.php @@ -4,7 +4,6 @@ use Bdf\Form\Aggregate\FormInterface; use Bdf\Form\Bundle\Http\PayloadSource; -use Bdf\Form\Custom\CustomForm; use Symfony\Component\HttpKernel\Attribute\ValueResolver; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; @@ -71,7 +70,9 @@ public function __construct( * The form class to use. * If null, it will be determined based on the argument type. * - * @var class-string|null + * In case of struct form, the DTO/Struct class name will be used instead of the form class. + * + * @var class-string|null */ public ?string $form = null, diff --git a/Http/Submit/SubmitFormValueResolver.php b/Http/Submit/SubmitFormValueResolver.php index 210b65e..4894e98 100644 --- a/Http/Submit/SubmitFormValueResolver.php +++ b/Http/Submit/SubmitFormValueResolver.php @@ -2,10 +2,12 @@ namespace Bdf\Form\Bundle\Http\Submit; +use Bdf\Form\Aggregate\FormInterface; use Bdf\Form\Bundle\Http\InvalidFormException; use Bdf\Form\Bundle\Http\PayloadSource; use Bdf\Form\ElementInterface; use Bdf\Form\Registry\RegistryInterface; +use Bdf\Form\Struct\StructForm; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; @@ -16,10 +18,19 @@ final class SubmitFormValueResolver implements ValueResolverInterface, EventSubscriberInterface { + /** + * Placeholder value for FormInterface parameter without associated SubmitForm attribute. + * The submitted form will be passed to arguments which is marked with this placeholder. + * + * @var object|null + */ + private static $formArgumentPlaceholder; + public function __construct( private readonly RegistryInterface $registry, private readonly ?TranslatorInterface $translator = null, ) { + self::$formArgumentPlaceholder ??= new \stdClass(); } #[\Override] @@ -28,6 +39,10 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable $attribute = $argument->getAttributesOfType(SubmitForm::class)[0] ?? null; if (null === $attribute) { + if (FormInterface::class === $argument->getType()) { + return [self::$formArgumentPlaceholder]; + } + return []; } @@ -36,7 +51,7 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable $attribute->value ??= $type && !\is_subclass_of($type, ElementInterface::class); if (null === $attribute->form) { - if (true === $attribute->value) { + if (true === $attribute->value && !\class_exists(StructForm::class)) { throw new \LogicException('The form class must be defined when the value is requested'); } @@ -56,6 +71,7 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo { $arguments = $event->getArguments(); $hasChanged = false; + $form = null; foreach ($arguments as $i => $argument) { if (!$argument instanceof SubmitForm) { @@ -63,7 +79,10 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo } $payload = $this->extractPayload($event->getRequest(), $argument->source); - $form = $this->registry->elementBuilder($argument->form)->buildElement(); + $form = \is_subclass_of($argument->form, ElementInterface::class) + ? $this->registry->elementBuilder($argument->form)->buildElement() + : $this->registry->elementBuilder(StructForm::class)->class($argument->form)->buildElement() + ; $form->submit($payload); if ($argument->validate && !$form->valid()) { @@ -74,6 +93,14 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo $hasChanged = true; } + if (null !== $form) { + foreach ($arguments as $i => $argument) { + if ($argument === self::$formArgumentPlaceholder) { + $arguments[$i] = $form; + } + } + } + if ($hasChanged) { $event->setArguments($arguments); } diff --git a/Registry/SymfonyRegistry.php b/Registry/SymfonyRegistry.php index de2a050..f3254a7 100644 --- a/Registry/SymfonyRegistry.php +++ b/Registry/SymfonyRegistry.php @@ -12,6 +12,8 @@ use Bdf\Form\Filter\FilterInterface; use Bdf\Form\Registry\Registry; use Bdf\Form\Registry\RegistryInterface; +use Bdf\Form\Struct\StructForm; +use Bdf\Form\Struct\StructFormBuilder; use Bdf\Form\Transformer\TransformerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\Validator\Constraint; @@ -21,6 +23,8 @@ */ class SymfonyRegistry implements RegistryInterface { + public const STRUCT_FORM_PROCESSOR_SERVICE_ID = 'bdf_form.struct.processor'; + /** * @var Registry */ @@ -45,6 +49,10 @@ public function __construct(Registry $registry, ContainerInterface $container) $this->container = $container; $registry->register(CustomForm::class, [$this, 'customFormBuilder']); + + if (\class_exists(StructForm::class)) { + $registry->register(StructForm::class, [$this, 'structFormBuilder']); + } } public function filter($filter): FilterInterface @@ -103,6 +111,19 @@ function ($builder) use ($formClass) { ); } + /** + * Create the form builder instance. + * + * @internal + */ + public function structFormBuilder(RegistryInterface $registry, string $formClass): StructFormBuilder + { + return new StructFormBuilder( + $this->container->get(self::STRUCT_FORM_PROCESSOR_SERVICE_ID), + $registry->elementBuilder(Form::class), + ); + } + /** * Register the element builder to the related element if possible. * diff --git a/Tests/FormBundleWithAttributeTest.php b/Tests/FormBundleWithAttributeTest.php index 480d7c4..b3e0fb5 100644 --- a/Tests/FormBundleWithAttributeTest.php +++ b/Tests/FormBundleWithAttributeTest.php @@ -5,11 +5,15 @@ require_once __DIR__.'/TestKernel.php'; use Bdf\Form\Aggregate\FormBuilder; +use Bdf\Form\Aggregate\FormInterface; use Bdf\Form\Attribute\AttributeForm; use Bdf\Form\Attribute\Processor\CompileAttributesProcessor; use Bdf\Form\Attribute\Processor\ReflectionProcessor; +use Bdf\Form\Bundle\Tests\FormsAttributes\StructDto; use Bdf\Form\Bundle\Tests\FormsAttributes\WithAnonymousFormClass; use Bdf\Form\Bundle\Tests\FormsAttributes\WithAttributes; +use Bdf\Form\Registry\RegistryInterface; +use Bdf\Form\Struct\StructForm; use PHPUnit\Framework\TestCase; use Symfony\Component\Filesystem\Filesystem; @@ -20,7 +24,7 @@ class FormBundleWithAttributeTest extends TestCase { protected function setUp(): void { - if (!class_exists(AttributeForm::class)) { + if (!\class_exists(AttributeForm::class)) { $this->markTestSkipped(); } @@ -50,7 +54,7 @@ public function testShouldUseCompileAttributesProcessor() $form = $kernel->getContainer()->get(WithAttributes::class); $prop = new \ReflectionProperty(AttributeForm::class, 'processor'); - $prop->setAccessible(true); + PHP_VERSION_ID >= 80500 || $prop->setAccessible(true); $processor = $prop->getValue($form); $this->assertInstanceOf(CompileAttributesProcessor::class, $processor); @@ -79,6 +83,40 @@ public function testFunctional() $this->assertSame(['foo' => 'azerty', 'bar' => 5], $form->value()); } + /** + * @return void + */ + public function testFunctionalStruct() + { + if (!\class_exists(StructForm::class)) { + $this->markTestSkipped('Struct not supported'); + } + + $kernel = new \TestKernel(['conf_php8.yaml']); + $kernel->boot(); + + /** @var FormInterface $form */ + $form = $kernel->getContainer()->get(RegistryInterface::class) + ->elementBuilder(StructForm::class) + ->class(StructDto::class) + ->buildElement(); + + $form->submit([ + 'id' => -2, + 'name' => 'azerty', + ]); + + $this->assertFalse($form->valid()); + $this->assertEquals(['id' => 'This value should be positive.'], $form->error()->toArray()); + + $form->submit([ + 'id' => 1, + 'name' => 'azerty', + ]); + $this->assertTrue($form->valid()); + $this->assertEquals(new StructDto(1, 'azerty'), $form->value()); + } + public function testDisableCompilation() { $kernel = new \TestKernel(['conf_php8.yaml', 'conf_disable_compilation.yaml']); @@ -88,7 +126,7 @@ public function testDisableCompilation() $form = $kernel->getContainer()->get(WithAttributes::class); $prop = new \ReflectionProperty(AttributeForm::class, 'processor'); - $prop->setAccessible(true); + PHP_VERSION_ID >= 80500 || $prop->setAccessible(true); $processor = $prop->getValue($form); $this->assertInstanceOf(ReflectionProcessor::class, $processor); @@ -103,7 +141,7 @@ public function testWithCustomResolverConfig() $form = $kernel->getContainer()->get(WithAttributes::class); $prop = new \ReflectionProperty(AttributeForm::class, 'processor'); - $prop->setAccessible(true); + PHP_VERSION_ID >= 80500 || $prop->setAccessible(true); $processor = $prop->getValue($form); $this->assertInstanceOf(CompileAttributesProcessor::class, $processor); @@ -136,7 +174,7 @@ public function testWithAnonymousFormClass() $this->assertSame(['foo' => 'BAR'], $o->process(['foo' => 'bar'])); $prop = new \ReflectionProperty(AttributeForm::class, 'processor'); - $prop->setAccessible(true); + PHP_VERSION_ID >= 80500 || $prop->setAccessible(true); $processor = $prop->getValue($o->form); $this->assertInstanceOf(CompileAttributesProcessor::class, $processor); diff --git a/Tests/FormsAttributes/StructDto.php b/Tests/FormsAttributes/StructDto.php new file mode 100644 index 0000000..6045946 --- /dev/null +++ b/Tests/FormsAttributes/StructDto.php @@ -0,0 +1,18 @@ + 'Doe', ], $content); } + + public function testWithStruct() + { + if (!\class_exists(StructForm::class)) { + $this->markTestSkipped(); + } + + $this->client->request('POST', '/struct', [ + 'id' => 42, + 'firstName' => 'John', + 'lastName' => 'Doe', + ]); + + $content = \json_decode($this->client->getResponse()->getContent(), true); + $this->assertSame([ + 'id' => 42, + 'firstName' => 'John', + 'lastName' => 'Doe', + ], $content); + + $this->client->request('POST', '/struct', [ + 'id' => -5, + 'firstName' => '@@@@', + 'lastName' => 'Doe', + ]); + + $content = \json_decode($this->client->getResponse()->getContent(), true); + $this->assertSame([ + 'message' => 'The JSON contains invalid data.', + 'fields' => [ + 'firstName' => 'This value is not valid.', + ], + ], $content); + } + + public function testWithStructAndForm() + { + if (!\class_exists(StructForm::class)) { + $this->markTestSkipped(); + } + + $this->client->request('POST', '/struct2', [ + 'id' => 42, + 'firstName' => 'John', + 'lastName' => 'Doe', + ]); + + $content = \json_decode($this->client->getResponse()->getContent(), true); + $this->assertSame([ + 'value' => [ + 'id' => 42, + 'firstName' => 'John', + 'lastName' => 'Doe', + ], + 'errors' => [], + ], $content); + + $this->client->request('POST', '/struct2', [ + 'id' => -5, + 'firstName' => '@@@@', + 'lastName' => 'Doe', + ]); + + $content = \json_decode($this->client->getResponse()->getContent(), true); + $this->assertSame([ + 'value' => [ + 'id' => '-5', + 'firstName' => '@@@@', + 'lastName' => 'Doe', + ], + 'errors' => [ + 'firstName' => 'This value is not valid.', + ], + ], $content); + } } diff --git a/Tests/Http/Submit/SubmitFormValueResolverTest.php b/Tests/Http/Submit/SubmitFormValueResolverTest.php index 71f1fc6..155d0a3 100644 --- a/Tests/Http/Submit/SubmitFormValueResolverTest.php +++ b/Tests/Http/Submit/SubmitFormValueResolverTest.php @@ -9,6 +9,7 @@ use Bdf\Form\Bundle\Tests\Http\Form\PersonDto; use Bdf\Form\Bundle\Tests\Http\Form\PersonForm; use Bdf\Form\Registry\Registry; +use Bdf\Form\Struct\StructForm; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; @@ -56,6 +57,10 @@ public function testResolveWithValueArgument() public function testResolveWithValueArgumentMissingForm() { + if (\class_exists(StructForm::class)) { + $this->markTestSkipped('This error is not more triggered with struct form'); + } + $this->expectException(\LogicException::class); $this->expectExceptionMessage('The form class must be defined when the value is requested'); diff --git a/Tests/Http/TestKernel.php b/Tests/Http/TestKernel.php index fd3914a..45e20ad 100644 --- a/Tests/Http/TestKernel.php +++ b/Tests/Http/TestKernel.php @@ -3,11 +3,13 @@ namespace Bdf\Form\Bundle\Tests\Http; use Bdf; +use Bdf\Form\Aggregate\FormInterface; use Bdf\Form\Bundle\Http\InvalidFormException; use Bdf\Form\Bundle\Http\PayloadSource; use Bdf\Form\Bundle\Http\Submit\SubmitForm; use Bdf\Form\Bundle\Tests\Http\Form\PersonDto; use Bdf\Form\Bundle\Tests\Http\Form\PersonForm; +use Bdf\Form\Bundle\Tests\Http\Form\PersonStruct; use Symfony; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -75,6 +77,21 @@ public function value(#[SubmitForm(form: PersonForm::class)] PersonDto $dto): Js return new JsonResponse($dto); } + #[Route('/struct', methods: ['POST'])] + public function struct(#[SubmitForm] PersonStruct $struct): JsonResponse + { + return new JsonResponse($struct); + } + + #[Route('/struct2', methods: ['POST'])] + public function struct2(#[SubmitForm(validate: false)] PersonStruct $struct, FormInterface $form): JsonResponse + { + return new JsonResponse([ + 'value' => $form->valid() ? $struct : $form->httpValue(), + 'errors' => $form->error()->toArray(), + ]); + } + #[Route('/form', methods: ['POST'])] public function form(#[SubmitForm(validate: false)] PersonForm $form): JsonResponse { diff --git a/composer.json b/composer.json index 575ac59..ed1dbb3 100755 --- a/composer.json +++ b/composer.json @@ -18,16 +18,16 @@ "require": { "php": "~7.2 | ~8.0", "b2pweb/bdf-form": "~1.0|~2.0", - "symfony/config": "~5.0|~6.0|~7.0", - "symfony/dependency-injection": "~5.0|~6.0|~7.0", - "symfony/framework-bundle": "~5.0|~6.0|~7.0" + "symfony/config": "~5.0|~6.0|~7.0|~8.0", + "symfony/dependency-injection": "~5.0|~6.0|~7.0|~8.0", + "symfony/framework-bundle": "~5.0|~6.0|~7.0|~8.0" }, "require-dev": { "phpunit/phpunit": "~7.0|~8.0|~9.0", - "symfony/phpunit-bridge": "~5.0|~6.0|~7.0", - "symfony/yaml": "~5.0|~6.0|~7.0", - "symfony/security-csrf": "~5.0|~6.0|~7.0", - "symfony/browser-kit": "~5.0|~6.0|~7.0", + "symfony/phpunit-bridge": "~5.0|~6.0|~7.0|~8.0", + "symfony/yaml": "~5.0|~6.0|~7.0|~8.0", + "symfony/security-csrf": "~5.0|~6.0|~7.0|~8.0", + "symfony/browser-kit": "~5.0|~6.0|~7.0|~8.0", "friendsofphp/php-cs-fixer": "~3.0" }, "suggest": { From 32b83218a98bf2e635027e46e08e01ce65ecd7db Mon Sep 17 00:00:00 2001 From: Vincent QUATREVIEUX Date: Mon, 20 Jul 2026 13:59:22 +0200 Subject: [PATCH 09/14] fix: syntax error on PHP 7.2 --- Registry/SymfonyRegistry.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Registry/SymfonyRegistry.php b/Registry/SymfonyRegistry.php index f3254a7..258b8a4 100644 --- a/Registry/SymfonyRegistry.php +++ b/Registry/SymfonyRegistry.php @@ -120,7 +120,7 @@ public function structFormBuilder(RegistryInterface $registry, string $formClass { return new StructFormBuilder( $this->container->get(self::STRUCT_FORM_PROCESSOR_SERVICE_ID), - $registry->elementBuilder(Form::class), + $registry->elementBuilder(Form::class) ); } From c90277646b74d369044b78e30f541a6db8da52d6 Mon Sep 17 00:00:00 2001 From: Vincent QUATREVIEUX Date: Wed, 22 Jul 2026 11:09:16 +0200 Subject: [PATCH 10/14] fix(struct): Various fixes when use DTO forms - Do not autowire/inject from container controller arguments marked with SubmitForm - Set null to DTO argument when the form is invalid - Allow to inject form instance when subtype of FormInterface is used - Always use validator from container on FormBuilder --- .../RemoveSubmitFormArgumentLocators.php | 115 ++++++++++++++++++ FormBundle.php | 12 ++ Http/Submit/SubmitFormValueResolver.php | 17 ++- Registry/SymfonyRegistry.php | 2 + Resources/config/form.yaml | 8 ++ Tests/BdfFormBundleTest.php | 25 ++++ Tests/FormBundleWithAttributeTest.php | 37 ++++++ .../CustomFormWithDependentConstraint.php | 17 +++ Tests/Forms/NoForbiddenValue.php | 16 +++ Tests/Forms/NoForbiddenValueValidator.php | 45 +++++++ .../StructWithDependentConstraint.php | 18 +++ Tests/Http/TestKernel.php | 2 +- 12 files changed, 310 insertions(+), 4 deletions(-) create mode 100644 DependencyInjection/Compiler/RemoveSubmitFormArgumentLocators.php create mode 100644 Tests/Forms/CustomFormWithDependentConstraint.php create mode 100644 Tests/Forms/NoForbiddenValue.php create mode 100644 Tests/Forms/NoForbiddenValueValidator.php create mode 100644 Tests/FormsAttributes/StructWithDependentConstraint.php diff --git a/DependencyInjection/Compiler/RemoveSubmitFormArgumentLocators.php b/DependencyInjection/Compiler/RemoveSubmitFormArgumentLocators.php new file mode 100644 index 0000000..f58b2a9 --- /dev/null +++ b/DependencyInjection/Compiler/RemoveSubmitFormArgumentLocators.php @@ -0,0 +1,115 @@ + the container compiles; + * - nullable: the reference uses IGNORE_ON_INVALID_REFERENCE, which DefinitionErrorExceptionPass treats as a + * compile-time error -> the container fails to build with "Cannot autowire service ...". + * + * This pass drops those references so the behaviour is consistent (and correct) regardless of nullability: the value + * is always provided by SubmitFormValueResolver, never by the container. + */ +class RemoveSubmitFormArgumentLocators implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + if (!$container->hasDefinition('argument_resolver.controller_locator') && !$container->hasAlias('argument_resolver.controller_locator')) { + return; + } + + $controllerLocator = $container->findDefinition('argument_resolver.controller_locator'); + $controllers = $controllerLocator->getArgument(0); + + foreach ($controllers as $controller => $argument) { + $names = $this->submitFormArgumentNames($container, (string) $controller); + + if (!$names) { + continue; + } + + // $argument is a ServiceClosureArgument wrapping a Reference to the per-method locator + $reference = $argument instanceof ServiceClosureArgument ? $argument->getValues()[0] : $argument; + $argumentLocator = $container->getDefinition((string) $reference); + + // per-consumer locators are derived from a shared prototype through a "withContext" cloning factory + if ($argumentLocator->getFactory()) { + $argumentLocator = $container->getDefinition((string) $argumentLocator->getFactory()[0]); + } + + $services = $argumentLocator->getArgument(0); + $changed = false; + + foreach ($names as $name) { + if (isset($services[$name])) { + unset($services[$name]); + $changed = true; + } + } + + if ($changed) { + $argumentLocator->replaceArgument(0, $services); + } + } + } + + /** + * Get the parameter names of the given controller that are marked with a SubmitForm attribute. + * + * @param string $controller The controller identifier, formatted as "serviceId::method" + * + * @return list + */ + private function submitFormArgumentNames(ContainerBuilder $container, string $controller): array + { + if (!str_contains($controller, '::')) { + return []; + } + + [$serviceId, $method] = explode('::', $controller, 2); + + if (!$container->hasDefinition($serviceId) && !$container->hasAlias($serviceId)) { + return []; + } + + $class = $container->findDefinition($serviceId)->getClass(); + + if (!$class || !method_exists($class, $method)) { + return []; + } + + try { + $parameters = (new ReflectionMethod($class, $method))->getParameters(); + } catch (ReflectionException) { + return []; + } + + $names = []; + + foreach ($parameters as $parameter) { + if ($parameter->getAttributes(SubmitForm::class, ReflectionAttribute::IS_INSTANCEOF)) { + $names[] = $parameter->name; + } + } + + return $names; + } +} diff --git a/FormBundle.php b/FormBundle.php index 39c9db1..4ec3153 100644 --- a/FormBundle.php +++ b/FormBundle.php @@ -6,10 +6,15 @@ use Bdf\Form\Bundle\DependencyInjection\Compiler\CompileAttributeForms; use Bdf\Form\Bundle\DependencyInjection\Compiler\RegisterCustomBuilders; use Bdf\Form\Bundle\DependencyInjection\Compiler\RegisterCustomForms; +use Bdf\Form\Bundle\DependencyInjection\Compiler\RemoveSubmitFormArgumentLocators; use Bdf\Form\Bundle\DependencyInjection\Compiler\UseCsrfTokenManager; +use Bdf\Form\Struct\StructForm; +use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; +use function class_exists; + /** * Bundle for register the BDF Form library into the Symfony container. */ @@ -21,6 +26,13 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass(new RegisterCustomForms()); $container->addCompilerPass(new UseCsrfTokenManager()); + // Fix "Cannot autowire service" when a DTO argument (using StructForm) is present on a controller. + // Must run after Symfony's RegisterControllerArgumentLocatorsPass (beforeOptimization, priority 0), + // hence the negative priority, so the controller argument locators are already built. + if (class_exists(StructForm::class)) { + $container->addCompilerPass(new RemoveSubmitFormArgumentLocators(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -100); + } + if (class_exists(AttributeForm::class)) { $container->addCompilerPass(new CompileAttributeForms()); } diff --git a/Http/Submit/SubmitFormValueResolver.php b/Http/Submit/SubmitFormValueResolver.php index 4894e98..d16f712 100644 --- a/Http/Submit/SubmitFormValueResolver.php +++ b/Http/Submit/SubmitFormValueResolver.php @@ -15,6 +15,7 @@ use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Contracts\Translation\TranslatorInterface; +use Throwable; final class SubmitFormValueResolver implements ValueResolverInterface, EventSubscriberInterface { @@ -39,7 +40,7 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable $attribute = $argument->getAttributesOfType(SubmitForm::class)[0] ?? null; if (null === $attribute) { - if (FormInterface::class === $argument->getType()) { + if (\is_a($argument->getType(), FormInterface::class, true)) { return [self::$formArgumentPlaceholder]; } @@ -84,12 +85,22 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo : $this->registry->elementBuilder(StructForm::class)->class($argument->form)->buildElement() ; $form->submit($payload); + $valid = $form->valid(); - if ($argument->validate && !$form->valid()) { + if ($argument->validate && !$valid) { throw new InvalidFormException($form->error(), $this->translator ? $this->translator->trans($argument->validateMessage) : $argument->validateMessage); } - $arguments[$i] = $argument->value ? $form->value() : $form; + if ($argument->value) { + try { + $arguments[$i] = $valid ? $form->value() : null; + } catch (Throwable) { + $arguments[$i] = null; + } + } else { + $arguments[$i] = $form; + } + $hasChanged = true; } diff --git a/Registry/SymfonyRegistry.php b/Registry/SymfonyRegistry.php index 258b8a4..c1007d8 100644 --- a/Registry/SymfonyRegistry.php +++ b/Registry/SymfonyRegistry.php @@ -3,6 +3,7 @@ namespace Bdf\Form\Bundle\Registry; use Bdf\Form\Aggregate\Form; +use Bdf\Form\Aggregate\FormBuilder; use Bdf\Form\Button\ButtonBuilderInterface; use Bdf\Form\Child\ChildBuilderInterface; use Bdf\Form\Custom\CustomForm; @@ -48,6 +49,7 @@ public function __construct(Registry $registry, ContainerInterface $container) $this->registry = $registry; $this->container = $container; + $registry->register(Form::class, function () { return $this->container->get('bdf_form.inner_form_builder'); }); $registry->register(CustomForm::class, [$this, 'customFormBuilder']); if (\class_exists(StructForm::class)) { diff --git a/Resources/config/form.yaml b/Resources/config/form.yaml index a1a8ab9..6bb052a 100644 --- a/Resources/config/form.yaml +++ b/Resources/config/form.yaml @@ -21,6 +21,14 @@ services: calls: - validator: ['@validator'] + bdf_form.inner_form_builder: + class: 'Bdf\Form\Aggregate\FormBuilder' + arguments: ['@Bdf\Form\Bundle\Registry\SymfonyRegistry'] + public: true + shared: false + calls: + - validator: ['@validator'] + Bdf\Form\Aggregate\FormBuilderInterface: alias: 'Bdf\Form\Aggregate\FormBuilder' public: true diff --git a/Tests/BdfFormBundleTest.php b/Tests/BdfFormBundleTest.php index 3b916e1..4157afb 100644 --- a/Tests/BdfFormBundleTest.php +++ b/Tests/BdfFormBundleTest.php @@ -9,10 +9,12 @@ use Bdf\Form\Bundle\FormBundle; use Bdf\Form\Bundle\Registry\SymfonyRegistry; use Bdf\Form\Bundle\Tests\Forms\A; +use Bdf\Form\Bundle\Tests\Forms\CustomFormWithDependentConstraint; use Bdf\Form\Bundle\Tests\Forms\FooElement; use Bdf\Form\Bundle\Tests\Forms\FooElementBuilder; use Bdf\Form\Bundle\Tests\Forms\MyConstraintValidator; use Bdf\Form\Bundle\Tests\Forms\MyCustomForm; +use Bdf\Form\Bundle\Tests\Forms\NoForbiddenValueValidator; use Bdf\Form\Csrf\CsrfElement; use Bdf\Form\Csrf\CsrfElementBuilder; use Bdf\Form\Registry\RegistryInterface; @@ -88,6 +90,29 @@ public function testCustomFormShouldInstantiateConstraintValidatorFromContainer( $this->assertEquals(new A('foo'), MyConstraintValidator::$injectedParameter); } + public function testCustomFormWithConstraintUsingValidatorWithDependencies() + { + $kernel = new \TestKernel(); + $kernel->boot(); + + NoForbiddenValueValidator::$dependency = null; + + $form = $kernel->getContainer()->get(RegistryInterface::class)->elementBuilder(CustomFormWithDependentConstraint::class)->buildElement(); + + // 'foo' is the value carried by the injected A service, so it must be rejected by the custom constraint + $form->submit(['value' => 'foo']); + + $this->assertFalse($form->valid()); + $this->assertEquals(['value' => 'This value is forbidden.'], $form->error()->toArray()); + // the validator has been instantiated from the container, with its dependency injected + $this->assertEquals(new A('foo'), NoForbiddenValueValidator::$dependency); + + $form->submit(['value' => 'bar']); + + $this->assertTrue($form->valid()); + $this->assertSame('bar', $form['value']->element()->value()); + } + public function testCustomFormShouldUseCurrentElementBuilderInstance() { $kernel = new \TestKernel(); diff --git a/Tests/FormBundleWithAttributeTest.php b/Tests/FormBundleWithAttributeTest.php index b3e0fb5..2682684 100644 --- a/Tests/FormBundleWithAttributeTest.php +++ b/Tests/FormBundleWithAttributeTest.php @@ -9,7 +9,10 @@ use Bdf\Form\Attribute\AttributeForm; use Bdf\Form\Attribute\Processor\CompileAttributesProcessor; use Bdf\Form\Attribute\Processor\ReflectionProcessor; +use Bdf\Form\Bundle\Tests\Forms\A; +use Bdf\Form\Bundle\Tests\Forms\NoForbiddenValueValidator; use Bdf\Form\Bundle\Tests\FormsAttributes\StructDto; +use Bdf\Form\Bundle\Tests\FormsAttributes\StructWithDependentConstraint; use Bdf\Form\Bundle\Tests\FormsAttributes\WithAnonymousFormClass; use Bdf\Form\Bundle\Tests\FormsAttributes\WithAttributes; use Bdf\Form\Registry\RegistryInterface; @@ -117,6 +120,40 @@ public function testFunctionalStruct() $this->assertEquals(new StructDto(1, 'azerty'), $form->value()); } + /** + * @return void + */ + public function testFunctionalStructWithConstraintUsingValidatorWithDependencies() + { + if (!\class_exists(StructForm::class)) { + $this->markTestSkipped('Struct not supported'); + } + + $kernel = new \TestKernel(['conf_php8.yaml']); + $kernel->boot(); + + NoForbiddenValueValidator::$dependency = null; + + /** @var FormInterface $form */ + $form = $kernel->getContainer()->get(RegistryInterface::class) + ->elementBuilder(StructForm::class) + ->class(StructWithDependentConstraint::class) + ->buildElement(); + + // 'foo' is the value carried by the injected A service, so it must be rejected by the custom constraint + $form->submit(['value' => 'foo']); + + $this->assertFalse($form->valid()); + $this->assertEquals(['value' => 'This value is forbidden.'], $form->error()->toArray()); + // the validator has been instantiated from the container, with its dependency injected + $this->assertEquals(new A('foo'), NoForbiddenValueValidator::$dependency); + + $form->submit(['value' => 'bar']); + + $this->assertTrue($form->valid()); + $this->assertEquals(new StructWithDependentConstraint('bar'), $form->value()); + } + public function testDisableCompilation() { $kernel = new \TestKernel(['conf_php8.yaml', 'conf_disable_compilation.yaml']); diff --git a/Tests/Forms/CustomFormWithDependentConstraint.php b/Tests/Forms/CustomFormWithDependentConstraint.php new file mode 100644 index 0000000..23f4f93 --- /dev/null +++ b/Tests/Forms/CustomFormWithDependentConstraint.php @@ -0,0 +1,17 @@ +string('value')->satisfy(new NoForbiddenValue()); + } +} diff --git a/Tests/Forms/NoForbiddenValue.php b/Tests/Forms/NoForbiddenValue.php new file mode 100644 index 0000000..85c22b2 --- /dev/null +++ b/Tests/Forms/NoForbiddenValue.php @@ -0,0 +1,16 @@ +a = $a; + } + + public function validate($value, Constraint $constraint): void + { + if (!$constraint instanceof NoForbiddenValue) { + return; + } + + self::$dependency = $this->a; + + if ($value === $this->a->foo) { + $this->context->buildViolation($constraint->message)->addViolation(); + } + } +} diff --git a/Tests/FormsAttributes/StructWithDependentConstraint.php b/Tests/FormsAttributes/StructWithDependentConstraint.php new file mode 100644 index 0000000..581530d --- /dev/null +++ b/Tests/FormsAttributes/StructWithDependentConstraint.php @@ -0,0 +1,18 @@ + $form->valid() ? $struct : $form->httpValue(), From f759e682976dca2126e2a0bc46a98d106f6b9f02 Mon Sep 17 00:00:00 2001 From: Vincent QUATREVIEUX Date: Wed, 22 Jul 2026 11:24:33 +0200 Subject: [PATCH 11/14] test: fix tests on PHP < 8.4 --- .github/workflows/php.yml | 1 + FormBundle.php | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index d4b833c..4527b8c 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -11,6 +11,7 @@ jobs: build: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: php-versions: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] name: PHP ${{ matrix.php-versions }} diff --git a/FormBundle.php b/FormBundle.php index 4ec3153..81b0f09 100644 --- a/FormBundle.php +++ b/FormBundle.php @@ -8,6 +8,7 @@ use Bdf\Form\Bundle\DependencyInjection\Compiler\RegisterCustomForms; use Bdf\Form\Bundle\DependencyInjection\Compiler\RemoveSubmitFormArgumentLocators; use Bdf\Form\Bundle\DependencyInjection\Compiler\UseCsrfTokenManager; +use Bdf\Form\Bundle\Http\Submit\SubmitForm; use Bdf\Form\Struct\StructForm; use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -29,7 +30,7 @@ public function build(ContainerBuilder $container): void // Fix "Cannot autowire service" when a DTO argument (using StructForm) is present on a controller. // Must run after Symfony's RegisterControllerArgumentLocatorsPass (beforeOptimization, priority 0), // hence the negative priority, so the controller argument locators are already built. - if (class_exists(StructForm::class)) { + if (class_exists(SubmitForm::class)) { $container->addCompilerPass(new RemoveSubmitFormArgumentLocators(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -100); } From 045b17c0ebc16f9ee00e130b07772f554c59f39d Mon Sep 17 00:00:00 2001 From: Vincent QUATREVIEUX Date: Wed, 22 Jul 2026 11:26:51 +0200 Subject: [PATCH 12/14] test: fix tests on PHP < 8.1 --- FormBundle.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/FormBundle.php b/FormBundle.php index 81b0f09..d041e50 100644 --- a/FormBundle.php +++ b/FormBundle.php @@ -12,6 +12,7 @@ use Bdf\Form\Struct\StructForm; use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpKernel\Attribute\ValueResolver; use Symfony\Component\HttpKernel\Bundle\Bundle; use function class_exists; @@ -30,7 +31,7 @@ public function build(ContainerBuilder $container): void // Fix "Cannot autowire service" when a DTO argument (using StructForm) is present on a controller. // Must run after Symfony's RegisterControllerArgumentLocatorsPass (beforeOptimization, priority 0), // hence the negative priority, so the controller argument locators are already built. - if (class_exists(SubmitForm::class)) { + if (class_exists(ValueResolver::class) && class_exists(SubmitForm::class)) { $container->addCompilerPass(new RemoveSubmitFormArgumentLocators(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -100); } From c592efeee35be856dae9cb13351ac06c7b9f8d1b Mon Sep 17 00:00:00 2001 From: Vincent QUATREVIEUX Date: Wed, 22 Jul 2026 11:28:46 +0200 Subject: [PATCH 13/14] style: run php-cs-fixer --- .../Compiler/RemoveSubmitFormArgumentLocators.php | 9 +++------ FormBundle.php | 6 ++---- Http/Submit/SubmitFormValueResolver.php | 3 +-- Registry/SymfonyRegistry.php | 1 - 4 files changed, 6 insertions(+), 13 deletions(-) diff --git a/DependencyInjection/Compiler/RemoveSubmitFormArgumentLocators.php b/DependencyInjection/Compiler/RemoveSubmitFormArgumentLocators.php index f58b2a9..690df4b 100644 --- a/DependencyInjection/Compiler/RemoveSubmitFormArgumentLocators.php +++ b/DependencyInjection/Compiler/RemoveSubmitFormArgumentLocators.php @@ -3,9 +3,6 @@ namespace Bdf\Form\Bundle\DependencyInjection\Compiler; use Bdf\Form\Bundle\Http\Submit\SubmitForm; -use ReflectionAttribute; -use ReflectionException; -use ReflectionMethod; use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -97,15 +94,15 @@ private function submitFormArgumentNames(ContainerBuilder $container, string $co } try { - $parameters = (new ReflectionMethod($class, $method))->getParameters(); - } catch (ReflectionException) { + $parameters = (new \ReflectionMethod($class, $method))->getParameters(); + } catch (\ReflectionException) { return []; } $names = []; foreach ($parameters as $parameter) { - if ($parameter->getAttributes(SubmitForm::class, ReflectionAttribute::IS_INSTANCEOF)) { + if ($parameter->getAttributes(SubmitForm::class, \ReflectionAttribute::IS_INSTANCEOF)) { $names[] = $parameter->name; } } diff --git a/FormBundle.php b/FormBundle.php index d041e50..35ea714 100644 --- a/FormBundle.php +++ b/FormBundle.php @@ -15,8 +15,6 @@ use Symfony\Component\HttpKernel\Attribute\ValueResolver; use Symfony\Component\HttpKernel\Bundle\Bundle; -use function class_exists; - /** * Bundle for register the BDF Form library into the Symfony container. */ @@ -31,11 +29,11 @@ public function build(ContainerBuilder $container): void // Fix "Cannot autowire service" when a DTO argument (using StructForm) is present on a controller. // Must run after Symfony's RegisterControllerArgumentLocatorsPass (beforeOptimization, priority 0), // hence the negative priority, so the controller argument locators are already built. - if (class_exists(ValueResolver::class) && class_exists(SubmitForm::class)) { + if (\class_exists(ValueResolver::class) && \class_exists(SubmitForm::class)) { $container->addCompilerPass(new RemoveSubmitFormArgumentLocators(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -100); } - if (class_exists(AttributeForm::class)) { + if (\class_exists(AttributeForm::class)) { $container->addCompilerPass(new CompileAttributeForms()); } } diff --git a/Http/Submit/SubmitFormValueResolver.php b/Http/Submit/SubmitFormValueResolver.php index d16f712..ffa1221 100644 --- a/Http/Submit/SubmitFormValueResolver.php +++ b/Http/Submit/SubmitFormValueResolver.php @@ -15,7 +15,6 @@ use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Contracts\Translation\TranslatorInterface; -use Throwable; final class SubmitFormValueResolver implements ValueResolverInterface, EventSubscriberInterface { @@ -94,7 +93,7 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo if ($argument->value) { try { $arguments[$i] = $valid ? $form->value() : null; - } catch (Throwable) { + } catch (\Throwable) { $arguments[$i] = null; } } else { diff --git a/Registry/SymfonyRegistry.php b/Registry/SymfonyRegistry.php index c1007d8..f0a2dac 100644 --- a/Registry/SymfonyRegistry.php +++ b/Registry/SymfonyRegistry.php @@ -3,7 +3,6 @@ namespace Bdf\Form\Bundle\Registry; use Bdf\Form\Aggregate\Form; -use Bdf\Form\Aggregate\FormBuilder; use Bdf\Form\Button\ButtonBuilderInterface; use Bdf\Form\Child\ChildBuilderInterface; use Bdf\Form\Custom\CustomForm; From 1762c7b6466a519bfb4d1aeb6056af20f43a5d8f Mon Sep 17 00:00:00 2001 From: Vincent QUATREVIEUX Date: Wed, 22 Jul 2026 15:11:03 +0200 Subject: [PATCH 14/14] fix: form instance injection --- Http/Submit/SubmitFormValueResolver.php | 38 +++++++++++++---------- Tests/BdfFormBundleTest.php | 18 +++++++++++ Tests/Http/FunctionSubmitFormTest.php | 40 +++++++++++++++++++++++++ Tests/Http/TestKernel.php | 9 ++++++ Tests/TestKernel.php | 21 +++++++++++-- 5 files changed, 108 insertions(+), 18 deletions(-) diff --git a/Http/Submit/SubmitFormValueResolver.php b/Http/Submit/SubmitFormValueResolver.php index ffa1221..442037b 100644 --- a/Http/Submit/SubmitFormValueResolver.php +++ b/Http/Submit/SubmitFormValueResolver.php @@ -18,19 +18,10 @@ final class SubmitFormValueResolver implements ValueResolverInterface, EventSubscriberInterface { - /** - * Placeholder value for FormInterface parameter without associated SubmitForm attribute. - * The submitted form will be passed to arguments which is marked with this placeholder. - * - * @var object|null - */ - private static $formArgumentPlaceholder; - public function __construct( private readonly RegistryInterface $registry, private readonly ?TranslatorInterface $translator = null, ) { - self::$formArgumentPlaceholder ??= new \stdClass(); } #[\Override] @@ -39,8 +30,10 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable $attribute = $argument->getAttributesOfType(SubmitForm::class)[0] ?? null; if (null === $attribute) { - if (\is_a($argument->getType(), FormInterface::class, true)) { - return [self::$formArgumentPlaceholder]; + $type = $argument->getType(); + + if (null !== $type && \is_a($type, FormInterface::class, true)) { + return [new FormParameter($type)]; } return []; @@ -103,11 +96,10 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo $hasChanged = true; } - if (null !== $form) { - foreach ($arguments as $i => $argument) { - if ($argument === self::$formArgumentPlaceholder) { - $arguments[$i] = $form; - } + foreach ($arguments as $i => $argument) { + if ($argument instanceof FormParameter) { + $arguments[$i] = $form ?? $this->registry->elementBuilder($argument->type)->buildElement(); + $hasChanged = true; } } @@ -139,3 +131,17 @@ public static function getSubscribedEvents(): array ]; } } + +/** + * @internal + */ +final class FormParameter +{ + public function __construct( + /** + * @var class-string + */ + public readonly string $type, + ) { + } +} diff --git a/Tests/BdfFormBundleTest.php b/Tests/BdfFormBundleTest.php index 4157afb..9e2738f 100644 --- a/Tests/BdfFormBundleTest.php +++ b/Tests/BdfFormBundleTest.php @@ -21,6 +21,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\HttpKernel\HttpKernelBrowser; /** * BdfSerializerBundleTest. @@ -144,6 +145,23 @@ public function testCustomElement() $this->assertSame($kernel->getContainer()->get(A::class), $builder->a); } + public function testInjectFormAsControllerArgumentWithoutSubmitForm() + { + $kernel = new \TestKernel(); + $client = new HttpKernelBrowser($kernel); + + $client->request('POST', '/form-only', [ + 'foo' => 'bar', + 'other' => 'baz', + ]); + + // The MyCustomForm service is autowired and injected directly by the container, + // then submitted by the controller: the request succeeds. + $response = $client->getResponse(); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['value' => [], 'errors' => []], \json_decode($response->getContent(), true)); + } + public function testCsrfElement() { $kernel = new \TestKernel(); diff --git a/Tests/Http/FunctionSubmitFormTest.php b/Tests/Http/FunctionSubmitFormTest.php index 7846396..c479615 100644 --- a/Tests/Http/FunctionSubmitFormTest.php +++ b/Tests/Http/FunctionSubmitFormTest.php @@ -98,6 +98,46 @@ public function testInjectFormError() ], $content); } + public function testInjectFormAndValueValid() + { + $this->client->request('POST', '/form2', [ + 'id' => 1, + 'firstName' => 'John', + 'lastName' => 'Doe', + ]); + + $content = \json_decode($this->client->getResponse()->getContent(), true); + $this->assertSame([ + 'value' => [ + 'id' => 1, + 'firstName' => 'John', + 'lastName' => 'Doe', + ], + 'errors' => [], + ], $content); + } + + public function testInjectFormAndValueError() + { + $this->client->request('POST', '/form2', [ + 'firstName' => '#####', + 'lastName' => 'Doe', + ]); + + $content = \json_decode($this->client->getResponse()->getContent(), true); + $this->assertSame([ + 'value' => [ + 'id' => null, + 'firstName' => '#####', + 'lastName' => 'Doe', + ], + 'errors' => [ + 'id' => 'This value should not be blank.', + 'firstName' => 'This value is not valid.', + ], + ], $content); + } + public function testMultiplePayloadSource() { $this->client->request('PUT', '/person/42', [ diff --git a/Tests/Http/TestKernel.php b/Tests/Http/TestKernel.php index ab924b5..6acd08e 100644 --- a/Tests/Http/TestKernel.php +++ b/Tests/Http/TestKernel.php @@ -101,6 +101,15 @@ public function form(#[SubmitForm(validate: false)] PersonForm $form): JsonRespo ]); } + #[Route('/form2', methods: ['POST'])] + public function form2(#[SubmitForm(form: PersonForm::class, validate: false)] ?PersonDto $person, PersonForm $form): JsonResponse + { + return new JsonResponse([ + 'value' => $person ?? $form->httpValue(), + 'errors' => $form->error()->toArray(), + ]); + } + #[Route('/person/{id}', methods: ['PUT'])] public function person(#[SubmitForm(source: [PayloadSource::Attributes, PayloadSource::Body], form: PersonForm::class)] PersonDto $dto): JsonResponse { diff --git a/Tests/TestKernel.php b/Tests/TestKernel.php index 42447f5..4428d33 100644 --- a/Tests/TestKernel.php +++ b/Tests/TestKernel.php @@ -1,8 +1,11 @@ add('form_only', '/form-only') + ->controller('kernel::formOnly') + ->methods(['POST']) + ; } protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader) @@ -37,4 +44,14 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load $loader->load(__DIR__.'/'.$config); } } + + public function formOnly(MyCustomForm $form, Request $request): JsonResponse + { + $form->submit($request->request->all()); + + return new JsonResponse([ + 'value' => $form->value(), + 'errors' => $form->error()->toArray(), + ]); + } }