Skip to content

feat(state): apply uri variable provider values - #8491

Open
soyuka wants to merge 2 commits into
api-platform:mainfrom
soyuka:feat/uri-variable-parameter-provider
Open

feat(state): apply uri variable provider values#8491
soyuka wants to merge 2 commits into
api-platform:mainfrom
soyuka:feat/uri-variable-parameter-provider

Conversation

@soyuka

@soyuka soyuka commented Sep 1, 2026

Copy link
Copy Markdown
Member
Q A
Branch? main
Tickets Alternative to #8431
License MIT
Doc PR todo

Alternative approach to #8431, which adds the current parameterName to the UriVariableTransformerInterface context so a transformer can tell which uri variable it is transforming.

The use case there (base64-encoded uri variable that must be decoded before querying) should not need a global transformer service at all: a uri variable is a Parameter, so it can already carry its own provider. That mechanism is already invoked for uri variables — it just has no effect on the query.

The bug

ParameterProvider::handlePathParameters() calls the provider, but received $uriVariables by value and returned only the Operation. The array forwarded to $this->decorated->provide($operation, $uriVariables, $context) — and from there to Doctrine\Orm\State\LinksHandlerTrait::handleLinks() — kept the raw route value. So a provider could mutate the parameter, and the query ignored it.

The value was observable via $operation->getUriVariables()['x']->getValue() (see the existing LinkParameterProviderResource fixture), which is why this went unnoticed: it only bites when Doctrine does the querying.

What this does

Writes the resolved value back, so this works:

#[Get(
    uriTemplate: '/blips/{targetClass}',
    uriVariables: [
        'targetClass' => new Link(
            fromClass: self::class,
            identifiers: ['name'],
            provider: [self::class, 'decodeName'],
        ),
    ],
)]
class Blip
{
    public static function decodeName(Parameter $parameter, array $parameters = [], array $context = []): void
    {
        $parameter->setValue(base64_decode((string) $parameter->getValue(), true));
    }
}

Per-parameter and scoped by declaration — no supportsTransformation() guessing, and no need to know the parameter name, because the provider is attached to it.

PreservesUriVariableInterface

One exclusion is needed. For a uri variable the value slot has two consumers that want different things:

  • LinksHandlerTrait::handleLinks() wants the scalar identifier (WHERE name = :x)
  • SecurityParameterProvider reads $parameter->getValue() as the object for security: expressions

ReadLinkParameterProvider deliberately puts a hydrated resource in the value for the second consumer, and that must not become the identifier. This cannot be detected by inspecting the value: Uuid, Ulid and DateTime are all legitimate uri-variable identifiers, so "is it an object?" is not a test.

So the provider declares its intent. ReadLinkParameterProvider implements the new marker PreservesUriVariableInterface; anything else is assumed to transform its value.

The polarity is deliberate — transform is the default, resolvers opt out. A positive/opt-in marker would exclude static callable providers (provider: [self::class, 'decodeName']), which cannot implement an interface.

Note this leaves the ?dummy=1 query parameter case untouched: there the hydrated entity in the value slot is correct and there is no competing consumer.

Listeners mode

The write-back alone fixed only the default stack. In listeners mode api_platform.state_provider.parameter was registered with null as its decorated inner and called separately by ReadListener:

$this->parameterProvider?->provide($operation, $uriVariables, $context);
$this->provider->provide($operation, $uriVariables, $context);

Two independent calls with the listener's own array, and ProviderInterface::provide() takes $uriVariables by value — so nothing could propagate. ParameterProvider now decorates the read chain in both modes (at -10, outermost; access checkers sit at 0, ParameterValidatorProvider at 110), preserving the previous call ordering.

Side effect: ReadListener also used to discard the Operation returned by ParameterProvider and hand ReadProvider a stale one. Going through the chain fixes that too.

BC

Targeting main (5.0) rather than 4.4 on purpose. A user-written provider that hydrates entities the way ReadLinkParameterProvider does, without implementing the marker, will now feed an object into its query — silent on a minor, documented on a major. Needs an upgrade note.

ReadListener's unused 4th constructor argument is removed (no in-tree call site passed it).

Tests

tests/Functional/Parameters/UriVariableParameterProviderTest.php reproduces the use case with a Doctrine-backed resource; it 404s before the fix (query runs with QmxpcA==) and passes after.

Verified locally with and without USE_SYMFONY_LISTENERS=1: UriVariableParameterProviderTest, LinkProviderParameterTest, SecurityTest, ParameterProviderTest, ValidationTest (38 each), plus ReadListenerTest. php-cs-fixer and PHPStan clean. Broader regression scope left to CI.

Open for review

  • The interface name and its placement in ApiPlatform\State\ParameterProvider.
  • Whether the write-back should also update _api_uri_variables. It currently does not, in either mode, so generated IRIs keep the original (encoded) value and still round-trip to the same URL.

A parameter provider declared on a uri variable ran, but its value was
discarded: handlePathParameters() received $uriVariables by value and
only returned the Operation, so the array forwarded to the Doctrine
links handler kept the raw route value. Transforming a uri variable was
therefore only possible through a global UriVariableTransformerInterface
service, which cannot tell which variable it is transforming.

Write the resolved value back so uriVariables: ['x' => new Link(provider:
...)] transforms the value used to query the resource, as QueryParameter
already does for filters.

ReadLinkParameterProvider is excluded: it sets the value to a hydrated
resource for security expressions, which must not reach the query as an
identifier. It declares this via PreservesUriVariableInterface, so user
providers doing the same can opt out too. Providers are otherwise assumed
to transform their value, which keeps callable providers working.

In listeners mode ParameterProvider was wired standalone with no
decorated inner and called separately by ReadListener, so a write-back
could not cross the two calls. It now decorates the read chain in both
modes, which also stops ReadListener from handing ReadProvider a stale
Operation.
private readonly ProviderInterface $provider,
?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null,
?UriVariablesConverterInterface $uriVariablesConverter = null,
private readonly ?ProviderInterface $parameterProvider = null,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to keep this for BC layer and deprecate adding this argument

@soyuka

soyuka commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

Follow-up for the Doctrine/Eloquent side of this: #8494.

Since the last review pass, two changes:

ReadLinkParameterProvider can now write the uri variable, opt-in. Working on the resolved resource is usually easier when writing a custom provider, so it is available per service through the constructor, or per link through the write_uri_variable extra property:

uriVariables: [
    'id' => new Link(
        provider: ReadLinkParameterProvider::class,
        fromClass: Dummy::class,
        extraProperties: ['write_uri_variable' => true],
    ),
]

PreservesUriVariableInterface therefore carries a method instead of being a pure marker, so the decision can depend on the parameter. The default is unchanged — the resource stays out of the uri variables.

This works for resources whose provider does not hit a persistence layer. Enabling it on a Doctrine-backed link still fails (Object of class Company could not be converted to string), which is what #8494 covers.

ReadListener's 4th constructor argument is restored. The class is public, so removing it breaks the BC promise even though the bundle no longer passes it. It is unpromoted and documented as unused.

Note on verification: local functional runs are currently blocked by an unrelated environment issue (a concurrent git worktree whose testbench laravel/vendor symlinks to its own parent, which makes any recursive scan of the repo loop — it breaks cache:warmup, so PHPStan cannot run either). The earlier state was verified green in both modes; these two commits are verified only by php-cs-fixer, so the CI result is the one to trust here.

The resolved resource is kept out of the uri variables by default because
Doctrine queries the resource with an identifier. A custom provider is
often easier to write against the resource itself, so allow opting in:
per service through the ReadLinkParameterProvider constructor, or per
link through the `write_uri_variable` extra property.

PreservesUriVariableInterface therefore carries a method rather than
being a pure marker, so the decision can depend on the parameter.

Opting in on a Doctrine-backed link still fails, as the links handler
binds the identifier with an explicit type; making getIdentifierValue()
resource aware is tracked separately.

Also restore ReadListener's $parameterProvider argument. The class is
public, so removing it breaks the BC promise even though the bundle no
longer passes it. Passing it now triggers a deprecation, it will be
removed in 6.0.
@soyuka
soyuka force-pushed the feat/uri-variable-parameter-provider branch from 1ceaa6c to 1a36c46 Compare September 2, 2026 11:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant