diff --git a/filters.md b/filters.md
deleted file mode 100644
index a41a7c5bbfe..00000000000
--- a/filters.md
+++ /dev/null
@@ -1,869 +0,0 @@
-# Parameters and Filters
-
-For documentation on the specific filter implementations available for your persistence layer,
-please refer to the following pages:
-
-- **[Doctrine Filters](../core/doctrine-filters.md)**
-- **[Elasticsearch Filters](../core/elasticsearch-filters.md)**
-
-API Platform provides a generic and powerful system to apply filters, sort criteria, and handle
-other request parameters. This system is primarily managed through **Parameter attributes**
-(`#[QueryParameter]` and `#[HeaderParameter]`), which allow for detailed and explicit configuration
-of how an API consumer can interact with a resource.
-
-These parameters can be linked to **Filters**, which are classes that contain the logic for applying
-criteria to your persistence backend (like Doctrine ORM or MongoDB ODM).
-
-You can declare parameters on a resource class to apply them to all operations, or on a specific
-operation for more granular control. When parameters are enabled, they automatically appear in the
-Hydra, [OpenAPI](openapi.md) and [GraphQL](graphql.md) documentations.
-
-

Watch the Filtering & Searching screencast
-
-> [!WARNING] For maximum flexibility and to ensure future compatibility, it is strongly recommended
-> to configure your filters via the parameters attribute using `QueryParameter`. The legacy method
-> using the `ApiFilter` attribute is not recommended.
-
-## Declaring Parameters
-
-The recommended way to define parameters is by using Parameter attributes directly on a resource
-class or on an operation. API Platform provides two main types of Parameter attributes based on
-their location (matching the OpenAPI `in` configuration):
-
-- `ApiPlatform\Metadata\QueryParameter`: For URL query parameters (e.g., `?name=value`).
-- `ApiPlatform\Metadata\HeaderParameter`: For HTTP headers (e.g., `Custom-Header: value`).
-
-### List of Available Filters
-
-When defining a `QueryParameter`, you must specify the filtering logic using the `filter` option.
-
-Here is a list of available filters you can use. You can pass the filter class name (recommended) or
-a new instance:
-
-- **`DateFilter`**: For filtering by date intervals (e.g., `?createdAt[after]=...`).
- - Usage: `new QueryParameter(filter: DateFilter::class)`
-- **`ExactFilter`**: For exact value matching.
- - Usage: `new QueryParameter(filter: ExactFilter::class)`
-- **`PartialSearchFilter`**: For partial string matching (SQL `LIKE %...%`).
- - Usage: `new QueryParameter(filter: PartialSearchFilter::class)`
-- **`IriFilter`**: For filtering by IRIs (e.g., relations).
- - Usage: `new QueryParameter(filter: IriFilter::class)`
-- **`BooleanFilter`**: For boolean field filtering.
- - Usage: `new QueryParameter(filter: BooleanFilter::class)`
-- **`NumericFilter`**: For numeric field filtering.
- - Usage: `new QueryParameter(filter: NumericFilter::class)`
-- **`RangeFilter`**: For range-based filtering (e.g., prices between X and Y).
- - Usage: `new QueryParameter(filter: RangeFilter::class)`
-- **`ExistsFilter`**: For checking existence of nullable values.
- - Usage: `new QueryParameter(filter: ExistsFilter::class)`
-- **`OrderFilter`**: For sorting results.
- - Usage: `new QueryParameter(filter: OrderFilter::class)`
-
-> [!TIP] Always check the specific documentation for your persistence layer (Doctrine ORM, MongoDB
-> ODM, Laravel Eloquent) to see the exact namespace and available options for these filters.
-
-You can declare a parameter on the resource class to make it available for all its operations:
-
-```php
- new QueryParameter(description: 'Filter our friends by name'),
- 'Request-ID' => new HeaderParameter(description: 'A unique request identifier') // keys are case insensitive
- ]
- )
- ]
-)]
-class Friend
-{
- // ...
-}
-```
-
-### Using Filters with DateTime Properties
-
-When working with `DateTime` or `DateTimeImmutable` properties, the system might default to exact
-matching. To enable date ranges (e.g., `after`, `before`), you must explicitly use the `DateFilter`:
-
-```php
- new QueryParameter(
- // Use the class string to leverage the service container (recommended)
- filter: DateFilter::class,
- properties: ['startDate', 'endDate']
- )
- ]
- )
-])]
-class Event
-{
- // ...
-}
-```
-
-This configuration allows clients to filter events by date ranges using queries like:
-
-- `/events?date[startDate][after]=2023-01-01`
-- `/events?date[endDate][before]=2023-12-31`
-
-### Filtering a Single Property
-
-Most of the time, a parameter maps directly to a property on your resource. For example, a
-`?name=Frodo` query parameter would filter for resources where the `name` property is "Frodo". This
-behavior is often handled by built-in or custom filters that you link to the parameter.
-
-For Hydra, you can map a query parameter to `hydra:freetextQuery` to indicate a general-purpose
-search query.
-
-```php
- new QueryParameter(property: 'hydra:freetextQuery', required: true)
- ]
- )
-])]
-class Issue {}
-```
-
-This will generate the following Hydra `IriTemplateMapping`:
-
-```json
-{
- "@context": "http://www.w3.org/ns/hydra/context.jsonld",
- "@type": "IriTemplate",
- "template": "http://api.example.com/issues{?q}",
- "variableRepresentation": "BasicRepresentation",
- "mapping": [
- {
- "@type": "IriTemplateMapping",
- "variable": "q",
- "property": "hydra:freetextQuery",
- "required": true
- }
- ]
-}
-```
-
-### Filtering Multiple Properties with `:property`
-
-Sometimes you need a generic filter that can operate on multiple properties. You can achieve this by
-using the `:property` placeholder in the parameter's `key`.
-
-```php
- new QueryParameter(
- filter: 'api_platform.doctrine.orm.search_filter.instance'
- )
- ]
- )
-])]
-class Book
-{
- // ...
-}
-```
-
-This configuration creates a dynamic parameter. API clients can now filter on any of the properties
-configured in the `SearchFilter` (in this case, `title` and `description`) by using a URL like
-`/books?search[title]=Ring` or `/books?search[description]=journey`.
-
-When using the `:property` placeholder, API Platform automatically creates as many parameters as
-there are properties. Each filter will be called by each detected parameter:
-
-```php
-public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
-{
- $parameter = $context['parameter'] ?? null;
- dump(key: $parameter->getKey(), value: $parameter->getValue());
- // shows key: search[title], value: Ring
-}
-```
-
-> [!NOTE] We are using `api_platform.doctrine.orm.search_filter.instance` (exists also for ODM).
-> Indeed this is a special instance of the search filter where `properties` can be changed during
-> runtime. This is considered as "legacy filter" below, in API Platform 4.0 we'll recommend to
-> create a custom filter or to use the `PartialSearchFilter`.
-
-### Restricting Properties with `:property` Placeholders
-
-Filters that work on a per-parameter basis can also use the `:property` placeholde and use the
-parameter's `properties` configuration:
-
-```php
- new QueryParameter(
- properties: ['title', 'author'], // Only these properties get parameters created, defaults to all properties
- filter: new PartialSearchFilter()
- )
- ]
- )
-])]
-class Book {
- // ...
-}
-```
-
-This will create 2 parameters: `search[title]` and `search[author]`, here is an example of the
-associated filter for Doctrine ORM:
-
-```php
-getValue();
-
- // Get the property for this specific parameter
- $property = $parameter->getProperty();
- $alias = $queryBuilder->getRootAliases()[0];
- $field = $alias.'.'.$property;
-
- $parameterName = $queryNameGenerator->generateParameterName($property);
-
- $queryBuilder
- ->andWhere($queryBuilder->expr()->like('LOWER('.$field.')', ':'.$parameterName))
- ->setParameter($parameterName, '%'.strtolower($value).'%');
- }
-}
-```
-
-**How it works:**
-
-1. API Platform creates individual parameters: `search[title]` and `search[author]` only
-2. URLs like `/books?search[description]=foo` are ignored (no parameter exists)
-3. Each parameter calls the filter with its specific property via `$parameter->getProperty()`
-4. The filter processes only that one property
-
-This approach is recommended for new filters as it's more flexible and allows true property
-restriction via the parameter configuration.
-
-> [!NOTE] Invalid values are usually ignored by our filters, use [validation](#parameter-validation)
-> to trigger errors for wrong parameter values.
-
-## OpenAPI and JSON Schema
-
-You have full control over how your parameters are documented in OpenAPI.
-
-### Customizing the OpenAPI Parameter
-
-You can pass a fully configured `ApiPlatform\OpenApi\Model\Parameter` object to the `openApi`
-property of your parameter attribute. This gives you total control over the generated documentation.
-
-```php
- new QueryParameter(
- schema: ['enum' => ['a', 'b'], 'uniqueItems' => true],
- castToArray: true,
- openApi: new OpenApiParameter(name: 'enum', in: 'query', style: 'deepObject')
- )
- ]
- )
-])]
-class User {}
-```
-
-### Using JSON Schema and Type Casting
-
-The `schema` property allows you to define validation rules using JSON Schema keywords. This is
-useful for simple validation like ranges, patterns, or enumerations.
-
-When you define a `schema`, API Platform can often infer the native PHP type of the parameter. For
-instance, `['type' => 'boolean']` implies a boolean. If you want to ensure the incoming string value
-(e.g., "true", "0") is cast to its actual native type before validation and filtering, set
-`castToNativeType` to `true`.
-
-```php
- new QueryParameter(
- schema: ['type' => 'boolean'],
- castToNativeType: true
- )
- ]
- )
-])]
-class Setting {}
-```
-
-If you need a custom validation function use the `castFn` property of the `Parameter` class.
-
-## Parameter Validation
-
-You can enforce validation rules on your parameters using the `required` property or by attaching
-Symfony Validator constraints.
-
-```php
- new QueryParameter(
- description: 'Filter by country code.',
- constraints: [new Assert\Country()]
- ),
- 'X-Request-ID' => new HeaderParameter(
- description: 'A unique request identifier.',
- required: true,
- constraints: [new Assert\Uuid()]
- )
- ]
- )
-])]
-class User {}
-```
-
-> [!NOTE] When `castToNativeType` is enabled, API Platform infers type validation from the JSON
-> Schema.
-
-The `ApiPlatform\Validator\Util\ParameterValidationConstraints` trait can be used to automatically
-infer validation constraints from the JSON Schema and OpenAPI definitions of a parameter.
-
-Here is the list of validation constraints that are automatically inferred from the JSON Schema and
-OpenAPI definitions of a parameter.
-
-### From OpenAPI Definition
-
-- **`allowEmptyValue`**: If set to `false`, a `Symfony\Component\Validator\Constraints\NotBlank`
- constraint is added.
-
-### From JSON Schema (`schema` property)
-
-- **`minimum`** / **`maximum`**:
- - If both are set, a `Symfony\Component\Validator\Constraints\Range` constraint is added.
- - If only `minimum` is set, a `Symfony\Component\Validator\Constraints\GreaterThanOrEqual`
- constraint is added.
- - If only `maximum` is set, a `Symfony\Component\Validator\Constraints\LessThanOrEqual`
- constraint is added.
-- **`exclusiveMinimum`** / **`exclusiveMaximum`**:
- - If `exclusiveMinimum` is used, it becomes a
- `Symfony\Component\Validator\Constraints\GreaterThan` constraint.
- - If `exclusiveMaximum` is used, it becomes a `Symfony\Component\Validator\Constraints\LessThan`
- constraint.
-- **`pattern`**: Becomes a `Symfony\Component\Validator\Constraints\Regex` constraint.
-- **`minLength`** / **`maxLength`**: Becomes a `Symfony\Component\Validator\Constraints\Length`
- constraint.
-- **`multipleOf`**: Becomes a `Symfony\Component\Validator\Constraints\DivisibleBy` constraint.
-- **`enum`**: Becomes a `Symfony\Component\Validator\Constraints\Choice` constraint with the
- specified values.
-- **`minItems`** / **`maxItems`**: Becomes a `Symfony\Component\Validator\Constraints\Count`
- constraint (for arrays).
-- **`uniqueItems`**: If `true`, becomes a `Symfony\Component\Validator\Constraints\Unique`
- constraint (for arrays).
-- **`type`**:
- - If set to `'array'`, a `Symfony\Component\Validator\Constraints\Type('array')` constraint is
- added.
- - If `castToNativeType` is also `true`, the schema `type` will add a
- `Symfony\Component\Validator\Constraints\Type` constraint for `'boolean'`, `'integer'`, and
- `'number'` (as `float`).
-
-### From the Parameter's `required` Property
-
-- **`required`**: If set to `true`, a `Symfony\Component\Validator\Constraints\NotNull` constraint
- is added.
-
-### Strict Parameter Validation
-
-By default, API Platform allows clients to send extra query parameters that are not defined in the
-operation's `parameters`. To enforce a stricter contract, you can set
-`strictQueryParameterValidation` to `true` on an operation. If an unsupported parameter is sent, API
-Platform will return a 400 Bad Request error.
-
-```php
- new QueryParameter(),
- ]
- )
-])]
-class StrictParameters {}
-```
-
-With this configuration, a request to `/strict_query_parameters?bar=test` will fail with a 400 error
-because `bar` is not a supported parameter.
-
-### Property filter
-
-> [!NOTE] We strongly recommend using [Vulcain](https://vulcain.rocks) instead of this filter.
-> Vulcain is faster, allows a better hit rate, and is supported out of the box in the API Platform
-> distribution. [!NOTE] When unsing JSON:API check out the
-> [specific SparseFieldset and Sort filters](./content-negotiation/#jsonapi-sparse-fieldset-and-sort-parameters)
-
-The property filter adds the possibility to select the properties to serialize (sparse fieldsets).
-
-Syntax: `?properties[]=&properties[][]=`
-
-You can add as many properties as you need.
-
-Enable the filter:
-
-```php
- new QueryParameter(filter: PropertyFilter::class)]
-)]
-class Book
-{
- // ...
-}
-```
-
-Three arguments are available to configure the filter:
-
-- `parameterName` is the query parameter name (default `properties`)
-- `overrideDefaultProperties` allows to override the default serialization properties (default
- `false`)
-- `whitelist` properties whitelist to avoid uncontrolled data exposure (default `null` to allow all
- properties)
-
-Given that the collection endpoint is `/books`, you can filter the serialization properties with the
-following query: `/books?properties[]=title&properties[]=author`. If you want to include some
-properties of the nested "author" document, use:
-`/books?properties[]=title&properties[author][]=name`.
-
-## Parameter Providers
-
-Parameter Providers are powerful services that can inspect, transform, or provide values for
-parameters. They can even modify the current `Operation` metadata on the fly. A provider is a class
-that implements `ApiPlatform\State\ParameterProviderInterface`.
-
-### `IriConverterParameterProvider`
-
-This built-in provider takes an IRI string (e.g., `/users/1`) and converts it into the corresponding
-Doctrine entity object. It supports both single IRIs and arrays of IRIs.
-
-```php
- new QueryParameter(provider: IriConverterParameterProvider::class),
- 'related' => new QueryParameter(
- provider: IriConverterParameterProvider::class,
- extraProperties: ['fetch_data' => true] // Forces fetching the entity data
- ),
- ],
- provider: [self::class, 'provideDummyFromParameter'],
- )
-])]
-class WithParameter
-{
- public static function provideDummyFromParameter(Operation $operation, array $uriVariables = [], array $context = []): object|array
- {
- // The value has been transformed from an IRI to an entity by the provider.
- $dummy = $operation->getParameters()->get('dummy')->getValue();
-
- // If multiple IRIs were provided as an array, this will be an array of entities
- $related = $operation->getParameters()->get('related')->getValue();
-
- return $dummy;
- }
-}
-```
-
-#### Configuration Options
-
-The `IriConverterParameterProvider` supports the following options in `extraProperties`:
-
-- **`fetch_data`**: Boolean (default: `false`) - When `true`, forces the IRI converter to fetch the
- actual entity data instead of just creating a reference.
-
-### `ReadLinkParameterProvider`
-
-This provider must be enabled before it can be used.
-
-```yaml
-api_platform:
- enable_link_security: true
-```
-
-This provider fetches a linked resource from a given identifier. This is useful when you need to
-load a related entity to use later, for example in your own state provider. When you have an API
-resource with a custom `uriTemplate` that includes parameters, the `ReadLinkParameterProvider` can
-automatically resolve the linked resource using the operation's URI template. This is particularly
-useful for nested resources or when you need to load a parent resource based on URI variables.
-
-```php
- new Link(schema: ['type' => 'string', 'format' => 'uuid'], property: 'id'),
- ],
- parameters: [
- 'dummy' => new QueryParameter(
- provider: ReadLinkParameterProvider::class,
- extraProperties: [
- 'resource_class' => Dummy::class,
- 'uri_template' => '/dummies/{id}' // Optional: specify the template for the linked resource
- ]
- )
- ],
- provider: [self::class, 'provideDummyFromParameter'],
-)]
-class WithParameter
-{
- public static function provideDummyFromParameter(Operation $operation, array $uriVariables = [], array $context = []): object|array
- {
- // The dummy parameter has been resolved to the actual Dummy entity
- // based on the parameter value and the specified uri_template
- return $operation->getParameters()->get('dummy')->getValue();
- }
-}
-```
-
-The provider will:
-
-- Take the parameter value (e.g., a UUID or identifier)
-- Use the `resource_class` to determine which resource to load
-- Optionally use the `uri_template` from `extraProperties` to construct the proper operation for
- loading the resource
-- Return the loaded entity, making it available in your state provider
-
-#### ReadLinkParameterProvider Configuration Options
-
-You can control the behavior of `ReadLinkParameterProvider` with these `extraProperties`:
-
-- **`resource_class`**: The class of the resource to load
-- **`uri_template`**: Optional URI template for the linked resource operation
-- **`uri_variable`**: Name of the URI variable to use when building URI variables array
-- **`throw_not_found`**: Boolean (default: `true`) - Whether to throw `NotFoundHttpException` when
- resource is not found
-
-```php
-'dummy' => new QueryParameter(
- provider: ReadLinkParameterProvider::class,
- extraProperties: [
- 'resource_class' => Dummy::class,
- 'throw_not_found' => false, // Won't throw NotFoundHttpException if resource is missing
- 'uri_variable' => 'customId' // Use 'customId' as the URI variable name
- ]
-)
-```
-
-### Array Support
-
-Both `IriConverterParameterProvider` and `ReadLinkParameterProvider` support processing arrays of
-values. When you pass an array of identifiers or IRIs, they will return an array of resolved
-entities:
-
-```php
-// For IRI converter: ?related[]=/dummies/1&related[]=/dummies/2
-// For ReadLink provider: ?dummies[]=uuid1&dummies[]=uuid2
-'items' => new QueryParameter(
- provider: ReadLinkParameterProvider::class,
- extraProperties: ['resource_class' => Dummy::class]
-)
-```
-
-### Creating a Custom Parameter Provider
-
-You can create your own providers to implement any custom logic. A provider must implement
-`ParameterProviderInterface`. The `provide` method can modify the parameter's value or even return a
-modified `Operation` to alter the request handling flow.
-
-For instance, a provider could add serialization groups to the normalization context based on a
-query parameter:
-
-```php
-getValue();
- if ('extended' === $value) {
- $context = $operation->getNormalizationContext();
- $context[AbstractNormalizer::GROUPS][] = 'extended_read';
- return $operation->withNormalizationContext($context);
- }
-
- return $operation;
- }
-}
-```
-
-### Changing how to parse Query / Header Parameters
-
-We use our own algorithm to parse a request's query, if you want to do the parsing of `QUERY_STRING`
-yourself, set `_api_query_parameters` in the Request attributes
-(`$request->attributes->set('_api_query_parameters', [])`) yourself. By default we use Symfony's
-`$request->headers->all()`, you can also set `_api_header_parameters` if you want to parse them
-yourself.
-
-## Creating Custom Filters
-
-For data-provider-specific filtering (e.g., Doctrine ORM), the recommended way to create a filter is
-to implement the corresponding `FilterInterface`.
-
-For Doctrine ORM, your filter should implement `ApiPlatform\Doctrine\Orm\Filter\FilterInterface`:
-
-```php
-getValue();
-
- // The parameter may not be present.
- // It's recommended to add validation (e.g., `required: true`) on the Parameter attribute
- // if the filter logic depends on the value.
- if ($value instanceof ParameterNotFound) {
- return;
- }
-
- $alias = $queryBuilder->getRootAliases()[0];
- $parameterName = $queryNameGenerator->generateParameterName('regexp_name');
-
- // Access the parameter's property or use the parameter key as fallback
- $property = $parameter->getProperty() ?? $parameter->getKey() ?? 'name';
-
- // You can also access filter context if the parameter provides it
- $filterContext = $parameter->getFilterContext() ?? null;
-
- $queryBuilder
- ->andWhere(sprintf('REGEXP(%s.%s, :%s) = 1', $alias, $property, $parameterName))
- ->setParameter($parameterName, $value);
- }
-
- // For BC, this function is not useful anymore when documentation occurs on the Parameter
- public function getDescription(): array {
- return [];
- }
-}
-```
-
-You can then instantiate this filter directly in your `QueryParameter`:
-
-```php
- new QueryParameter(filter: new RegexpFilter())
- ]
- )
-])]
-class User {}
-```
-
-> [!NOTE] A `filter` is either an instanceof `FilterInterface` or a string referencing a filter
-> service.
-
-## Parameter Attribute Reference
-
-| Property | Description |
-| ------------------ | --------------------------------------------------------------------------------------------------------------------- |
-| `key` | The name of the parameter (e.g., `name`, `order`). |
-| `filter` | The filter service or instance that processes the parameter's value. |
-| `provider` | A service that transforms the parameter's value before it's used. |
-| `description` | A description for the API documentation. |
-| `property` | The resource property this parameter is mapped to. |
-| `required` | Whether the parameter is required. |
-| `constraints` | Symfony Validator constraints to apply to the value. |
-| `schema` | A JSON Schema for validation and documentation. |
-| `castToArray` | Casts the parameter value to an array. Useful for query parameters like `foo[]=1&foo[]=2`. Defaults to `true`. |
-| `castToNativeType` | Casts the parameter value to its native PHP type based on the `schema`. |
-| `openApi` | Customize OpenAPI documentation or hide the parameter (`false`). |
-| `hydra` | Hide the parameter from Hydra documentation (`false`). |
-| `security` | A [Symfony expression](https://symfony.com/doc/current/security/expressions.html) to control access to the parameter. |
-
-## Parameter Security
-
-You can secure individual parameters using Symfony expression language. When a security expression
-evaluates to `false`, the parameter will be ignored and treated as if it wasn't provided.
-
-```php
- new QueryParameter(
- security: 'is_granted("ROLE_ADMIN")'
- ),
- 'auth' => new HeaderParameter(
- security: '"secured" == auth',
- description: 'Only accessible when auth header equals "secured"'
- ),
- 'secret' => new QueryParameter(
- security: '"secured" == secret',
- description: 'Only accessible when secret parameter equals "secured"'
- )
- ]
- )
-])]
-class SecureResource
-{
- // ...
-}
-```
-
-In the security expressions, you have access to:
-
-- Parameter values by their key name (e.g., `auth`, `secret`)
-- Standard security functions like `is_granted()`
-- The current user via `user`
-- Request object via `request`
diff --git a/src/Doctrine/Common/Filter/NameConverterAwareInterface.php b/src/Doctrine/Common/Filter/NameConverterAwareInterface.php
new file mode 100644
index 00000000000..8cec1fc8d5b
--- /dev/null
+++ b/src/Doctrine/Common/Filter/NameConverterAwareInterface.php
@@ -0,0 +1,25 @@
+
+ *
+ * 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\Doctrine\Common\Filter;
+
+use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
+
+interface NameConverterAwareInterface
+{
+ public function hasNameConverter(): bool;
+
+ public function getNameConverter(): ?NameConverterInterface;
+
+ public function setNameConverter(NameConverterInterface $nameConverter): void;
+}
diff --git a/src/Doctrine/Common/ParameterExtensionTrait.php b/src/Doctrine/Common/ParameterExtensionTrait.php
index 4d865a8730f..57f1ff140c0 100644
--- a/src/Doctrine/Common/ParameterExtensionTrait.php
+++ b/src/Doctrine/Common/ParameterExtensionTrait.php
@@ -15,11 +15,13 @@
use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface;
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
+use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareInterface;
use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface;
use ApiPlatform\Metadata\Parameter;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
+use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
trait ParameterExtensionTrait
{
@@ -28,6 +30,7 @@ trait ParameterExtensionTrait
protected ContainerInterface $filterLocator;
protected ?ManagerRegistry $managerRegistry = null;
protected ?LoggerInterface $logger = null;
+ protected ?NameConverterInterface $nameConverter = null;
/**
* @param object $filter the filter instance to configure
@@ -43,6 +46,10 @@ private function configureFilter(object $filter, Parameter $parameter): void
$filter->setLogger($this->logger);
}
+ if ($this->nameConverter && $filter instanceof NameConverterAwareInterface && !$filter->hasNameConverter()) {
+ $filter->setNameConverter($this->nameConverter);
+ }
+
if ($filter instanceof PropertyAwareFilterInterface) {
$properties = [];
// Check if the filter has getProperties method (e.g., if it's an AbstractFilter)
diff --git a/src/Doctrine/Odm/Extension/ParameterExtension.php b/src/Doctrine/Odm/Extension/ParameterExtension.php
index d841fb9240e..a9d2ac4c4ec 100644
--- a/src/Doctrine/Odm/Extension/ParameterExtension.php
+++ b/src/Doctrine/Odm/Extension/ParameterExtension.php
@@ -22,6 +22,7 @@
use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
+use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
/**
* Reads operation parameters and execute its filter.
@@ -36,10 +37,12 @@ public function __construct(
ContainerInterface $filterLocator,
?ManagerRegistry $managerRegistry = null,
?LoggerInterface $logger = null,
+ ?NameConverterInterface $nameConverter = null,
) {
$this->filterLocator = $filterLocator;
$this->managerRegistry = $managerRegistry;
$this->logger = $logger;
+ $this->nameConverter = $nameConverter;
}
/**
diff --git a/src/Doctrine/Odm/Filter/AbstractFilter.php b/src/Doctrine/Odm/Filter/AbstractFilter.php
index 75b85b9ac47..1f897d223c3 100644
--- a/src/Doctrine/Odm/Filter/AbstractFilter.php
+++ b/src/Doctrine/Odm/Filter/AbstractFilter.php
@@ -14,6 +14,7 @@
namespace ApiPlatform\Doctrine\Odm\Filter;
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
+use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareInterface;
use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface;
use ApiPlatform\Doctrine\Common\PropertyHelperTrait;
use ApiPlatform\Doctrine\Odm\PropertyHelperTrait as MongoDbOdmPropertyHelperTrait;
@@ -32,7 +33,7 @@
*
* @author Alan Poulain
*/
-abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface, ManagerRegistryAwareInterface
+abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface
{
use MongoDbOdmPropertyHelperTrait;
use PropertyHelperTrait;
@@ -85,6 +86,21 @@ public function setManagerRegistry(ManagerRegistry $managerRegistry): void
$this->managerRegistry = $managerRegistry;
}
+ public function hasNameConverter(): bool
+ {
+ return $this->nameConverter instanceof NameConverterInterface;
+ }
+
+ public function getNameConverter(): ?NameConverterInterface
+ {
+ return $this->nameConverter;
+ }
+
+ public function setNameConverter(NameConverterInterface $nameConverter): void
+ {
+ $this->nameConverter = $nameConverter;
+ }
+
/**
* @return array|null
*/
diff --git a/src/Doctrine/Odm/Tests/Extension/ParameterExtensionTest.php b/src/Doctrine/Odm/Tests/Extension/ParameterExtensionTest.php
index 0188c1c0206..e2b410cc51c 100644
--- a/src/Doctrine/Odm/Tests/Extension/ParameterExtensionTest.php
+++ b/src/Doctrine/Odm/Tests/Extension/ParameterExtensionTest.php
@@ -17,6 +17,7 @@
use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait;
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait;
+use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareInterface;
use ApiPlatform\Doctrine\Odm\Extension\ParameterExtension;
use ApiPlatform\Doctrine\Odm\Filter\FilterInterface;
use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait;
@@ -30,6 +31,7 @@
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
+use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
class ParameterExtensionTest extends TestCase
{
@@ -123,6 +125,62 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera
$this->assertNotNull($filter->getManagerRegistry());
}
+ public function testApplyToCollectionWithNameConverter(): void
+ {
+ $aggregationBuilder = $this->createMock(Builder::class);
+ $nameConverter = $this->createMock(NameConverterInterface::class);
+
+ $filter = new class($nameConverter) implements FilterInterface, NameConverterAwareInterface {
+ use BackwardCompatibleFilterDescriptionTrait;
+
+ private ?NameConverterInterface $nameConverter = null;
+
+ public function __construct(private readonly NameConverterInterface $expectedNameConverter)
+ {
+ }
+
+ public function hasNameConverter(): bool
+ {
+ return $this->nameConverter instanceof NameConverterInterface;
+ }
+
+ public function getNameConverter(): ?NameConverterInterface
+ {
+ return $this->nameConverter;
+ }
+
+ public function setNameConverter(NameConverterInterface $nameConverter): void
+ {
+ $this->nameConverter = $nameConverter;
+ }
+
+ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
+ {
+ Assert::assertTrue($this->hasNameConverter());
+ Assert::assertSame($this->expectedNameConverter, $this->getNameConverter());
+ Assert::assertSame('SomeClass', $resourceClass);
+ }
+ };
+
+ $operation = (new GetCollection())
+ ->withParameters([
+ (new QueryParameter(
+ key: 'param1',
+ filter: $filter,
+ ))->setValue(1),
+ ]);
+
+ $extension = new ParameterExtension(
+ $this->createNonCalledFilterLocator(),
+ nameConverter: $nameConverter,
+ );
+ $context = [];
+ $extension->applyToCollection($aggregationBuilder, 'SomeClass', $operation, $context);
+
+ $this->assertSame([], $context);
+ $this->assertSame($nameConverter, $filter->getNameConverter());
+ }
+
public function testApplyToCollectionPassesContext(): void
{
$aggregationBuilder = $this->createMock(Builder::class);
diff --git a/src/Doctrine/Odm/composer.json b/src/Doctrine/Odm/composer.json
index cbca401032d..2b902cfc249 100644
--- a/src/Doctrine/Odm/composer.json
+++ b/src/Doctrine/Odm/composer.json
@@ -25,7 +25,7 @@
],
"require": {
"php": ">=8.2",
- "api-platform/doctrine-common": "^4.2.9",
+ "api-platform/doctrine-common": "^4.2.23",
"api-platform/metadata": "^4.2",
"api-platform/state": "^4.2.4",
"doctrine/mongodb-odm": "^2.10",
diff --git a/src/Doctrine/Orm/Extension/ParameterExtension.php b/src/Doctrine/Orm/Extension/ParameterExtension.php
index c21c1018ce9..f9a4f95dcce 100644
--- a/src/Doctrine/Orm/Extension/ParameterExtension.php
+++ b/src/Doctrine/Orm/Extension/ParameterExtension.php
@@ -22,6 +22,7 @@
use Doctrine\Persistence\ManagerRegistry;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
+use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
/**
* Reads operation parameters and execute its filter.
@@ -36,10 +37,12 @@ public function __construct(
ContainerInterface $filterLocator,
?ManagerRegistry $managerRegistry = null,
?LoggerInterface $logger = null,
+ ?NameConverterInterface $nameConverter = null,
) {
$this->filterLocator = $filterLocator;
$this->managerRegistry = $managerRegistry;
$this->logger = $logger;
+ $this->nameConverter = $nameConverter;
}
/**
diff --git a/src/Doctrine/Orm/Filter/AbstractFilter.php b/src/Doctrine/Orm/Filter/AbstractFilter.php
index 0f9e7417dd1..e07597bea1e 100644
--- a/src/Doctrine/Orm/Filter/AbstractFilter.php
+++ b/src/Doctrine/Orm/Filter/AbstractFilter.php
@@ -14,6 +14,7 @@
namespace ApiPlatform\Doctrine\Orm\Filter;
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
+use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareInterface;
use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface;
use ApiPlatform\Doctrine\Common\PropertyHelperTrait;
use ApiPlatform\Doctrine\Orm\PropertyHelperTrait as OrmPropertyHelperTrait;
@@ -26,7 +27,7 @@
use Psr\Log\NullLogger;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
-abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface, ManagerRegistryAwareInterface
+abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface
{
use OrmPropertyHelperTrait;
use PropertyHelperTrait;
@@ -78,6 +79,21 @@ public function setManagerRegistry(ManagerRegistry $managerRegistry): void
$this->managerRegistry = $managerRegistry;
}
+ public function hasNameConverter(): bool
+ {
+ return $this->nameConverter instanceof NameConverterInterface;
+ }
+
+ public function getNameConverter(): ?NameConverterInterface
+ {
+ return $this->nameConverter;
+ }
+
+ public function setNameConverter(NameConverterInterface $nameConverter): void
+ {
+ $this->nameConverter = $nameConverter;
+ }
+
public function getProperties(): ?array
{
return $this->properties;
diff --git a/src/Doctrine/Orm/composer.json b/src/Doctrine/Orm/composer.json
index 0fb956e21f9..01bfac2b8d3 100644
--- a/src/Doctrine/Orm/composer.json
+++ b/src/Doctrine/Orm/composer.json
@@ -24,7 +24,7 @@
],
"require": {
"php": ">=8.2",
- "api-platform/doctrine-common": "^4.2.9",
+ "api-platform/doctrine-common": "^4.2.23",
"api-platform/metadata": "^4.2",
"api-platform/state": "^4.2.4",
"doctrine/orm": "^2.17 || ^3.0"
diff --git a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php
index 17d22bbf589..65b99d8fe8c 100644
--- a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php
+++ b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php
@@ -180,6 +180,7 @@
service('api_platform.filter_locator'),
service('doctrine_mongodb')->nullOnInvalid(),
service('logger')->nullOnInvalid(),
+ service('api_platform.name_converter')->nullOnInvalid(),
])
->tag('api_platform.doctrine_mongodb.odm.aggregation_extension.collection', ['priority' => 32])
->tag('api_platform.doctrine_mongodb.odm.aggregation_extension.item');
diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.php b/src/Symfony/Bundle/Resources/config/doctrine_orm.php
index 964686b1401..c46ea32f5f8 100644
--- a/src/Symfony/Bundle/Resources/config/doctrine_orm.php
+++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.php
@@ -195,6 +195,7 @@
service('api_platform.filter_locator'),
service('doctrine')->nullOnInvalid(),
service('logger')->nullOnInvalid(),
+ service('api_platform.name_converter')->nullOnInvalid(),
])
->tag('api_platform.doctrine.orm.query_extension.collection', ['priority' => -16])
->tag('api_platform.doctrine.orm.query_extension.item', ['priority' => -9]);
diff --git a/tests/Fixtures/TestBundle/Document/ConvertedDateParameter.php b/tests/Fixtures/TestBundle/Document/ConvertedDateParameter.php
new file mode 100644
index 00000000000..3fc59fd6e1c
--- /dev/null
+++ b/tests/Fixtures/TestBundle/Document/ConvertedDateParameter.php
@@ -0,0 +1,51 @@
+
+ *
+ * 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\Fixtures\TestBundle\Document;
+
+use ApiPlatform\Doctrine\Odm\Filter\DateFilter;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\QueryParameter;
+use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
+
+/**
+ * Tests that legacy AbstractFilter subclasses work with QueryParameter
+ * when a nameConverter is configured (issue #7866).
+ */
+#[ApiResource(
+ operations: [
+ new GetCollection(
+ parameters: [
+ 'nameConverted' => new QueryParameter(
+ filter: new DateFilter(),
+ properties: ['nameConverted'],
+ ),
+ ],
+ ),
+ ],
+)]
+#[ODM\Document]
+class ConvertedDateParameter
+{
+ #[ODM\Id(strategy: 'INCREMENT', type: 'int')]
+ private ?int $id = null;
+
+ #[ODM\Field(type: 'date')]
+ public \DateTime $nameConverted;
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+}
diff --git a/tests/Fixtures/TestBundle/Entity/ConvertedDateParameter.php b/tests/Fixtures/TestBundle/Entity/ConvertedDateParameter.php
new file mode 100644
index 00000000000..47232100e1e
--- /dev/null
+++ b/tests/Fixtures/TestBundle/Entity/ConvertedDateParameter.php
@@ -0,0 +1,53 @@
+
+ *
+ * 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\Fixtures\TestBundle\Entity;
+
+use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\QueryParameter;
+use Doctrine\ORM\Mapping as ORM;
+
+/**
+ * Tests that legacy AbstractFilter subclasses work with QueryParameter
+ * when a nameConverter is configured (issue #7866).
+ */
+#[ApiResource(
+ operations: [
+ new GetCollection(
+ parameters: [
+ 'nameConverted' => new QueryParameter(
+ filter: new DateFilter(),
+ properties: ['nameConverted'],
+ ),
+ ],
+ ),
+ ],
+)]
+#[ORM\Entity]
+class ConvertedDateParameter
+{
+ #[ORM\Column(type: 'integer', nullable: true)]
+ #[ORM\Id]
+ #[ORM\GeneratedValue(strategy: 'AUTO')]
+ private ?int $id = null;
+
+ #[ORM\Column(type: 'date')]
+ public \DateTime $nameConverted;
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+}
diff --git a/tests/Functional/Parameters/NameConverterFilterTest.php b/tests/Functional/Parameters/NameConverterFilterTest.php
new file mode 100644
index 00000000000..4b65c321d13
--- /dev/null
+++ b/tests/Functional/Parameters/NameConverterFilterTest.php
@@ -0,0 +1,82 @@
+
+ *
+ * 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\Functional\Parameters;
+
+use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
+use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedDateParameter as ConvertedDateParameterDocument;
+use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedDateParameter;
+use ApiPlatform\Tests\RecreateSchemaTrait;
+use ApiPlatform\Tests\SetupClassResourcesTrait;
+
+/**
+ * Tests that legacy AbstractFilter subclasses (DateFilter, etc.) work correctly
+ * with QueryParameter when a nameConverter is configured.
+ *
+ * @see https://github.com/api-platform/core/issues/7866
+ */
+final class NameConverterFilterTest extends ApiTestCase
+{
+ use RecreateSchemaTrait;
+ use SetupClassResourcesTrait;
+
+ protected static ?bool $alwaysBootKernel = false;
+
+ /**
+ * @return class-string[]
+ */
+ public static function getResources(): array
+ {
+ return [ConvertedDateParameter::class];
+ }
+
+ protected function setUp(): void
+ {
+ $entityClass = $this->isMongoDB() ? ConvertedDateParameterDocument::class : ConvertedDateParameter::class;
+
+ $this->recreateSchema([$entityClass]);
+ $this->loadFixtures($entityClass);
+ }
+
+ public function testDateFilterWithNameConverter(): void
+ {
+ $response = self::createClient()->request('GET', '/converted_date_parameters?nameConverted[after]=2025-01-15');
+ $this->assertResponseIsSuccessful();
+ $members = $response->toArray()['hydra:member'];
+ $this->assertCount(2, $members);
+ }
+
+ public function testDateFilterBeforeWithNameConverter(): void
+ {
+ $response = self::createClient()->request('GET', '/converted_date_parameters?nameConverted[before]=2025-01-15');
+ $this->assertResponseIsSuccessful();
+ $members = $response->toArray()['hydra:member'];
+ $this->assertCount(1, $members);
+ }
+
+ /**
+ * @param class-string $entityClass
+ */
+ private function loadFixtures(string $entityClass): void
+ {
+ $manager = $this->getManager();
+
+ foreach (['2025-01-01', '2025-02-01', '2025-03-01'] as $date) {
+ $entity = new $entityClass();
+ $entity->nameConverted = new \DateTime($date);
+ $manager->persist($entity);
+ }
+
+ $manager->flush();
+ }
+}