Choosing Between Shopware 6 Storefront API and Custom Controllers for Headless Checkout
Decide when to use Shopware 6's built-in Storefront API versus custom controllers for headless cart and checkout flows. Includes a worked payment webhook example, update-safety trade-offs, and a verification checklist.
22 Sept 2026, 07:12 UTC

The Real Problem: Cart and Checkout Logic in a Headless Build
You're building a headless storefront on Shopware 6. The product catalog, navigation, and CMS pages come easily from /store-api. But when you reach cart and checkout, the decision gets sharper: do you lean on the built-in Storefront API endpoints, or do you write custom controllers to handle pricing rules, payment flows, or ERP callbacks? The wrong choice here means either fighting the framework on every minor release or reimplementing tax, shipping, and promotion logic that Shopware already owns.
Takeaway: Use the Storefront API for standard cart and checkout flows—adding line items, applying promotions, calculating totals, and placing orders. Reach for a custom controller only when you need behavior the API cannot express, such as a third-party payment webhook that must mutate cart state before the customer returns, or a B2B quote-to-order workflow that bypasses the standard checkout steps.
What the Storefront API Gives You Out of the Box
The /store-api surface is scoped to a sales channel and a context token (or session cookie). That single context carries language, currency, tax rules, and the active customer group. A request to POST /store-api/v3/checkout/cart/line-item with a product ID and quantity triggers the same price calculation, promotion evaluation, and stock check that the default storefront uses. The response returns the updated cart with totals, taxes, and shipping costs already resolved.
Because the API is versioned with Shopware minor releases (e.g., 6.5, 6.6), endpoint paths and response shapes stay stable within a major version. You can verify the exact contract on your instance by calling an endpoint and comparing the JSON to the documentation for that minor version.
When a Custom Controller Makes Sense
Custom controllers live in your own plugin under src/Controller/Storefront and are registered as services. They are useful when:
- A payment provider sends an asynchronous webhook that must update the cart (e.g., mark a payment pending, store a transaction ID) before the shopper returns to the success page.
- You need to expose a headless endpoint for a mobile app that initiates a quote-to-order flow: the sales team configures a cart in the admin, then the buyer receives a one-click link that converts the quote into an order without the standard checkout steps.
- You must integrate an external tax engine that replaces Shopware's calculation for specific regions.
In each case, the custom controller should delegate to Shopware services (CartService, OrderService, StateMachineRegistry) rather than reimplementing business logic. That keeps your code update-safe: when Shopware changes internal signatures, you only adjust the service calls, not the calculation itself.
Worked Example: Payment Webhook That Updates Cart Before Redirect
Assume you use a payment gateway that posts a payment.pending webhook to /api/payment/webhook with a cart token and a transaction reference. You need to persist that reference on the cart so the success page can display it.
// src/Controller/Storefront/PaymentWebhookController.php
namespace App\Controller\Storefront;
use Shopware\Storefront\Controller\StorefrontController;
use Shopware\Core\Checkout\Cart\Cart;
use Shopware\Core\Checkout\Cart\SalesChannel\CartService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
class PaymentWebhookController extends StorefrontController
{
public function __construct(
private readonly CartService $cartService
) {}
#[Route(path: '/api/payment/webhook', name: 'frontend.payment.webhook', methods: ['POST'])]
public function handle(Request $request): JsonResponse
{
$payload = json_decode($request->getContent(), true);
$cartToken = $payload['cart_token'] ?? null;
$transactionRef = $payload['transaction_ref'] ?? null;
if (!$cartToken || !$transactionRef) {
return new JsonResponse(['error' => 'missing fields'], 400);
}
$cart = $this->cartService->getCart($cartToken, $request);
$cart->getExtensions()['payment_transaction_ref'] = $transactionRef;
$this->cartService->persistCart($cartToken, $cart, $request);
return new JsonResponse(['status' => 'ok']);
}
}
Where to run: Deploy this as part of a custom plugin (e.g., composer require your-vendor/payment-webhook-plugin and enable it via bin/console plugin:install --activate YourPaymentWebhookPlugin). Requires SSH access to the Shopware host and permission to run console commands.
Verification: After deployment, call the endpoint with a valid cart token from an active session and inspect the cart's extensions via GET /store-api/v3/checkout/cart. The payment_transaction_ref should appear under extensions.
Risk: The webhook runs outside the customer's browser session, so CSRF protection is not applied. Validate the webhook signature (HMAC) before mutating cart state. Also, the CartService signature changed between 6.4 and 6.5 (the persistCart method gained a context argument). Pin your plugin's shopware/core requirement to the minor range you test against (e.g., ^6.5.0 || ^6.6.0).
Trade-offs and Update Safety
| Approach | Pros | Cons |
|---|---|---|
| Storefront API only | Zero custom PHP; upgrades are configuration-only; SEO URLs, structured data, and CMS preview work via the API. | Cannot intercept async webhooks; limited to exposed actions; extending requires decorating controllers (which can break on minor releases). |
| Custom controller | Full control over request/response; can call any internal service; fits async flows. | You own the PHP code; must test against each minor release; must reimplement storefront concerns (SEO, structured data, cookie consent) in your headless frontend. |
If you decorate a core controller (e.g., CheckoutController) to inject logic, you inherit the parent's method signatures. A minor release that changes the signature or return type will break your decoration. Prefer event subscribers (CartPersistedEvent, OrderPlacedEvent) for side effects—they are more stable because Shopware treats them as extension points.
Practical Checklist Before You Commit
- Confirm the installed version: run
bin/console aboutor inspectcomposer.lockforshopware/core. Note the exact minor (e.g., 6.5.8). - Call a representative endpoint (
GET /store-api/v3/product/<id>with a valid sales channel header) and save the response shape. Compare it to the Shopware 6.5 docs. - If you write a custom controller, add a functional test that boots the kernel, dispatches a request, and asserts the response code and JSON keys. Run it on a staging instance after every
composer update. - Rotate Storefront API tokens per sales channel; never embed Admin API credentials in the frontend.
Closing Action
Start with the Storefront API for every cart and checkout step. Only introduce a custom controller when you hit a concrete requirement the API cannot satisfy—an async webhook, a quote-to-order shortcut, or an external tax override. Keep that controller thin: validate input, call a Shopware service, return JSON. Test it on a staging instance after each minor upgrade, and you'll avoid the rewrite that comes from fighting the framework.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.