You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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).
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."
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.
Sees filter as an array → merges into _api_filters
Sees page as a string → is_array() check fails → drops it
Sets _api_filters = ['custom' => 'true']
ReadProvider sees _api_filters non-null → uses as-is (skips full RequestParser::parseRequestParams)
Pagination::getPage() looks for page in filters → not found → returns
default 1
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):
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)
FilterParameterProvider
Input: Parameter with value from filter[name]=foo&filter[age]=42
Output: writes the array into _api_filters under each property key
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.
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:
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.
PageParameterProvider — same story; Laravel relies on default page/itemsPerPage handling. Bracket-form page[number] is silently
ignored.
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.
_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.
RFC: port JSON:API parameter providers to Symfony, deprecate translation listeners
Summary
Symfony still translates JSON:API
sort=,filter[...],page[...]through aninline mutation block in
ApiPlatform\JsonApi\State\JsonApiProviderplus threelegacy listeners. This is the only remaining JSON:API surface that has not been
moved to the
QueryParameter+ParameterProviderarchitecture 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 alreadysolved 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 onApiResource): 6.0 (already planned).Current state
Symfony
src/JsonApi/State/JsonApiProvider.php$request->querydirectlysort=a,-btoorderfilterfilter[...]into_api_filterspage[...]into_api_filters(bracket form only — flatpage=2anditemsPerPage=Nwere silently dropped, fixed in fix(jsonapi): merge flat page/itemsPerPage params with bracket filter #8193)fields[type]=a,b) andinclude_api_filter_property,_api_included,_api_filtersrequest attrssrc/Symfony/EventListener/JsonApi/TransformSortingParametersListener.phpsrc/Symfony/EventListener/JsonApi/TransformFilteringParametersListener.phpsrc/Symfony/EventListener/JsonApi/TransformPaginationParametersListener.php(
use_symfony_listeners: true)main_controller(default) uses providers and thelegacy controller path uses listeners
src/JsonApi/Filter/SparseFieldset.php+SparseFieldsetParameterProvider.phpParameterProviderFilterInterfaceand writes to the operation'snormalizationContext[ATTRIBUTES]Laravel
src/Laravel/JsonApi/State/JsonApiProvider.phpincludeApiPlatform\JsonApi\State\JsonApiProviderwithout the support of sort,filter and fields as these should be implemented using QueryParameters and
specific Filters."
src/Laravel/Eloquent/Filter/JsonApi/SortFilter.phpsrc/Laravel/Eloquent/Filter/JsonApi/SortFilterParameterProvider.phpsort=a,-binto['a' => 'asc', 'b' => 'desc']BuilderWhat is not there on either side
Laravel users still hand-write
#[QueryParameter(...)]on each model (seesrc/Laravel/workbench/app/Models/Book.php:60-84for the pattern with theEloquent filters).
FilterParameterProvider(filter syntax) on either side.PageParameterProvideron either side.SortFilterParameterProvider.Problem (concrete)
Bug #7888:
?filter[custom]=true&page=2returns page 1.Sequence:
JsonApiProviderreads$request->queryfilteras an array → merges into_api_filterspageas a string →is_array()check fails → drops it_api_filters = ['custom' => 'true']ReadProvidersees_api_filtersnon-null → uses as-is (skips fullRequestParser::parseRequestParams)Pagination::getPage()looks forpagein filters → not found → returnsdefault
1client receives
meta.currentPage = 1butlinks.nextreferences?page=3. Pagination de-syncs from links.Class of bug: a single writer of
_api_filterssetting partial state silentlysuppresses the
RequestParserfallback that would otherwise produce correctvalues.
Patch shipped on 4.3 papers over this by passing flat scalars through. Real
fix is to stop mutating
_api_filtersfrom an inlineprovide()block andmove translation into discrete, testable, declared
ParameterProvidersattached per operation.
_api_filtersitself stays — request attributes are the correct cross-layerpropagation channel.
Proposed design (Symfony)
New classes
All under
src/JsonApi/State/(storage-agnostic, no Doctrine dependency):SortParameterProviderParameterwith value fromsort=a,-b,c[$field => 'asc'|'desc']array into_api_filters[order_parameter_name](or into the parameter value fordownstream filter consumption)
FilterParameterProviderParameterwith value fromfilter[name]=foo&filter[age]=42_api_filtersunder each property keyPageParameterProviderParameterwith value from any of:page[number],page[size],page[offset], flatpage, flatitemsPerPage, flatpagination, flatpartialpage,itemsPerPage,pagination,partialkeys into
_api_filtersThese mirror the role of
SparseFieldsetParameterProviderand live next toit. They write to
_api_filtersexactly 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.phpDecorates
ResourceMetadataCollectionFactoryInterface. For each operation:$operation->getFormats()containsjsonapi, or no format restrictionis set and
jsonapiis 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)Wire in
src/Symfony/Bundle/Resources/config/metadata.phpand equivalent forLaravel.
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
TransformSortingParametersListenerTransformFilteringParametersListenerTransformPaginationParametersListenerMarked
@deprecatedin 5.0, removed in 6.0. The parameter providers rununder both controller paths (main controller and the legacy controller) once
they are wired as Parameters, because the Parameter pipeline runs in
ParameterProvider(insrc/State/Provider/ParameterProvider.php), which iscalled by both paths.
Migration note: users who relied on these listeners being firing-points for
custom subscribers need to subscribe to
KernelEvents::REQUESTdirectly ormove their logic into a ParameterProvider.
Laravel gaps
Laravel already has the architecture but is missing:
FilterParameterProvider— there is no equivalent offilter[name]=fooparsing. 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.PageParameterProvider— same story; Laravel relies on defaultpage/itemsPerPagehandling. Bracket-formpage[number]is silentlyignored.
Auto-attach metadata factory equivalent. Today every Laravel model
declares its filters manually; even the JSON:API-specific
SortFilterisonly present when the user writes
new QueryParameter(filter: SortFilter::class).Recommendation: add the same
JsonApiParameterResourceMetadataCollectionFactoryin
src/Laravel/JsonApi/and the same three providers undersrc/Laravel/JsonApi/State/. They can share interfaces with Symfony — onlythe 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.0Already planned. Once parameter providers are the way, the legacy
#[ApiResource(filters: ['my.filter.service.id'])]shorthand is fullyredundant — every legacy filter has a
QueryParameterequivalent. TheFiltersResourceMetadataCollectionFactory(
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
BC considerations
_api_filterskeeps its current shape. No change for downstream consumers(
PropertyFilter,GroupFilter, Doctrine extensions).QueryParameters on the same keys (sort,filter,page,fields,include) take precedence over auto-attached defaults. This isthe same contract the existing
ParameterResourceMetadataCollectionFactoryexpects.
trigger_deprecation()notice.Out of scope
_api_filters. Decided not to. Request attributes are the correctpropagation channel; the bug was about who writes them, not whether they
exist.
(
OrderFilter,SearchFilter, etc.) already accept the array shape thenew providers will produce.
References
src/JsonApi/Filter/SparseFieldset.phpsrc/Laravel/Eloquent/Filter/JsonApi/SortFilter.php+SortFilterParameterProvider.php