diff --git a/src/Symfony/Bundle/Resources/config/maker.xml b/src/Symfony/Bundle/Resources/config/maker.xml
index 4b089817f9e..ce87f360c4c 100644
--- a/src/Symfony/Bundle/Resources/config/maker.xml
+++ b/src/Symfony/Bundle/Resources/config/maker.xml
@@ -14,6 +14,11 @@
+
+
+
+
+
diff --git a/src/Symfony/Maker/Enum/SupportedFilterTypes.php b/src/Symfony/Maker/Enum/SupportedFilterTypes.php
new file mode 100644
index 00000000000..94721ddb871
--- /dev/null
+++ b/src/Symfony/Maker/Enum/SupportedFilterTypes.php
@@ -0,0 +1,20 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\Symfony\Maker\Enum;
+
+enum SupportedFilterTypes: string
+{
+ case ORM = 'orm';
+ case ODM = 'odm';
+}
diff --git a/src/Symfony/Maker/MakeFilter.php b/src/Symfony/Maker/MakeFilter.php
new file mode 100644
index 00000000000..e2796c7f5e2
--- /dev/null
+++ b/src/Symfony/Maker/MakeFilter.php
@@ -0,0 +1,102 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\Symfony\Maker;
+
+use ApiPlatform\Metadata\Exception\InvalidArgumentException;
+use ApiPlatform\Symfony\Maker\Enum\SupportedFilterTypes;
+use Symfony\Bundle\MakerBundle\ConsoleStyle;
+use Symfony\Bundle\MakerBundle\DependencyBuilder;
+use Symfony\Bundle\MakerBundle\Generator;
+use Symfony\Bundle\MakerBundle\InputConfiguration;
+use Symfony\Bundle\MakerBundle\Maker\AbstractMaker;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+
+final class MakeFilter extends AbstractMaker
+{
+ /**
+ * {@inheritdoc}
+ */
+ public static function getCommandName(): string
+ {
+ return 'make:filter';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function getCommandDescription(): string
+ {
+ return 'Creates an API Platform filter';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configureCommand(Command $command, InputConfiguration $inputConfig): void
+ {
+ $command
+ ->addArgument('type', InputArgument::REQUIRED, \sprintf('Choose a type for your filter (%s>)', self::getFilterTypesAsString()))
+ ->addArgument('name', InputArgument::REQUIRED, 'Choose a class name for your filter (e.g. AwesomeFilter>)')
+ ->setHelp(file_get_contents(__DIR__.'/Resources/help/MakeFilter.txt'));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configureDependencies(DependencyBuilder $dependencies): void
+ {
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @throws \Exception
+ */
+ public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
+ {
+ $typeArgument = strtolower((string) $input->getArgument('type'));
+ $type = SupportedFilterTypes::tryFrom($typeArgument);
+ if (null === $type) {
+ throw new InvalidArgumentException(\sprintf('The type "%s" is not a valid filter type, valid options are: %s.', $typeArgument, self::getFilterTypesAsString()));
+ }
+
+ $filterNameDetails = $generator->createClassNameDetails(
+ name: $input->getArgument('name'),
+ namespacePrefix: 'Filter\\'
+ );
+ $filterName = \sprintf('%sFilter', ucfirst($type->value));
+
+ $generator->generateClass(className: $filterNameDetails->getFullName(), templateName: \sprintf(
+ '%s/Resources/skeleton/%s.php.tpl',
+ __DIR__,
+ $filterName
+ ));
+
+ $generator->writeChanges();
+
+ $this->writeSuccessMessage($io);
+ $io->text([
+ 'Next: Open your filter class and start customizing it.',
+ ]);
+ }
+
+ private static function getFilterTypesAsString(): string
+ {
+ $validOptions = array_column(SupportedFilterTypes::cases(), 'value');
+
+ return implode(' or ', array_map('strtoupper', $validOptions));
+ }
+}
diff --git a/src/Symfony/Maker/Resources/help/MakeFilter.txt b/src/Symfony/Maker/Resources/help/MakeFilter.txt
new file mode 100644
index 00000000000..d5b4e504e38
--- /dev/null
+++ b/src/Symfony/Maker/Resources/help/MakeFilter.txt
@@ -0,0 +1,9 @@
+The %command.name% command generates a new API Platform filter class for Doctrine ORM or ODM (MongoDB).
+
+php %command.full_name% type name
+
+Important:
+- If you omit the argument, the command will prompt you to choose the filter type interactively.
+- If you omit the argument, the command will ask you to enter the class name interactively.
+
+Elasticsearch isn't supported yet.
diff --git a/src/Symfony/Maker/Resources/skeleton/OdmFilter.php.tpl b/src/Symfony/Maker/Resources/skeleton/OdmFilter.php.tpl
new file mode 100644
index 00000000000..5cc29f54ef1
--- /dev/null
+++ b/src/Symfony/Maker/Resources/skeleton/OdmFilter.php.tpl
@@ -0,0 +1,27 @@
+
+
+namespace ;
+
+use ApiPlatform\Doctrine\Odm\Filter\FilterInterface;
+use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait;
+use ApiPlatform\Metadata\Operation;
+use Doctrine\ODM\MongoDB\Aggregation\Builder;
+
+class implements FilterInterface
+{
+ use BackwardCompatibleFilterDescriptionTrait; // Here for backward compatibility, keep it until 5.0.
+
+ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
+ {
+ // Retrieve the parameter and it's value
+ // $parameter = $context['parameter'];
+ // $value = $parameter->getValue();
+
+ // Retrieve the property
+ // $property = $parameter->getProperty();
+
+ // TODO: make your awesome query using the $aggregationBuilder
+ // $aggregationBuilder->
+ }
+}
diff --git a/src/Symfony/Maker/Resources/skeleton/OrmFilter.php.tpl b/src/Symfony/Maker/Resources/skeleton/OrmFilter.php.tpl
new file mode 100644
index 00000000000..b4d8a82318a
--- /dev/null
+++ b/src/Symfony/Maker/Resources/skeleton/OrmFilter.php.tpl
@@ -0,0 +1,32 @@
+
+
+namespace ;
+
+use ApiPlatform\Doctrine\Orm\Filter\FilterInterface;
+use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
+use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait;
+use ApiPlatform\Metadata\Operation;
+use Doctrine\ORM\QueryBuilder;
+
+class implements FilterInterface
+{
+ use BackwardCompatibleFilterDescriptionTrait; // Here for backward compatibility, keep it until 5.0.
+
+ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
+ {
+ // Retrieve the parameter and it's value
+ // $parameter = $context['parameter'];
+ // $value = $parameter->getValue();
+
+ // Retrieve the property
+ // $property = $parameter->getProperty();
+
+ // Retrieve alias and parameter name
+ // $alias = $queryBuilder->getRootAliases()[0];
+ // $parameterName = $queryNameGenerator->generateParameterName($property);
+
+ // TODO: make your awesome query using the $queryBuilder
+ // $queryBuilder->
+ }
+}
diff --git a/tests/Fixtures/Symfony/Maker/CustomOdmFilter.fixture b/tests/Fixtures/Symfony/Maker/CustomOdmFilter.fixture
new file mode 100644
index 00000000000..502251bedac
--- /dev/null
+++ b/tests/Fixtures/Symfony/Maker/CustomOdmFilter.fixture
@@ -0,0 +1,24 @@
+namespace App\Filter;
+
+use ApiPlatform\Doctrine\Odm\Filter\FilterInterface;
+use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait;
+use ApiPlatform\Metadata\Operation;
+use Doctrine\ODM\MongoDB\Aggregation\Builder;
+
+class CustomOdmFilter implements FilterInterface
+{
+ use BackwardCompatibleFilterDescriptionTrait; // Here for backward compatibility, keep it until 5.0.
+
+ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
+ {
+ // Retrieve the parameter and it's value
+ // $parameter = $context['parameter'];
+ // $value = $parameter->getValue();
+
+ // Retrieve the property
+ // $property = $parameter->getProperty();
+
+ // TODO: make your awesome query using the $aggregationBuilder
+ // $aggregationBuilder->
+ }
+}
diff --git a/tests/Fixtures/Symfony/Maker/CustomOrmFilter.fixture b/tests/Fixtures/Symfony/Maker/CustomOrmFilter.fixture
new file mode 100644
index 00000000000..3750e1830b0
--- /dev/null
+++ b/tests/Fixtures/Symfony/Maker/CustomOrmFilter.fixture
@@ -0,0 +1,29 @@
+namespace App\Filter;
+
+use ApiPlatform\Doctrine\Orm\Filter\FilterInterface;
+use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
+use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait;
+use ApiPlatform\Metadata\Operation;
+use Doctrine\ORM\QueryBuilder;
+
+class CustomOrmFilter implements FilterInterface
+{
+ use BackwardCompatibleFilterDescriptionTrait; // Here for backward compatibility, keep it until 5.0.
+
+ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
+ {
+ // Retrieve the parameter and it's value
+ // $parameter = $context['parameter'];
+ // $value = $parameter->getValue();
+
+ // Retrieve the property
+ // $property = $parameter->getProperty();
+
+ // Retrieve alias and parameter name
+ // $alias = $queryBuilder->getRootAliases()[0];
+ // $parameterName = $queryNameGenerator->generateParameterName($property);
+
+ // TODO: make your awesome query using the $queryBuilder
+ // $queryBuilder->
+ }
+}
diff --git a/tests/Symfony/Maker/MakeFilterTest.php b/tests/Symfony/Maker/MakeFilterTest.php
new file mode 100644
index 00000000000..bf6bd01ea5b
--- /dev/null
+++ b/tests/Symfony/Maker/MakeFilterTest.php
@@ -0,0 +1,110 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\Tests\Symfony\Maker;
+
+use ApiPlatform\Metadata\Exception\InvalidArgumentException;
+use PHPUnit\Framework\Attributes\DataProvider;
+use Symfony\Bundle\FrameworkBundle\Console\Application;
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Symfony\Component\Console\Exception\MissingInputException;
+use Symfony\Component\Console\Tester\CommandTester;
+use Symfony\Component\Filesystem\Filesystem;
+
+class MakeFilterTest extends KernelTestCase
+{
+ protected function setup(): void
+ {
+ (new Filesystem())->remove(self::tempDir());
+ }
+
+ #[DataProvider('filterProvider')]
+ public function testMakeFilter(string $type, string $name, bool $isInteractive): void
+ {
+ $inputs = ['type' => $type, 'name' => $name];
+ $newFilterFile = self::tempFile("src/Filter/{$name}.php");
+
+ $command = (new Application(self::bootKernel()))->find('make:filter');
+ $commandTester = new CommandTester($command);
+ $commandTester->setInputs($isInteractive ? $inputs : []);
+ $commandTester->execute($isInteractive ? [] : $inputs);
+
+ $this->assertFileExists($newFilterFile);
+
+ $expected = preg_replace('~\R~u', "\r\n", file_get_contents(__DIR__."/../../Fixtures/Symfony/Maker/{$name}.fixture"));
+ $result = preg_replace('~\R~u', "\r\n", file_get_contents($newFilterFile));
+ $this->assertStringContainsString($expected, $result);
+
+ $display = $commandTester->getDisplay();
+ $commandTester->assertCommandIsSuccessful();
+ $interactiveOutputType = 'Choose a type for your filter';
+ $interactiveOutputName = 'Choose a class name for your filter';
+
+ if ($isInteractive) {
+ $this->assertStringContainsString($interactiveOutputType, $display);
+ $this->assertStringContainsString($interactiveOutputName, $display);
+ } else {
+ $this->assertStringNotContainsString($interactiveOutputType, $display);
+ $this->assertStringNotContainsString($interactiveOutputName, $display);
+ }
+
+ $this->assertStringContainsString(' Next: Open your filter class and start customizing it.', $display);
+ }
+
+ public static function filterProvider(): \Generator
+ {
+ yield 'Generate ORM filter' => ['orm', 'CustomOrmFilter', true];
+ yield 'Generate ORM filter not interactively' => ['orm', 'CustomOrmFilter', false];
+ yield 'Generate ODM filter' => ['odm', 'CustomOdmFilter', true];
+ yield 'Generate ODM filter not interactively' => ['odm', 'CustomOdmFilter', false];
+ }
+
+ #[DataProvider('filterErrorProvider')]
+ public function testCommandFailsWithInvalidInput(array $inputs, string $exceptionClass = InvalidArgumentException::class): void
+ {
+ $this->expectException($exceptionClass);
+ $command = (new Application(self::bootKernel()))->find('make:filter');
+ (new CommandTester($command))->execute($inputs);
+ }
+
+ public static function filterErrorProvider(): \Generator
+ {
+ yield 'Missing type and name arguments' => [
+ [],
+ MissingInputException::class,
+ ];
+
+ yield 'Invalid type argument' => [
+ ['type' => 'john', 'name' => 'MyCustomFilter'],
+ ];
+
+ yield 'No valid type argument given' => [
+ ['type' => 'John', 'name' => 'MyCustomFilter'],
+ ];
+
+ yield 'Missing name argument' => [
+ ['type' => 'orm'],
+ MissingInputException::class,
+ ];
+ }
+
+ private static function tempDir(): string
+ {
+ return __DIR__.'/../../Fixtures/app/var/tmp';
+ }
+
+ private static function tempFile(string $path): string
+ {
+ return \sprintf('%s/%s', self::tempDir(), $path);
+ }
+}