Skip to content

RFC: port JSON:API parameter providers to Symfony, deprecate translation listeners #8194

@soyuka

Description

@soyuka

RFC: port JSON:API parameter providers to Symfony, deprecate translation listeners

Summary

Symfony still translates JSON:API sort=, filter[...], page[...] through an
inline mutation block in ApiPlatform\JsonApi\State\JsonApiProvider plus three
legacy listeners. This is the only remaining JSON:API surface that has not been
moved to the QueryParameter + ParameterProvider architecture used elsewhere.
The mutation-based approach is brittle (see #7888, fixed by #8193 on 4.3),
ships partial state into _api_filters, and duplicates logic that is already
solved in Laravel for the same protocol.

This RFC proposes porting the missing pieces to Symfony, auto-attaching them
as default Parameters on JSON:API operations through a metadata factory, and
deprecating the three legacy listeners.

Target branch: main (4.4 / 5.0). Listener removal: 5.0. Final cleanup
(filters: [...] shorthand on ApiResource): 6.0 (already planned).

Current state

Symfony

  • src/JsonApi/State/JsonApiProvider.php

    • Reads $request->query directly
    • Translates sort=a,-b to order filter
    • Merges filter[...] into _api_filters
    • Merges page[...] into _api_filters (bracket form only — flat
      page=2 and itemsPerPage=N were silently dropped, fixed in fix(jsonapi): merge flat page/itemsPerPage params with bracket filter #8193)
    • Computes sparse fieldset (fields[type]=a,b) and include
    • Sets _api_filter_property, _api_included, _api_filters request attrs
  • src/Symfony/EventListener/JsonApi/TransformSortingParametersListener.php

  • src/Symfony/EventListener/JsonApi/TransformFilteringParametersListener.php

  • src/Symfony/EventListener/JsonApi/TransformPaginationParametersListener.php

    • Same translation logic, used on the legacy listener-based controller path
      (use_symfony_listeners: true)
    • Both paths exist because main_controller (default) uses providers and the
      legacy controller path uses listeners
  • src/JsonApi/Filter/SparseFieldset.php + SparseFieldsetParameterProvider.php

    • The only JSON:API piece already on the new architecture
    • Implements ParameterProviderFilterInterface and writes to the operation's
      normalizationContext[ATTRIBUTES]
    • Proves the pattern is viable

Laravel

  • src/Laravel/JsonApi/State/JsonApiProvider.php

    • Stripped down: only handles include
    • Docstring explicitly states: "This is a copy of
      ApiPlatform\JsonApi\State\JsonApiProvider without the support of sort,
      filter and fields as these should be implemented using QueryParameters and
      specific Filters."
  • src/Laravel/Eloquent/Filter/JsonApi/SortFilter.php

  • src/Laravel/Eloquent/Filter/JsonApi/SortFilterParameterProvider.php

    • Provider parses sort=a,-b into ['a' => 'asc', 'b' => 'desc']
    • Filter applies the array to an Eloquent Builder

What is not there on either side

  • An automatic way to declare these parameters on every JSON:API resource. On
    Laravel users still hand-write #[QueryParameter(...)] on each model (see
    src/Laravel/workbench/app/Models/Book.php:60-84 for the pattern with the
    Eloquent filters).
  • No FilterParameterProvider (filter syntax) on either side.
  • No PageParameterProvider on either side.
  • Symfony has no JSON:API equivalent of SortFilterParameterProvider.

Problem (concrete)

Bug #7888: ?filter[custom]=true&page=2 returns page 1.

Sequence:

  1. JsonApiProvider reads $request->query
  2. Sees filter as an array → merges into _api_filters
  3. Sees page as a string → is_array() check fails → drops it
  4. Sets _api_filters = ['custom' => 'true']
  5. ReadProvider sees _api_filters non-null → uses as-is (skips full
    RequestParser::parseRequestParams)
  6. Pagination::getPage() looks for page in filters → not found → returns
    default 1
  7. Hydra link generation advertises the user-requested page in URLs, so the
    client receives meta.currentPage = 1 but links.next references
    ?page=3. Pagination de-syncs from links.

Class of bug: a single writer of _api_filters setting partial state silently
suppresses the RequestParser fallback that would otherwise produce correct
values.

Patch shipped on 4.3 papers over this by passing flat scalars through. Real
fix is to stop mutating _api_filters from an inline provide() block and
move translation into discrete, testable, declared ParameterProviders
attached per operation.

_api_filters itself stays — request attributes are the correct cross-layer
propagation channel.

Proposed design (Symfony)

New classes

All under src/JsonApi/State/ (storage-agnostic, no Doctrine dependency):

  1. SortParameterProvider

    • Input: Parameter with value from sort=a,-b,c
    • Output: writes structured [$field => 'asc'|'desc'] array into
      _api_filters[order_parameter_name] (or into the parameter value for
      downstream filter consumption)
  2. FilterParameterProvider

    • Input: Parameter with value from filter[name]=foo&filter[age]=42
    • Output: writes the array into _api_filters under each property key
  3. PageParameterProvider

    • Input: Parameter with value from any of:
      page[number], page[size], page[offset], flat page, flat
      itemsPerPage, flat pagination, flat partial
    • Output: writes canonical page, itemsPerPage, pagination, partial
      keys into _api_filters
    • Subsumes the 4.3 patch's scalar passthrough

These mirror the role of SparseFieldsetParameterProvider and live next to
it. They write to _api_filters exactly like the current translation block,
so the rest of the read pipeline (Pagination, Doctrine filters,
PropertyFilter, GroupFilter) is unchanged.

Auto-attach metadata factory

src/JsonApi/Metadata/Resource/Factory/JsonApiParameterResourceMetadataCollectionFactory.php

Decorates ResourceMetadataCollectionFactoryInterface. For each operation:

  • If $operation->getFormats() contains jsonapi, or no format restriction
    is set and jsonapi is globally enabled, attach as defaults:
    • new QueryParameter(key: 'sort', provider: SortParameterProvider::class)
    • new QueryParameter(key: 'filter', provider: FilterParameterProvider::class)
    • new QueryParameter(key: 'page', provider: PageParameterProvider::class)
    • new QueryParameter(key: 'fields', provider: SparseFieldsetParameterProvider::class)
      (replaces inline transformFieldsetsParameters())
    • new QueryParameter(key: 'include', provider: IncludeParameterProvider::class)
      (replaces remaining inline block — last thing to leave JsonApiProvider)
  • User-declared parameters on the same key win (no override)

Wire in src/Symfony/Bundle/Resources/config/metadata.php and equivalent for
Laravel.

JsonApiProvider becomes a no-op

After the factory does its job, the Symfony JsonApiProvider::provide()
contains nothing — the file can be removed in 5.0 (or kept as a deprecation
shim).

Listener deprecation

  • TransformSortingParametersListener
  • TransformFilteringParametersListener
  • TransformPaginationParametersListener

Marked @deprecated in 5.0, removed in 6.0. The parameter providers run
under both controller paths (main controller and the legacy controller) once
they are wired as Parameters, because the Parameter pipeline runs in
ParameterProvider (in src/State/Provider/ParameterProvider.php), which is
called by both paths.

Migration note: users who relied on these listeners being firing-points for
custom subscribers need to subscribe to KernelEvents::REQUEST directly or
move their logic into a ParameterProvider.

Laravel gaps

Laravel already has the architecture but is missing:

  1. FilterParameterProvider — there is no equivalent of filter[name]=foo
    parsing. Users must declare each filter as a discrete QueryParameter
    (#[QueryParameter(key: 'name', filter: PartialSearchFilter::class)]).
    That works but is not JSON:API spec compliant on the client side, which
    sends ?filter[name]=foo, not ?name=foo.

  2. PageParameterProvider — same story; Laravel relies on default
    page/itemsPerPage handling. Bracket-form page[number] is silently
    ignored.

  3. Auto-attach metadata factory equivalent. Today every Laravel model
    declares its filters manually; even the JSON:API-specific SortFilter is
    only present when the user writes
    new QueryParameter(filter: SortFilter::class).

Recommendation: add the same JsonApiParameterResourceMetadataCollectionFactory
in src/Laravel/JsonApi/ and the same three providers under
src/Laravel/JsonApi/State/. They can share interfaces with Symfony — only
the apply-step (Doctrine vs Eloquent) differs, and the apply-step is the
filter classes, not the parameter providers. The providers themselves should
be 100% shared in src/JsonApi/State/ if possible (no storage dependency).

filters: [...] removal in 6.0

Already planned. Once parameter providers are the way, the legacy
#[ApiResource(filters: ['my.filter.service.id'])] shorthand is fully
redundant — every legacy filter has a QueryParameter equivalent. The
FiltersResourceMetadataCollectionFactory
(src/Metadata/Resource/Factory/FiltersResourceMetadataCollectionFactory.php)
that resolves service ids can be deleted with the array shorthand.

This RFC is a prerequisite: as long as JSON:API translation lives outside
the Parameter system, the filters: [...] array still has a role.

File-level plan

+ src/JsonApi/State/SortParameterProvider.php
+ src/JsonApi/State/FilterParameterProvider.php
+ src/JsonApi/State/PageParameterProvider.php
+ src/JsonApi/State/IncludeParameterProvider.php
+ src/JsonApi/Metadata/Resource/Factory/JsonApiParameterResourceMetadataCollectionFactory.php
~ src/JsonApi/State/JsonApiProvider.php                                          # shrink, then deprecate
~ src/Symfony/EventListener/JsonApi/TransformSortingParametersListener.php       # @deprecated
~ src/Symfony/EventListener/JsonApi/TransformFilteringParametersListener.php     # @deprecated
~ src/Symfony/EventListener/JsonApi/TransformPaginationParametersListener.php    # @deprecated
~ src/Symfony/Bundle/Resources/config/state/jsonapi.php                          # wire new factory
+ src/Laravel/JsonApi/State/FilterParameterProvider.php                          # parity
+ src/Laravel/JsonApi/State/PageParameterProvider.php                            # parity
~ src/Laravel/JsonApi/State/JsonApiProvider.php                                  # may become no-op
~ src/Laravel/ApiPlatformDeferredProvider.php                                    # register new providers + factory

BC considerations

  • _api_filters keeps its current shape. No change for downstream consumers
    (PropertyFilter, GroupFilter, Doctrine extensions).
  • User-declared QueryParameters on the same keys (sort, filter, page,
    fields, include) take precedence over auto-attached defaults. This is
    the same contract the existing ParameterResourceMetadataCollectionFactory
    expects.
  • Listeners stay functional in 5.0 with a trigger_deprecation() notice.

Out of scope

  • Removing _api_filters. Decided not to. Request attributes are the correct
    propagation channel; the bug was about who writes them, not whether they
    exist
    .
  • Rewriting Doctrine ORM filters for JSON:API. Existing filters
    (OrderFilter, SearchFilter, etc.) already accept the array shape the
    new providers will produce.
  • GraphQL parity. JSON:API is a REST-only concern.

References

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    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