Skip to content

All AbstractFilter subclasses silently fail with QueryParameter when a nameConverter is configured #7866

@corentin-larose

Description

@corentin-larose

API Platform version(s) affected: 4.3.1
Environment: PHP 8.4, Symfony 8.0

Description

When using the QueryParameter approach with any AbstractFilter subclass and a camel_case_to_snake_case name converter, filters on multi-word properties are silently ignored. No error is thrown — results are simply returned unfiltered.

Any property whose PHP name is camelCase (createdAt, firstName, publishedAt, …) triggers the bug. Single-word properties (email, name, status, …) are not affected since camelCase and snake_case are identical for them.

The root cause is a property name mismatch between two parts of the framework:

  1. ParameterResourceMetadataCollectionFactory::setDefaults() normalizes the parameter's property (singular) to snake_case (source):
// ParameterResourceMetadataCollectionFactory::setDefaults()
$parameter = $parameter->withProperty($this->nameConverter->normalize($property));
// createdAt → created_at
  1. But ParameterExtensionTrait::configureFilter() sets the filter's properties from $parameter->getProperties() (plural), which remains in its original form (camelCase):
// ParameterExtensionTrait::configureFilter()
foreach ($parameter->getProperties() ?? [$propertyKey] as $property) {
    $properties[$property] = $parameter->getFilterContext();
}
$filter->setProperties($properties);
// Sets: ['createdAt' => null]
  1. Then the filter calls filterProperty('created_at', ...) (snake_case from the parameter's normalized singular property), but AbstractFilter::isPropertyEnabled('created_at') looks for it in the properties map which only contains createdAtreturns false, filter is silently skipped.

  2. Even if isPropertyEnabled passed, the filter's nameConverter is null (never injected by configureFilter), so denormalizePropertyName('created_at') returns 'created_at' unchanged, and isPropertyMapped('created_at') fails against Doctrine metadata which uses camelCase property names.

Affected filters: all AbstractFilter subclasses — DateFilter, OrderFilter, ExistsFilter, BooleanFilter, RangeFilter, NumericFilter, BackedEnumFilter, SearchFilter.

Not affected: newer filter classes (ExactFilter, PartialSearchFilter, ComparisonFilter, SortFilter, IriFilter) that implement FilterInterface directly without extending AbstractFilter, and filters using the legacy #[ApiFilter] attribute (which go through FilterExtension with DI-registered services that already have the nameConverter injected).

How to reproduce

// config/packages/api_platform.php
'name_converter' => 'serializer.name_converter.camel_case_to_snake_case',
// Any AbstractFilter subclass on any multi-word camelCase property triggers the bug.
// Examples with DateFilter, ExistsFilter, and OrderFilter:

#[ApiResource(operations: [
    new GetCollection(parameters: [
        'createdAt' => new QueryParameter(
            filter: new DateFilter(),
            properties: ['createdAt'],
        ),
        'exists[:property]' => new QueryParameter(
            filter: new ExistsFilter(),
            properties: ['publishedAt'],
        ),
        'order[:property]' => new QueryParameter(
            filter: new OrderFilter(),
            properties: ['createdAt'],
        ),
    ]),
])]
class Book
{
    #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
    protected DateTimeImmutable $createdAt;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
    protected ?DateTimeImmutable $publishedAt = null;
}
# All three return unfiltered/unsorted results — filters are silently ignored
curl "https://example.com/api/books?createdAt[after]=2025-01-01"
curl "https://example.com/api/books?exists[publishedAt]=true"
curl "https://example.com/api/books?order[createdAt]=desc"

Output of debug:api-resource on the GetCollection operation confirms the mismatch:

#property: "created_at"        ← normalized (singular)
#properties: array:1 [
    0 => "createdAt"           ← NOT normalized (plural)
]
#filter: DateFilter { nameConverter: null }

Possible Solution

The root cause is that ParameterExtensionTrait::configureFilter() does not:

  1. Normalize the properties (plural) to match the already-normalized property (singular)
  2. Inject the nameConverter into the filter — so AbstractFilter::denormalizePropertyName() is a no-op

Option A: Inject nameConverter into filters (preferred — matches existing ManagerRegistryAwareInterface pattern)

Add a NameConverterAwareInterface to AbstractFilter, similar to ManagerRegistryAwareInterface and LoggerAwareInterface:

interface NameConverterAwareInterface
{
    public function setNameConverter(NameConverterInterface $nameConverter): void;
    public function hasNameConverter(): bool;
}

Then in ParameterExtensionTrait::configureFilter():

if ($this->nameConverter && $filter instanceof NameConverterAwareInterface && !$filter->hasNameConverter()) {
    $filter->setNameConverter($this->nameConverter);
}

The ParameterExtension constructor would need the NameConverterInterface injected (same as ManagerRegistry already is).

Option B: Also normalize properties (plural) in configureFilter() (complementary hardening)

As an additional hardening measure alongside Option A, configureFilter() could also normalize the plural properties to match the already-normalized singular property. This also requires NameConverterInterface in ParameterExtension (same prerequisite as Option A):

$propertyKey = $parameter->getProperty() ?? $parameter->getKey();
foreach ($parameter->getProperties() ?? [$propertyKey] as $property) {
    $normalizedProperty = $this->nameConverter?->normalize($property) ?? $property;
    if (!isset($properties[$normalizedProperty])) {
        $properties[$normalizedProperty] = $parameter->getFilterContext();
    }
}

Note: Option B alone would only fix isPropertyEnabled but not isPropertyMapped — the filter still needs the nameConverter to denormalize property names back to camelCase for Doctrine metadata lookups. Option A alone is sufficient for a complete fix; Option B is belt-and-suspenders.

Additional Context

Current workaround: register the filter as a service with the nameConverter manually injected, reference it by service ID, and keep properties in camelCase (PHP property names):

// config/services.php — example with ExistsFilter (same pattern for DateFilter, OrderFilter, etc.)
'app.filter.exists' => [
    'class' => ExistsFilter::class,
    'arguments' => [
        '$managerRegistry'     => service('doctrine'),
        '$logger'              => service('monolog.logger'),
        '$properties'          => null,
        '$existsParameterName' => 'exists',
        '$nameConverter'       => service('serializer.name_converter.camel_case_to_snake_case'),
    ],
    'tags' => ['api_platform.filter'],
],
// Entity — service ID + camelCase properties (critical: snake_case would also fail)
new QueryParameter(filter: 'app.filter.exists', properties: ['publishedAt'])

properties must remain in camelCase. Using snake_case (['published_at']) would fix isPropertyMapped (via the injected nameConverter) but break isPropertyEnabled, because configureFilter() sets the filter's properties map from the un-normalized plural getProperties(), and after denormalizePropertyName() the lookup key becomes camelCase — creating the reverse mismatch.

Impact

After working around this bug, a few additional observations that may help scope the fix:

This is a migration blocker, not an edge case

The QueryParameter approach is the recommended path forward (#[ApiFilter] is deprecated in favor of QueryParameter). However, there are no modern FilterInterface-only equivalents for ExistsFilter, BooleanFilter, DateFilter, NumericFilter, BackedEnumFilter, or RangeFilter. Users migrating to QueryParameter are forced to use AbstractFilter subclasses for these use cases — and silently hit this bug on any multi-word property.

ExactFilter + PartialSearchFilter + SortFilter + IriFilter cover SearchFilter and OrderFilter, but the gap for the six other filter types means there is currently no bug-free path for them with QueryParameter.

The silent failure makes this extremely hard to diagnose

The filter simply returns unfiltered results — no exception, no log entry, no deprecation notice. A developer following the documentation examples with a createdAt property could spend hours debugging. A fail-fast approach would significantly help: when an AbstractFilter is used without a nameConverter in a context where one is globally configured, throwing an exception (or at minimum logging a warning) would surface the issue immediately.

The workaround is fragile and filter-specific

Each AbstractFilter subclass has different constructor signatures, making the DI service workaround non-trivial:

  • DateFilter, BooleanFilter, NumericFilter, BackedEnumFilter, RangeFilter — inherit AbstractFilter's constructor (4 params)
  • ExistsFilter — adds $existsParameterName
  • OrderFilter — adds $orderParameterName and $orderNullsComparison
  • SearchFilter — requires IriConverterInterface and IdentifiersExtractorInterface (cannot be used with new inline at all, even without the nameConverter bug)

And the workaround has a non-obvious constraint: properties in the QueryParameter must stay in camelCase — using snake_case creates the reverse mismatch (see workaround section above). None of this is documented.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions