RFC 10008 gives HTTP a safe, cacheable method that carries a request body. Shopware 6.7 already runs on the Symfony version that supports it. Here's what that gets you for free, what a working Store API route looks like, and the one place it still falls short.
RFC 10008, published June 2026, defines QUERY: an HTTP method that's safe and cacheable like GET, but carries a request body like POST. It closes a real gap. Four things to know before you touch a route:
symfony/http-foundation ~7.4.0, which added native QUERY support: body parsing, and isMethodSafe()/isMethodCacheable() now return true for it
CacheResponseSubscriber) still hardcodes GET as the only cacheable method, so a QUERY route won't get cached by Shopware's own HTTP cache yet, even though the framework underneath it could do it
<form method="query">, but fetch() can already send it (QUERY isn't on the forbidden-method list). Treat it as an API-client method for now, not an HTML form replacement
HTTP has had this gap since the beginning. GET is safe, idempotent, and cacheable, but no reliable request body. POST has a body, but it's neither safe nor cacheable by default. Anything that needs to send a complex, structured query (think: a product search with a dozen filters, facets, and sort rules) has always had to pick one of those two trade-offs. QUERY, standardized in June 2026 as RFC 10008, is the method built to close it.
Shopware's own Store API is a textbook case of the problem. Its /store-api/search route accepts both POST and GET, on purpose, and that split exists for exactly the reason RFC 10008 describes. This guide to the HTTP QUERY method walks through what it actually specifies, confirms against Shopware's real 6.7.12.2 source how much of it already works today, builds a QUERY-capable Store API route in a plugin, and is upfront about the one part that still doesn't work: Shopware's HTTP cache layer.
QUERY sits deliberately between GET and POST. Per the RFC, a QUERY request "causes the request target to process the enclosed content in a safe and idempotent manner." In practical terms:
Symfony added native QUERY support in 7.4. Here's what actually ships in symfony/http-foundation today:
Symfony 7.4, Request.php:
public function isMethodSafe(): bool
{
return \in_array($this->getMethod(), ['GET', 'HEAD', 'OPTIONS', 'TRACE', 'QUERY'], true);
}
public function isMethodIdempotent(): bool
{
return \in_array($this->getMethod(), ['HEAD', 'GET', 'PUT', 'DELETE', 'TRACE', 'OPTIONS', 'PURGE', 'QUERY'], true);
}
public function isMethodCacheable(): bool
{
return \in_array($this->getMethod(), ['GET', 'HEAD', 'QUERY'], true);
}
Symfony also treats QUERY like PUT, DELETE, and PATCH when parsing the request: its body gets decoded and populated into the same parameter bag POST uses, not the query-string bag. And in HttpKernel\HttpCache\Store, the built-in reverse-proxy cache already hashes the request body into the cache key for QUERY, exactly as the RFC requires:
Symfony 7.4, HttpKernel/HttpCache/Store.php:
protected function generateCacheKey(Request $request): string
{
$key = $request->getUri();
if ('QUERY' === $request->getMethod()) {
// add null byte to separate the URI from the body and avoid boundary collisions
// which could lead to cache poisoning
$key .= "\0".$request->getContent();
}
return 'md'.hash('sha256', $key);
}
Where it stops: no major browser ships a native <form method="query">, and there's no built-in caching UX for it yet. QUERY isn't on the fetch spec's forbidden-method list (that's CONNECT, TRACE, TRACK), so fetch(url, { method: 'QUERY' }) already works in modern browsers today. It just doesn't get any special browser-level treatment yet. Treat QUERY as an API-client and server-to-server method for now.
This is the part most people get wrong: they assume QUERY needs a shim on top of Shopware. Check the actual composer.json shipped in the shopware/core v6.7.12.2 tag, and the framework layer is already there:
shopware/core v6.7.12.2, composer.json (excerpt):
"symfony/http-foundation": "~7.4.0",
"symfony/http-kernel": "~7.4.12",
"symfony/routing": "~7.4.12",
That means Request::METHOD_QUERY, the safe/idempotent/cacheable flags, and the QUERY-aware body parsing shown above are all present in a stock Shopware 6.7.12+ install. Nothing to patch at the framework level. Symfony already supports it. The open question is whether Shopware's own code uses it yet.
Check your own install: run composer show symfony/http-foundation in your project root. If you're below 7.4.0, either your Shopware core is older than 6.7.12, or a plugin has pinned a lower constraint. Everything in this guide needs 7.4+.
Shopware's core /store-api/search route is a real, live example of the exact tension RFC 10008 was written to close. Here's the actual ProductSearchRoute.php from the 6.7.12.2 tag:
<?php declare(strict_types=1);
namespace Shopware\Core\Content\Product\SalesChannel\Search;
#[Route(defaults: [PlatformRequest::ATTRIBUTE_ROUTE_SCOPE => [StoreApiRouteScope::ID]])]
class ProductSearchRoute extends AbstractProductSearchRoute
{
#[Route(
path: '/store-api/search',
name: 'store-api.search',
methods: [Request::METHOD_POST, Request::METHOD_GET],
defaults: [
PlatformRequest::ATTRIBUTE_ENTITY => ProductDefinition::ENTITY_NAME,
PlatformRequest::ATTRIBUTE_HTTP_CACHE => true,
]
)]
public function load(Request $request, SalesChannelContext $context, Criteria $criteria): ProductSearchRouteResponse
{
// ...
}
}
Read the methods array and the ATTRIBUTE_HTTP_CACHE => true default together and the intent is obvious. GET was added to this route specifically to make it cacheable by Shopware's reverse proxy, because CacheResponseSubscriber (more on that in section 5) only marks GET responses eligible for caching. POST stays in the array because a search with several filters, an aggregation block, and custom sorting doesn't reliably fit in a query string, and some proxies and CDNs truncate or reject long URLs.
In other words: Shopware is already running the same GET-for-cache, POST-for-payload workaround that RFC 10008 exists to retire. Core just hasn't adopted QUERY yet.
Shopware's Criteria resolution doesn't ask "is this POST?" It asks "is this GET, or not?" That distinction matters. Here's the actual branch in RequestCriteriaBuilder::handleRequest():
shopware/core, Framework/DataAbstractionLayer/Search/RequestCriteriaBuilder.php:
public function handleRequest(Request $request, Criteria $criteria, EntityDefinition $definition, Context $context): Criteria
{
if ($request->isMethod(Request::METHOD_GET)) {
// ...reads from $request->query
$criteria = $this->fromArray($request->query->all(), $criteria, $definition, $context);
} else {
// anything that isn't GET falls here, including QUERY
$criteria = $this->fromArray($request->request->all(), $criteria, $definition, $context);
}
return $criteria;
}
The else branch reads from $request->request, the same parameter bag Symfony 7.4 populates for QUERY bodies (see section 1). A QUERY request lands in that else branch automatically, because it isn't GET. No special case needed. RequestParamHelper::get(), which several multi-method Store API routes use to read individual parameters, has the same shape: check $request->query first, then fall back to $request->request. Both already work with QUERY without modification.
You can't retroactively add a method to a core route from a plugin, decorators replace service behavior, not the routing attributes on the concrete class. So this builds a new, decoratable custom route following Shopware's standard abstract-class pattern, with QUERY support from day one, the same pattern covered in detail on the plugin development side of things. It's a product search endpoint that accepts both POST (for clients that can't send QUERY yet) and QUERY (for clients that can).
<?php declare(strict_types=1);
namespace Elixent\QueryDemo\Core\Content\Product\SalesChannel;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
abstract class AbstractProductQueryRoute
{
abstract public function getDecorated(): AbstractProductQueryRoute;
abstract public function load(Criteria $criteria, SalesChannelContext $context): ProductQueryRouteResponse;
}
<?php declare(strict_types=1);
namespace Elixent\QueryDemo\Core\Content\Product\SalesChannel;
use Shopware\Core\Content\Product\Aggregate\ProductVisibility\ProductVisibilityDefinition;
use Shopware\Core\Content\Product\ProductDefinition;
use Shopware\Core\Content\Product\SalesChannel\ProductAvailableFilter;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\Plugin\Exception\DecorationPatternException;
use Shopware\Core\Framework\Routing\StoreApiRouteScope;
use Shopware\Core\PlatformRequest;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
#[Route(defaults: [PlatformRequest::ATTRIBUTE_ROUTE_SCOPE => [StoreApiRouteScope::ID]])]
class ProductQueryRoute extends AbstractProductQueryRoute
{
public function __construct(
private readonly EntityRepository $productRepository
) {
}
public function getDecorated(): AbstractProductQueryRoute
{
throw new DecorationPatternException(self::class);
}
#[Route(
path: '/store-api/product/query',
name: 'store-api.product.query',
methods: [Request::METHOD_POST, Request::METHOD_QUERY],
defaults: [PlatformRequest::ATTRIBUTE_ENTITY => ProductDefinition::ENTITY_NAME]
)]
public function load(Criteria $criteria, SalesChannelContext $context): ProductQueryRouteResponse
{
$criteria->addFilter(
new ProductAvailableFilter($context->getSalesChannelId(), ProductVisibilityDefinition::VISIBILITY_ALL)
);
$result = $this->productRepository->search($criteria, $context->getContext());
return new ProductQueryRouteResponse($result);
}
}
That's the entire change from a normal POST-only Store API route: one extra entry, Request::METHOD_QUERY, in the methods array. The Criteria $criteria argument resolves through CriteriaValueResolver and RequestCriteriaBuilder exactly as shown in section 3, no matter which of the two methods the client used.
Keep POST in the array. Corporate proxies, older CDN edge configs, and some WAF rulesets reject request methods they don't recognize. Registering both means QUERY-capable clients get the safe, cacheable semantics, and everything else still works. Drop POST once your infrastructure is verified to pass QUERY through cleanly end to end.
curl's -X flag sends any method string, including ones it doesn't recognize, so no special tooling is needed:
# Same route, POST fallback
curl https://your-shop.test/store-api/product/query \
-X POST \
-H "Content-Type: application/json" \
-H "sw-access-key: YOUR_SALES_CHANNEL_ACCESS_KEY" \
-d '{"limit": 10, "filter": [{"type": "range", "field": "price", "parameters": {"lte": 50}}]}'
# The actual QUERY method
curl https://your-shop.test/store-api/product/query \
-X QUERY \
-H "Content-Type: application/json" \
-H "sw-access-key: YOUR_SALES_CHANNEL_ACCESS_KEY" \
-d '{"limit": 10, "filter": [{"type": "range", "field": "price", "parameters": {"lte": 50}}]}'
Both return an identical response, because both land in RequestCriteriaBuilder's else branch. If you're calling this from JavaScript instead, fetch() handles it the same way a browser handles any other method:
const response = await fetch('/store-api/product/query', {
method: 'QUERY',
headers: {
'Content-Type': 'application/json',
'sw-access-key': accessKey,
},
body: JSON.stringify({ limit: 10 }),
});
Symfony's HttpCache component can already build a body-aware cache key for QUERY (section 1). That's Symfony's reverse-proxy simulator, not the layer that actually decides whether Shopware marks a response cacheable in the first place. That decision happens in CacheResponseSubscriber, and it's still hardcoded to GET:
shopware/core v6.7.12.2, Framework/Adapter/Cache/Http/CacheResponseSubscriber.php:
if (!$request->isMethod(Request::METHOD_GET)) {
$this->noCache($request, $response, $area);
return;
}
Every response to a non-GET request, POST or QUERY, gets noCache() called on it here, unconditionally, before Shopware's own cache-policy logic even runs. A QUERY route works correctly. It's just never eligible for Shopware's reverse-proxy cache, regardless of how safe or idempotent the request is.
Don't decorate this class. CacheResponseSubscriber is marked @internal in core. Internal classes sit outside Shopware's backward-compatibility promise, so a decorator built against today's implementation can silently break on the next minor release. That's not a workaround worth the maintenance risk for a single method check.
Two honest options until core catches up:
/store-api/search route shows the exact motivation for adopting it, native support in CacheResponseSubscriber is a plausible, low-risk addition for a future release. Track the Shopware changelog rather than fighting an internal class.
If a QUERY request returns a 405 or gets dropped entirely before it reaches PHP-FPM, the cause is often outside Shopware. Nginx configs commonly include a method allowlist as a hardening measure, something like:
# A common nginx hardening snippet that will silently kill QUERY requests
if ($request_method !~ ^(GET|HEAD|POST|PUT|DELETE|OPTIONS)$) {
return 444;
}
That rule predates RFC 10008 by design, it exists to reject unrecognized methods as a defensive measure, and QUERY is unrecognized to a config written before June 2026. If you've verified the plugin route and Symfony version are correct but requests still fail before hitting the application, check the nginx (or Apache, or Caddy) config and any WAF/CDN rules in front of it for a method allowlist that needs QUERY added explicitly.
RFC 10008 is new, but the infrastructure to use it inside Shopware 6.7 mostly already exists. The framework does the hard part. What's left is a one-line route change, a fallback method for clients that can't send QUERY yet, and honesty about the one gap (Shopware's own HTTP cache) that core hasn't closed.
Written by Huzaifa Mustafa
Explore more Shopware development topics, or browse the full guides library:
Whether you're building custom plugins, migrating projects, or optimizing deployments, get help from a Certified Shopware Developer.
Talk to an Engineer