9.5 API: Update QueryExtension Signatures
This step updates API Platform 2.x QueryExtension method signatures to API Platform 4.x format.
When to skip this step:
Your plugin doesn't have custom QueryExtension classes
When to do this step:
You have custom QueryExtension classes implementing
QueryCollectionExtensionInterface
orQueryItemExtensionInterface
Overview of Changes
API Platform 4.x changed the method signature for QueryExtensions to pass an Operation
object instead of operation name string:
string $operationName = null
?Operation $operation = null
Check: 'shop_get' === $operationName
Check: $operation?->getName() === 'shop_get'
Key changes:
Parameter type changed from
string
to?Operation
Parameter name changed from
$operationName
to$operation
Must import
ApiPlatform\Metadata\Operation
Access operation name via
$operation?->getName()
1. Identify Files to Migrate
Check if you have QueryExtensions:
find src -name "*Extension.php" -path "*/QueryExtension/*"
find src -name "*Extension.php" -path "*/Doctrine/ORM/*"
2. Update Method Signatures
Signature Changes
For QueryCollectionExtensionInterface
:
Before (API Platform 2.x):
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
string $operationName = null,
array $context = [],
): void
After (API Platform 4.x):
use ApiPlatform\Metadata\Operation;
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void
For QueryItemExtensionInterface
:
Before (API Platform 2.x):
public function applyToItem(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
array $identifiers,
string $operationName = null,
array $context = [],
): void
After (API Platform 4.x):
use ApiPlatform\Metadata\Operation;
public function applyToItem(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
array $identifiers,
?Operation $operation = null,
array $context = [],
): void
Required changes:
Add import:
use ApiPlatform\Metadata\Operation;
Change parameter type:
string $operationName = null
→?Operation $operation = null
Update any references to
$operationName
in method body
3. Example Migrations
Example 1: Extension That Applies to All Operations
This is the simplest case - only signature needs updating.
Before (API Platform 2.x):
src/Doctrine/ORM/QueryExtension/EnabledExtension.php
:
<?php
declare(strict_types=1);
namespace Vendor\Plugin\Doctrine\ORM\QueryExtension;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\QueryBuilder;
use Vendor\Plugin\Entity\ProductInterface;
final class EnabledExtension implements QueryCollectionExtensionInterface
{
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
string $operationName = null,
array $context = [],
): void {
if (!is_a($resourceClass, ProductInterface::class, true)) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere(sprintf('%s.enabled = :enabled', $rootAlias))
->setParameter('enabled', true);
}
}
After (API Platform 4.x):
<?php
declare(strict_types=1);
namespace Vendor\Plugin\Doctrine\ORM\QueryExtension;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
use Vendor\Plugin\Entity\ProductInterface;
final class EnabledExtension implements QueryCollectionExtensionInterface
{
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
if (!is_a($resourceClass, ProductInterface::class, true)) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere(sprintf('%s.enabled = :enabled', $rootAlias))
->setParameter('enabled', true);
}
}
Changes:
Added
use ApiPlatform\Metadata\Operation;
Changed
string $operationName = null
to?Operation $operation = null
Method body unchanged (doesn't use operation name)
Example 2: Extension That Checks Operation Name
Before (API Platform 2.x):
src/Doctrine/ORM/QueryExtension/Shop/ChannelBasedExtension.php
:
<?php
declare(strict_types=1);
namespace Vendor\Plugin\Doctrine\ORM\QueryExtension\Shop;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\QueryBuilder;
use Sylius\Component\Channel\Context\ChannelContextInterface;
use Vendor\Plugin\Entity\ProductInterface;
final class ChannelBasedExtension implements QueryCollectionExtensionInterface
{
public function __construct(
private ChannelContextInterface $channelContext,
) {
}
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
string $operationName = null,
array $context = [],
): void {
if (!is_a($resourceClass, ProductInterface::class, true)) {
return;
}
// Only apply to shop operations
if ('shop_get_products' !== $operationName) {
return;
}
$channel = $this->channelContext->getChannel();
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder
->andWhere(sprintf('%s.channel = :channel', $rootAlias))
->setParameter('channel', $channel);
}
}
After (API Platform 4.x):
<?php
declare(strict_types=1);
namespace Vendor\Plugin\Doctrine\ORM\QueryExtension\Shop;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
use Sylius\Component\Channel\Context\ChannelContextInterface;
use Vendor\Plugin\Entity\ProductInterface;
final class ChannelBasedExtension implements QueryCollectionExtensionInterface
{
public function __construct(
private ChannelContextInterface $channelContext,
) {
}
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
if (!is_a($resourceClass, ProductInterface::class, true)) {
return;
}
// Only apply to shop operations
if ($operation?->getName() !== 'shop_get_products') {
return;
}
$channel = $this->channelContext->getChannel();
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder
->andWhere(sprintf('%s.channel = :channel', $rootAlias))
->setParameter('channel', $channel);
}
}
Changes:
Added
use ApiPlatform\Metadata\Operation;
Changed
string $operationName = null
to?Operation $operation = null
Changed
'shop_get_products' !== $operationName
to$operation?->getName() !== 'shop_get_products'
Example 3: Item Extension
Before (API Platform 2.x):
src/Doctrine/ORM/QueryExtension/Shop/LocaleBasedExtension.php
:
<?php
declare(strict_types=1);
namespace Vendor\Plugin\Doctrine\ORM\QueryExtension\Shop;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\QueryBuilder;
use Sylius\Component\Locale\Context\LocaleContextInterface;
use Vendor\Plugin\Entity\TranslatableInterface;
final class LocaleBasedExtension implements QueryItemExtensionInterface
{
public function __construct(
private LocaleContextInterface $localeContext,
) {
}
public function applyToItem(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
array $identifiers,
string $operationName = null,
array $context = [],
): void {
if (!is_a($resourceClass, TranslatableInterface::class, true)) {
return;
}
$locale = $this->localeContext->getLocaleCode();
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder
->innerJoin(sprintf('%s.translations', $rootAlias), 'translation')
->andWhere('translation.locale = :locale')
->setParameter('locale', $locale);
}
}
After (API Platform 4.x):
<?php
declare(strict_types=1);
namespace Vendor\Plugin\Doctrine\ORM\QueryExtension\Shop;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
use Sylius\Component\Locale\Context\LocaleContextInterface;
use Vendor\Plugin\Entity\TranslatableInterface;
final class LocaleBasedExtension implements QueryItemExtensionInterface
{
public function __construct(
private LocaleContextInterface $localeContext,
) {
}
public function applyToItem(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
array $identifiers,
?Operation $operation = null,
array $context = [],
): void {
if (!is_a($resourceClass, TranslatableInterface::class, true)) {
return;
}
$locale = $this->localeContext->getLocaleCode();
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder
->innerJoin(sprintf('%s.translations', $rootAlias), 'translation')
->andWhere('translation.locale = :locale')
->setParameter('locale', $locale);
}
}
Changes:
Added
use ApiPlatform\Metadata\Operation;
Changed
string $operationName = null
to?Operation $operation = null
Method body unchanged (doesn't use operation name)
Example 4: Extension Implementing Both Interfaces
Before (API Platform 2.x):
src/Doctrine/ORM/QueryExtension/TaxonBasedExtension.php
:
<?php
declare(strict_types=1);
namespace Vendor\Plugin\Doctrine\ORM\QueryExtension;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\QueryBuilder;
use Vendor\Plugin\Entity\ProductInterface;
final class TaxonBasedExtension implements
QueryCollectionExtensionInterface,
QueryItemExtensionInterface
{
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
string $operationName = null,
array $context = [],
): void {
$this->addWhere($queryBuilder, $resourceClass, $context);
}
public function applyToItem(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
array $identifiers,
string $operationName = null,
array $context = [],
): void {
$this->addWhere($queryBuilder, $resourceClass, $context);
}
private function addWhere(QueryBuilder $queryBuilder, string $resourceClass, array $context): void
{
if (!is_a($resourceClass, ProductInterface::class, true)) {
return;
}
$taxonCode = $context['filters']['taxon'] ?? null;
if (null === $taxonCode) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder
->innerJoin(sprintf('%s.taxons', $rootAlias), 'taxon')
->andWhere('taxon.code = :taxonCode')
->setParameter('taxonCode', $taxonCode);
}
}
After (API Platform 4.x):
<?php
declare(strict_types=1);
namespace Vendor\Plugin\Doctrine\ORM\QueryExtension;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
use Vendor\Plugin\Entity\ProductInterface;
final class TaxonBasedExtension implements
QueryCollectionExtensionInterface,
QueryItemExtensionInterface
{
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
$this->addWhere($queryBuilder, $resourceClass, $context);
}
public function applyToItem(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
array $identifiers,
?Operation $operation = null,
array $context = [],
): void {
$this->addWhere($queryBuilder, $resourceClass, $context);
}
private function addWhere(QueryBuilder $queryBuilder, string $resourceClass, array $context): void
{
if (!is_a($resourceClass, ProductInterface::class, true)) {
return;
}
$taxonCode = $context['filters']['taxon'] ?? null;
if (null === $taxonCode) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder
->innerJoin(sprintf('%s.taxons', $rootAlias), 'taxon')
->andWhere('taxon.code = :taxonCode')
->setParameter('taxonCode', $taxonCode);
}
}
Changes:
Added
use ApiPlatform\Metadata\Operation;
Changed
string $operationName = null
to?Operation $operation = null
in bothapplyToCollection()
andapplyToItem()
methodsMethod bodies unchanged (don't use operation name)
4. Common Operation Name Check Patterns
If your extension checks the operation name, update as follows:
Check if operation matches specific name:
// Before:
if ('shop_get_products' === $operationName) { }
// After:
if ($operation?->getName() === 'shop_get_products') { }
Check if operation is NOT a specific name:
// Before:
if ('admin_get_all' !== $operationName) { return; }
// After:
if ($operation?->getName() !== 'admin_get_all') { return; }
Check if operation exists:
// Before:
if (null !== $operationName) { }
// After:
if (null !== $operation) { }
Check multiple operations:
// Before:
if (in_array($operationName, ['shop_get_products', 'shop_search_products'], true)) { }
// After:
if (in_array($operation?->getName(), ['shop_get_products', 'shop_search_products'], true)) { }
5. Service Registration
No changes needed to service registration. QueryExtensions are auto-tagged.
Example service configuration (remains the same):
<service id="vendor.plugin.doctrine.orm.query_extension.enabled"
class="Vendor\Plugin\Doctrine\ORM\QueryExtension\EnabledExtension"
>
<tag name="api_platform.doctrine.orm.query_extension.collection" priority="10"/>
</service>
6. Validate Changes
Clear the cache:
vendor/bin/console cache:clear
Verify extensions are registered:
vendor/bin/console debug:container | grep query.*extension
Test API endpoints:
# Test collection endpoint
curl -X GET http://localhost/api/v2/shop/products -H "Accept: application/json"
# Test item endpoint
curl -X GET http://localhost/api/v2/shop/products/1 -H "Accept: application/json"
Important Notes
Import required: Always add
use ApiPlatform\Metadata\Operation;
Signature change:
string $operationName = null
→?Operation $operation = null
Null-safe operator: Use
$operation?->getName()
to safely access operation nameBoth methods: If implementing both interfaces, update both
applyToCollection()
andapplyToItem()
Service registration: No changes needed - extensions are auto-tagged
Backward compatible: The
= null
default maintains same behavior
Additional Operation Methods
The Operation
object provides additional useful methods:
// Get operation name
$operation?->getName()
// Get resource class
$operation?->getClass()
// Get operation short name
$operation?->getShortName()
// Check operation type
$operation instanceof \ApiPlatform\Metadata\Get
$operation instanceof \ApiPlatform\Metadata\GetCollection
Reference
For more examples, check Sylius core QueryExtensions:
vendor/sylius/sylius/src/Sylius/Bundle/ApiBundle/Doctrine/ORM/QueryExtension/
Example files:
Shop/Channel/ChannelBasedExtension.php
- Collection extension checking operationShop/Product/EnabledExtension.php
- Simple extension applying to all operationsCommon/TranslationOrderNameAndLocaleExtension.php
- Extension using both interfaces
Last updated
Was this helpful?