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:
ParameterResourceMetadataCollectionFactory::setDefaults() normalizes the parameter's property (singular) to snake_case (source):
// ParameterResourceMetadataCollectionFactory::setDefaults()
$parameter = $parameter->withProperty($this->nameConverter->normalize($property));
// createdAt → created_at
- 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]
-
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 createdAt → returns false, filter is silently skipped.
-
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:
- Normalize the
properties (plural) to match the already-normalized property (singular)
- 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.
API Platform version(s) affected: 4.3.1
Environment: PHP 8.4, Symfony 8.0
Description
When using the
QueryParameterapproach with anyAbstractFiltersubclass and acamel_case_to_snake_casename 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:
ParameterResourceMetadataCollectionFactory::setDefaults()normalizes the parameter'sproperty(singular) to snake_case (source):ParameterExtensionTrait::configureFilter()sets the filter'spropertiesfrom$parameter->getProperties()(plural), which remains in its original form (camelCase):Then the filter calls
filterProperty('created_at', ...)(snake_case from the parameter's normalized singular property), butAbstractFilter::isPropertyEnabled('created_at')looks for it in the properties map which only containscreatedAt→ returnsfalse, filter is silently skipped.Even if
isPropertyEnabledpassed, the filter'snameConverterisnull(never injected byconfigureFilter), sodenormalizePropertyName('created_at')returns'created_at'unchanged, andisPropertyMapped('created_at')fails against Doctrine metadata which uses camelCase property names.Affected filters: all
AbstractFiltersubclasses —DateFilter,OrderFilter,ExistsFilter,BooleanFilter,RangeFilter,NumericFilter,BackedEnumFilter,SearchFilter.Not affected: newer filter classes (
ExactFilter,PartialSearchFilter,ComparisonFilter,SortFilter,IriFilter) that implementFilterInterfacedirectly without extendingAbstractFilter, and filters using the legacy#[ApiFilter]attribute (which go throughFilterExtensionwith DI-registered services that already have thenameConverterinjected).How to reproduce
Output of
debug:api-resourceon the GetCollection operation confirms the mismatch:Possible Solution
The root cause is that
ParameterExtensionTrait::configureFilter()does not:properties(plural) to match the already-normalizedproperty(singular)nameConverterinto the filter — soAbstractFilter::denormalizePropertyName()is a no-opOption A: Inject
nameConverterinto filters (preferred — matches existingManagerRegistryAwareInterfacepattern)Add a
NameConverterAwareInterfacetoAbstractFilter, similar toManagerRegistryAwareInterfaceandLoggerAwareInterface:Then in
ParameterExtensionTrait::configureFilter():The
ParameterExtensionconstructor would need theNameConverterInterfaceinjected (same asManagerRegistryalready is).Option B: Also normalize
properties(plural) inconfigureFilter()(complementary hardening)As an additional hardening measure alongside Option A,
configureFilter()could also normalize the pluralpropertiesto match the already-normalized singularproperty. This also requiresNameConverterInterfaceinParameterExtension(same prerequisite as Option A):Additional Context
Current workaround: register the filter as a service with the
nameConvertermanually injected, reference it by service ID, and keeppropertiesin camelCase (PHP property names):propertiesmust remain in camelCase. Using snake_case (['published_at']) would fixisPropertyMapped(via the injectednameConverter) but breakisPropertyEnabled, becauseconfigureFilter()sets the filter's properties map from the un-normalized pluralgetProperties(), and afterdenormalizePropertyName()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
QueryParameterapproach is the recommended path forward (#[ApiFilter]is deprecated in favor ofQueryParameter). However, there are no modernFilterInterface-only equivalents forExistsFilter,BooleanFilter,DateFilter,NumericFilter,BackedEnumFilter, orRangeFilter. Users migrating toQueryParameterare forced to useAbstractFiltersubclasses for these use cases — and silently hit this bug on any multi-word property.ExactFilter+PartialSearchFilter+SortFilter+IriFiltercoverSearchFilterandOrderFilter, but the gap for the six other filter types means there is currently no bug-free path for them withQueryParameter.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
createdAtproperty could spend hours debugging. A fail-fast approach would significantly help: when anAbstractFilteris used without anameConverterin 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
AbstractFiltersubclass has different constructor signatures, making the DI service workaround non-trivial:DateFilter,BooleanFilter,NumericFilter,BackedEnumFilter,RangeFilter— inheritAbstractFilter's constructor (4 params)ExistsFilter— adds$existsParameterNameOrderFilter— adds$orderParameterNameand$orderNullsComparisonSearchFilter— requiresIriConverterInterfaceandIdentifiersExtractorInterface(cannot be used withnewinline at all, even without the nameConverter bug)And the workaround has a non-obvious constraint:
propertiesin theQueryParametermust stay in camelCase — using snake_case creates the reverse mismatch (see workaround section above). None of this is documented.