-
-
Notifications
You must be signed in to change notification settings - Fork 960
Description
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'sproperty(singular) to snake_case (source):
// ParameterResourceMetadataCollectionFactory::setDefaults()
$parameter = $parameter->withProperty($this->nameConverter->normalize($property));
// createdAt → created_at- But
ParameterExtensionTrait::configureFilter()sets the filter'spropertiesfrom$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), 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 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-normalizedproperty(singular) - Inject the
nameConverterinto the filter — soAbstractFilter::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
isPropertyEnabledbut notisPropertyMapped— the filter still needs thenameConverterto 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— 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: properties in the QueryParameter must stay in camelCase — using snake_case creates the reverse mismatch (see workaround section above). None of this is documented.