(G/09) API-Entwicklung

Die HTTP QUERY Methode in Shopware 6

RFC 10008 gibt HTTP eine sichere, cachefähige Methode mit Request-Body. Shopware 6.7 läuft bereits auf der Symfony-Version, die sie unterstützt. Was das bringt, wie eine funktionierende Store-API-Route aussieht, und wo es noch hakt.

Von Huzaifa Mustafa 10 Min. Lesezeit 30. Juli 2026

Kurzantwort

RFC 10008, veröffentlicht im Juni 2026, definiert QUERY: eine HTTP-Methode, die wie GET sicher und cachefähig ist, aber wie POST einen Request-Body trägt. Sie schließt eine echte Lücke. Vier Dinge, bevor Sie eine Route anfassen:

  • Shopware 6.7.12+ setzt bereits symfony/http-foundation ~7.4.0 voraus, das native QUERY-Unterstützung mitbringt: Body-Parsing, und isMethodSafe()/isMethodCacheable() geben dafür jetzt true zurück
  • Shopwares eigener Criteria-Resolver prüft bereits "ist das GET, oder nicht" statt "ist das POST." Das heißt: QUERY zu einer Store-API-Route hinzuzufügen braucht eine Zeile, kein Body-Parsing-Workaround
  • Shopwares Reverse-Proxy-Cache-Schicht (CacheResponseSubscriber) prüft weiterhin nur GET als cachefähige Methode. Eine QUERY-Route wird von Shopwares eigenem HTTP-Cache also noch nicht gecacht, obwohl das darunterliegende Framework es könnte
  • Kein Browser bietet ein <form method="query">, aber fetch() kann es schon senden (QUERY steht nicht auf der Liste verbotener Methoden). Behandeln Sie es vorerst als API-Client-Methode, nicht als HTML-Formular-Ersatz

HTTP hat diese Lücke seit Anfang an. GET ist sicher, idempotent und cachefähig, aber ohne verlässlichen Request-Body. POST hat einen Body, ist aber standardmäßig weder sicher noch cachefähig. Alles, was eine komplexe, strukturierte Anfrage senden muss (etwa eine Produktsuche mit einem Dutzend Filtern, Facetten und Sortierregeln), musste sich bisher für einen dieser beiden Kompromisse entscheiden. QUERY, im Juni 2026 als RFC 10008 standardisiert, ist die Methode, die genau das beheben soll.

Shopwares eigene Store API ist ein Lehrbuchbeispiel für das Problem. Die Route /store-api/search akzeptiert absichtlich sowohl POST als auch GET, und genau dieser Split existiert aus dem Grund, den RFC 10008 beschreibt. Diese Anleitung zur HTTP QUERY Methode geht durch, was sie tatsächlich spezifiziert, prüft anhand des echten Shopware-6.7.12.2-Quellcodes, wie viel davon heute schon funktioniert, baut eine QUERY-fähige Store-API-Route in einem Plugin, und ist ehrlich zu dem einen Punkt, der noch nicht funktioniert: Shopwares HTTP-Cache-Schicht.

1. Was RFC 10008 tatsächlich festlegt

Sicher, idempotent, cachefähig, mit Body

QUERY liegt bewusst zwischen GET und POST. Laut RFC "veranlasst" eine QUERY-Anfrage "das Ziel, den enthaltenen Inhalt sicher und idempotent zu verarbeiten". Praktisch bedeutet das:

  • Sicher: eine QUERY-Anfrage darf den Serverzustand nicht verändern. Gleiche Garantie wie bei GET
  • Idempotent: zweimal senden hat denselben Effekt wie einmal. Nach einem Timeout gefahrlos wiederholbar
  • Cachefähig: ein Cache darf die Antwort speichern und wiederverwenden, der RFC verlangt aber, dass der Cache-Key den Request-Inhalt einbezieht, nicht nur die URI, da zwei QUERY-Anfragen an denselben Pfad völlig unterschiedliche Bodies tragen können
  • Body-fähig: anders als GET hat eine QUERY-Anfrage einen definierten, strukturierten Body, meist JSON, genau richtig für verschachtelte Filter-/Sortier-/Aggregations-Payloads, die einen Query-String nicht überleben würden

Wie Symfony 7.4 es umsetzt

Symfony hat native QUERY-Unterstützung mit Version 7.4 eingeführt. Das wird tatsächlich in symfony/http-foundation ausgeliefert:

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 behandelt QUERY beim Parsen der Anfrage auch wie PUT, DELETE und PATCH: der Body wird dekodiert und landet in derselben Parameter-Bag wie bei POST, nicht in der Query-String-Bag. Und in HttpKernel\HttpCache\Store hasht der eingebaute Reverse-Proxy-Cache-Simulator den Request-Body bereits in den Cache-Key für QUERY ein, genau wie es der RFC verlangt:

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);
}

Wo es endet: kein größerer Browser bietet ein natives <form method="query">, und es gibt noch keine eingebaute Caching-UX dafür. QUERY steht nicht auf der Liste der verbotenen Methoden der Fetch-Spezifikation (das sind CONNECT, TRACE, TRACK), also funktioniert fetch(url, { method: 'QUERY' }) schon heute in modernen Browsern. Nur eben ohne besondere Browser-seitige Behandlung. Behandeln Sie QUERY vorerst als API-Client- und Server-zu-Server-Methode.

2. Shopware 6.7.12+ liefert bereits Symfony 7.4 aus

Das wird oft falsch angenommen: dass QUERY einen Shim über Shopware braucht. Ein Blick in das tatsächliche composer.json im shopware/core-Tag v6.7.12.2 zeigt: die Framework-Schicht ist schon da.

shopware/core v6.7.12.2, composer.json (Auszug):

"symfony/http-foundation": "~7.4.0",
"symfony/http-kernel": "~7.4.12",
"symfony/routing": "~7.4.12",

Das heißt: Request::METHOD_QUERY, die Safe-/Idempotent-/Cacheable-Flags und das QUERY-fähige Body-Parsing von oben sind in einer Standard-Shopware-6.7.12+-Installation bereits vorhanden. Auf Framework-Ebene ist nichts zu patchen. Symfony unterstützt es bereits. Offen ist nur, ob Shopwares eigener Code es auch nutzt.

Eigene Installation prüfen: Führen Sie composer show symfony/http-foundation im Projektstammverzeichnis aus. Liegt die Version unter 7.4.0, ist entweder Ihr Shopware-Core älter als 6.7.12, oder ein Plugin hat eine niedrigere Version fixiert. Alles in dieser Anleitung braucht 7.4+.

3. Wo Shopwares Store API den alten GET/POST-Split noch zeigt

Shopwares Core-Route /store-api/search ist ein reales, lebendes Beispiel für genau die Spannung, die RFC 10008 auflösen soll. Hier die tatsächliche ProductSearchRoute.php aus dem Tag 6.7.12.2:

<?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
    {
        // ...
    }
}

Betrachtet man das methods-Array zusammen mit dem Default ATTRIBUTE_HTTP_CACHE => true, ist die Absicht klar. GET wurde dieser Route gezielt hinzugefügt, um sie über Shopwares Reverse-Proxy cachefähig zu machen, denn CacheResponseSubscriber (mehr dazu in Abschnitt 5) markiert nur GET-Antworten als cache-berechtigt. POST bleibt im Array, weil eine Suche mit mehreren Filtern, einem Aggregationsblock und individueller Sortierung nicht zuverlässig in einen Query-String passt, und manche Proxies und CDNs lange URLs kürzen oder ablehnen.

Mit anderen Worten: Shopware fährt bereits denselben GET-für-Cache-POST-für-Payload-Workaround, den RFC 10008 ablösen soll. Core hat QUERY nur noch nicht übernommen.

Warum QUERY zur eigenen Route hinzuzufügen weniger Aufwand ist als gedacht

Shopwares Criteria-Auflösung fragt nicht "ist das POST?" Sie fragt "ist das GET, oder nicht?" Diese Unterscheidung ist entscheidend. Hier der tatsächliche Zweig 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)) {
        // ...liest aus $request->query
        $criteria = $this->fromArray($request->query->all(), $criteria, $definition, $context);
    } else {
        // alles, was nicht GET ist, landet hier, auch QUERY
        $criteria = $this->fromArray($request->request->all(), $criteria, $definition, $context);
    }

    return $criteria;
}

Der else-Zweig liest aus $request->request, derselben Parameter-Bag, die Symfony 7.4 für QUERY-Bodies befüllt (siehe Abschnitt 1). Eine QUERY-Anfrage landet automatisch in diesem else-Zweig, weil sie kein GET ist. Kein Sonderfall nötig. RequestParamHelper::get(), das mehrere Multi-Methoden-Store-API-Routen zum Auslesen einzelner Parameter nutzen, hat dieselbe Form: erst $request->query prüfen, dann auf $request->request zurückfallen. Beide funktionieren bereits ohne Änderung mit QUERY.

4. QUERY zu einer Store-API-Route im Plugin hinzufügen

Eine Core-Route lässt sich nicht nachträglich um eine Methode erweitern, Dekoratoren ersetzen Service-Verhalten, nicht die Routing-Attribute der konkreten Klasse. Deshalb entsteht hier eine neue, dekorierbare Custom-Route nach Shopwares Standard-Abstract-Class-Pattern, dasselbe Muster, das auch bei der Plugin-Entwicklung zum Einsatz kommt, von Anfang an mit QUERY-Unterstützung. Es ist ein Produktsuch-Endpunkt, der sowohl POST (für Clients, die noch kein QUERY senden können) als auch QUERY (für Clients, die es können) akzeptiert.

Die abstrakte Klasse

<?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;
}

Die konkrete Route

<?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);
    }
}

Das ist die gesamte Änderung gegenüber einer normalen POST-only-Store-API-Route: ein zusätzlicher Eintrag, Request::METHOD_QUERY, im methods-Array. Das Argument Criteria $criteria wird über CriteriaValueResolver und RequestCriteriaBuilder aufgelöst, genau wie in Abschnitt 3 gezeigt, unabhängig davon, welche der beiden Methoden der Client verwendet hat.

POST im Array lassen. Firmen-Proxys, ältere CDN-Edge-Konfigurationen und manche WAF-Regelsätze lehnen Request-Methoden ab, die sie nicht kennen. Beide zu registrieren bedeutet: QUERY-fähige Clients bekommen die sicheren, cachefähigen Semantiken, und alles andere funktioniert weiterhin. POST kann entfallen, sobald geprüft ist, dass die eigene Infrastruktur QUERY end-to-end sauber durchreicht.

Testen

Das -X-Flag von curl sendet jede beliebige Methode, auch unbekannte, spezielles Tooling ist nicht nötig:

# Gleiche Route, POST-Fallback
curl https://ihr-shop.test/store-api/product/query \
  -X POST \
  -H "Content-Type: application/json" \
  -H "sw-access-key: IHR_SALES_CHANNEL_ACCESS_KEY" \
  -d '{"limit": 10, "filter": [{"type": "range", "field": "price", "parameters": {"lte": 50}}]}'

# Die tatsächliche QUERY-Methode
curl https://ihr-shop.test/store-api/product/query \
  -X QUERY \
  -H "Content-Type: application/json" \
  -H "sw-access-key: IHR_SALES_CHANNEL_ACCESS_KEY" \
  -d '{"limit": 10, "filter": [{"type": "range", "field": "price", "parameters": {"lte": 50}}]}'

Beide liefern eine identische Antwort, weil beide im else-Zweig von RequestCriteriaBuilder landen. Wird das aus JavaScript aufgerufen, behandelt fetch() es wie jede andere Methode auch:

const response = await fetch('/store-api/product/query', {
    method: 'QUERY',
    headers: {
        'Content-Type': 'application/json',
        'sw-access-key': accessKey,
    },
    body: JSON.stringify({ limit: 10 }),
});

5. Was noch nicht funktioniert: Shopwares HTTP-Cache

Symfonys HttpCache-Komponente kann bereits einen body-bewussten Cache-Key für QUERY bilden (Abschnitt 1). Das ist Symfonys Reverse-Proxy-Simulator, nicht die Schicht, die tatsächlich entscheidet, ob Shopware eine Antwort überhaupt als cachefähig markiert. Diese Entscheidung fällt in CacheResponseSubscriber, und die ist weiterhin fest auf GET verdrahtet:

shopware/core v6.7.12.2, Framework/Adapter/Cache/Http/CacheResponseSubscriber.php:

if (!$request->isMethod(Request::METHOD_GET)) {
    $this->noCache($request, $response, $area);

    return;
}

Jede Antwort auf eine Nicht-GET-Anfrage, ob POST oder QUERY, bekommt hier unbedingt noCache(), noch bevor Shopwares eigene Cache-Policy-Logik überhaupt läuft. Eine QUERY-Route funktioniert korrekt. Sie ist nur nie für Shopwares Reverse-Proxy-Cache berechtigt, egal wie sicher oder idempotent die Anfrage ist.

Diese Klasse nicht dekorieren. CacheResponseSubscriber ist im Core als @internal markiert. Interne Klassen stehen außerhalb von Shopwares Abwärtskompatibilitätszusage, ein Dekorator gegen die heutige Implementierung kann beim nächsten Minor-Release stillschweigend brechen. Das Risiko lohnt sich nicht für eine einzelne Methodenprüfung.

Zwei ehrliche Optionen, bis Core nachzieht:

  • Stattdessen am Edge cachen. Muss die Antwort heute wirklich gecacht werden, konfigurieren Sie CDN oder Reverse-Proxy (Varnish, Fastly, Cloudflare) so, dass für QUERY-Anfragen an diese spezifische Route der Request-Body in den eigenen Cache-Key einfließt, unabhängig von Shopwares interner Cache-Schicht.
  • Auf Core warten. Da Shopware bereits die Symfony-Version ausliefert, die QUERY unterstützt, und die eigene /store-api/search-Route genau die Motivation dafür zeigt, ist native Unterstützung in CacheResponseSubscriber eine naheliegende, risikoarme Ergänzung für ein künftiges Release. Verfolgen Sie den Shopware-Changelog, statt gegen eine interne Klasse anzukämpfen.

6. Erst den Webserver prüfen, bevor Shopware verdächtigt wird

Kommt eine QUERY-Anfrage mit 405 zurück oder wird sie schon vor PHP-FPM verworfen, liegt die Ursache oft außerhalb von Shopware. Nginx-Konfigurationen enthalten häufig eine Methoden-Allowlist als Härtungsmaßnahme, etwa so:

# Ein verbreiteter Nginx-Härtungs-Snippet, der QUERY-Anfragen stillschweigend killt
if ($request_method !~ ^(GET|HEAD|POST|PUT|DELETE|OPTIONS)$) {
    return 444;
}

Diese Regel ist älter als RFC 10008, sie soll unbekannte Methoden als Schutzmaßnahme ablehnen, und QUERY ist einer Konfiguration von vor Juni 2026 schlicht unbekannt. Sind Plugin-Route und Symfony-Version geprüft, aber Anfragen scheitern trotzdem vor der Anwendung, prüfen Sie die Nginx- (oder Apache-, oder Caddy-)Konfiguration sowie WAF-/CDN-Regeln davor auf eine Methoden-Allowlist, die QUERY explizit ergänzt werden muss.

Statusprüfung

Zusammenfassung: Was heute bereit ist

Funktioniert sofort

  • Symfony-7.4-QUERY-Body-Parsing
  • isMethodSafe/Idempotent/Cacheable
  • Criteria- und Parameter-Auflösung
  • fetch() aus modernen Browsern

Braucht eine Zeile

  • METHOD_QUERY zu #[Route]
  • POST als Fallback behalten
  • Kein Body-Parsing-Klebecode
  • Funktioniert auf jeder dekorierbaren Route

Noch nicht so weit

  • Shopwares Reverse-Proxy-Caching
  • Native HTML-Formular-Unterstützung
  • Manche Nginx-/WAF-Konfigurationen standardmäßig
  • Ältere CDN-Edge-Knoten

Zuerst tun

  • 1 Symfony ~7.4.0+ bestätigen
  • 2 Nginx-Methoden-Allowlists prüfen
  • 3 POST + QUERY zusammen registrieren
  • 4 Bei Bedarf schon jetzt am Edge cachen

RFC 10008 ist neu, aber die Infrastruktur, um es in Shopware 6.7 zu nutzen, existiert größtenteils schon. Das Framework übernimmt den schwierigen Teil. Was bleibt, ist eine einzeilige Routen-Änderung, eine Fallback-Methode für Clients, die noch kein QUERY senden können, und Ehrlichkeit über die eine Lücke (Shopwares eigener HTTP-Cache), die Core noch nicht geschlossen hat.

Geschrieben von

Teilen:

Benötigen Sie Hilfe bei der Optimierung Ihres Shopware-Workflows?

Egal ob Sie benutzerdefinierte Plugins entwickeln, Projekte migrieren oder Deployments optimieren - erhalten Sie Beratung von einem zertifizierten Shopware-Entwickler.

Jetzt Beratung anfragen