Skip to content

Modern parameter filters (ExactFilter, SortFilter, ComparisonFilter) generate invalid DQL for multi-word properties when a name converter is configured #8380

Description

@lglot

API Platform version(s) affected: 4.3.15 (still present on main as of 2026-07-02)
Environment: PHP 8.3, Symfony 6.4, Doctrine ORM, CamelCaseToSnakeCaseNameConverter configured as api_platform.name_converter

Description

Follow-up to #7866 / #7877, which fixed the missing nameConverter injection for legacy AbstractFilter subclasses used inline with QueryParameter.

The modern parameter filters (ExactFilter, SortFilter, ComparisonFilter, PartialSearchFilter, ...) have a related but distinct problem: when a name converter is configured, ParameterResourceMetadataCollectionFactory::setDefaults() normalizes Parameter::property (createdAt -> created_at), and the filters then use $parameter->getProperty() as-is in the DQL. Since the Doctrine field is createdAt, every request filtering on a multi-word property fails with HTTP 500:

Doctrine\ORM\Query\QueryException: [Semantical Error] ... Class App\Entity\Book has no field or association named created_at

Single-word properties (status, name, ...) are unaffected (camelCase == snake_case for them), which makes the bug easy to miss in a test suite.

Note that the migration guide currently recommends the modern filters as the fix for the legacy filters' nameConverter issues ("If you encounter silent filter failures with camelCase properties (e.g., createdAt, firstName), upgrading to the modern filter equivalents listed above is the recommended solution"), but on this setup they fail harder than the legacy ones (500 instead of silently ignoring the filter).

How to reproduce

# config/packages/api_platform.yaml
api_platform:
    name_converter: 'Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter'
#[ApiResource]
#[GetCollection(
    parameters: [
        'created_at' => new QueryParameter(filter: new ExactFilter(), property: 'createdAt'),
        'sortBy[created_at]' => new QueryParameter(filter: new SortFilter(), property: 'createdAt'),
    ],
)]
class Book
{
    // ...
    public \DateTimeImmutable $createdAt;
}
  • GET /books?created_at=2026-01-01 -> 500 (has no field or association named created_at)
  • GET /books?sortBy[created_at]=asc -> 500
  • same with ComparisonFilter(new ExactFilter()) and ?created_at[gt]=...
  • GET /books?name=foo (single-word property) -> works

Possible Solution

Mirror what #7877 did for the legacy filters: denormalize Parameter::property back to the PHP field name before it reaches the query, either in ParameterExtensionTrait::configureFilter() or in the filters themselves. The metadata factory already preserves the PHP path for nested properties (nested_properties_info skips the name conversion in setDefaults()), so flat multi-word properties are the only remaining case.

Additional Context

Workaround we are shipping in the meantime — a small decorating filter that restores the camelCase property before delegating:

final class DenormalizePropertyFilter implements FilterInterface, ManagerRegistryAwareInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface
{
    use ManagerRegistryAwareTrait;

    public function __construct(private readonly FilterInterface $inner)
    {
        $this->nameConverter = new CamelCaseToSnakeCaseNameConverter();
    }

    public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
    {
        if ($this->inner instanceof ManagerRegistryAwareInterface && $this->hasManagerRegistry()) {
            $this->inner->setManagerRegistry($this->getManagerRegistry());
        }

        $parameter = $context['parameter'] ?? null;
        if ($parameter instanceof Parameter && null !== ($property = $parameter->getProperty())) {
            $context['parameter'] = $parameter->withProperty($this->nameConverter->denormalize($property));
        }

        $this->inner->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context);
    }

    // getDescription/getSchema/getOpenApiParameters delegate to $this->inner
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions