Verified on Shopware 6.7
Shopware 6's payment handler API was significantly redesigned. If you've seen tutorials using SynchronousPaymentHandlerInterface, AsynchronousPaymentHandlerInterface, or PreparedPaymentHandlerInterface - those interfaces no longer exist. The current API uses a single abstract class for all payment types.
How Shopware 6 Payment Handlers Work
All payment handlers extend AbstractPaymentHandler. The distinction between synchronous and asynchronous payment is determined entirely by the return value of pay():
- Return
null- payment captured inline, no redirect needed (synchronous) - Return
RedirectResponse- customer is sent to a payment provider,finalize()is called on return (asynchronous)
There are no separate classes. One abstract base, one pay() method, two possible behaviors.
Registering a Payment Method
Register your payment method in the plugin's install() lifecycle method:
use Shopware\Core\Framework\Plugin;
use Shopware\Core\Framework\Plugin\Context\InstallContext;
use Shopware\Core\Framework\Plugin\Util\PluginIdProvider;
class YourPaymentPlugin extends Plugin
{
public function install(InstallContext $installContext): void
{
$repository = $this->container->get('payment_method.repository');
$repository->create([[
'name' => 'Custom Payment',
'description' => 'Pay with our custom provider',
'handlerIdentifier' => CustomPaymentHandler::class,
'afterOrderEnabled' => true,
'pluginId' => $this->container
->get(PluginIdProvider::class)
->getPluginIdByBaseClass(static::class, $installContext->getContext()),
]], $installContext->getContext());
}
public function deactivate(DeactivateContext $deactivateContext): void
{
$this->setPaymentMethodActive(false, $deactivateContext->getContext());
}
public function activate(ActivateContext $activateContext): void
{
$this->setPaymentMethodActive(true, $activateContext->getContext());
}
private function setPaymentMethodActive(bool $active, Context $context): void
{
$repository = $this->container->get('payment_method.repository');
$criteria = new Criteria();
$criteria->addFilter(new EqualsFilter('handlerIdentifier', CustomPaymentHandler::class));
$id = $repository->searchIds($criteria, $context)->firstId();
if ($id) {
$repository->update([['id' => $id, 'active' => $active]], $context);
}
}
}
The AbstractPaymentHandler
namespace Shopware\Core\Checkout\Payment\Cart\PaymentHandler;
abstract class AbstractPaymentHandler
{
// Required: called only to check REFUND and RECURRING support
abstract public function supports(
PaymentHandlerType $type,
string $paymentMethodId,
Context $context
): bool;
// Required: main payment logic
// Return null → sync capture (no redirect)
// Return RedirectResponse → send customer to provider, finalize() called on return
abstract public function pay(
Request $request,
PaymentTransactionStruct $transaction,
Context $context,
?Struct $validateStruct // populated if validate() returned something
): ?RedirectResponse;
// Optional: called before order is persisted
// Return a Struct to pass data into pay(), return null to skip
public function validate(Cart $cart, RequestDataBag $dataBag, SalesChannelContext $context): ?Struct
{ return null; }
// Optional: called after redirect returns (only if pay() returned RedirectResponse)
public function finalize(Request $request, PaymentTransactionStruct $transaction, Context $context): void {}
// Optional: only called if supports(PaymentHandlerType::REFUND) returns true
public function refund(RefundPaymentTransactionStruct $transaction, Context $context): void
{ throw PaymentException::paymentHandlerTypeUnsupported($this, PaymentHandlerType::REFUND); }
// Optional: only called if supports(PaymentHandlerType::RECURRING) returns true
public function recurring(PaymentTransactionStruct $transaction, Context $context): void
{ throw PaymentException::paymentHandlerTypeUnsupported($this, PaymentHandlerType::RECURRING); }
}
PaymentTransactionStruct contains:
getOrderTransactionId(): string- the order transaction UUIDgetReturnUrl(): ?string- the URL Shopware will redirect back to after the provider (set by Shopware)getRecurring(): ?RecurringDataStruct- populated for recurring/subscription payments
To access the full order or transaction amount inside pay(), load the order transaction via repository using getOrderTransactionId().
Synchronous Payment Handler
Payment is captured immediately when the order is placed. pay() returns null.
use Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionStateHandler;
use Shopware\Core\Checkout\Payment\Cart\PaymentHandler\AbstractPaymentHandler;
use Shopware\Core\Checkout\Payment\Cart\PaymentHandler\PaymentHandlerType;
use Shopware\Core\Checkout\Payment\Cart\PaymentTransactionStruct;
use Shopware\Core\Checkout\Payment\PaymentException;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\Struct\Struct;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
class SyncPaymentHandler extends AbstractPaymentHandler
{
public function __construct(
private readonly OrderTransactionStateHandler $stateHandler,
private readonly EntityRepository $orderTransactionRepository,
private readonly PaymentApiService $paymentApi,
) {}
public function supports(PaymentHandlerType $type, string $paymentMethodId, Context $context): bool
{
return false; // no refund or recurring support
}
public function pay(
Request $request,
PaymentTransactionStruct $transaction,
Context $context,
?Struct $validateStruct
): ?RedirectResponse {
$transactionId = $transaction->getOrderTransactionId();
// Load the order transaction to get amount/order details
$orderTransaction = $this->orderTransactionRepository
->search(new Criteria([$transactionId]), $context)
->first();
$amount = $orderTransaction->getAmount()->getTotalPrice();
$currency = $orderTransaction->getOrder()->getCurrency()->getIsoCode();
try {
$result = $this->paymentApi->charge([
'amount' => $amount,
'currency' => $currency,
'reference' => $orderTransaction->getOrder()->getOrderNumber(),
]);
if (!$result->isSuccessful()) {
throw PaymentException::syncProcessInterrupted(
$transactionId,
'Payment declined: ' . $result->getMessage()
);
}
$this->stateHandler->paid($transactionId, $context);
} catch (\Exception $e) {
throw PaymentException::syncProcessInterrupted($transactionId, $e->getMessage());
}
return null; // null = sync, no redirect
}
}
Asynchronous Payment Handler (with Redirect)
For providers that require the customer to complete payment on an external page. pay() returns a RedirectResponse. Shopware calls finalize() when the customer returns.
class AsyncPaymentHandler extends AbstractPaymentHandler
{
public function __construct(
private readonly OrderTransactionStateHandler $stateHandler,
private readonly EntityRepository $orderTransactionRepository,
private readonly PaymentApiService $paymentApi,
) {}
public function supports(PaymentHandlerType $type, string $paymentMethodId, Context $context): bool
{
return false;
}
public function pay(
Request $request,
PaymentTransactionStruct $transaction,
Context $context,
?Struct $validateStruct
): ?RedirectResponse {
$transactionId = $transaction->getOrderTransactionId();
$returnUrl = $transaction->getReturnUrl(); // Shopware's return URL
$orderTransaction = $this->orderTransactionRepository
->search(
(new Criteria([$transactionId]))->addAssociation('order.currency'),
$context
)
->first();
try {
$session = $this->paymentApi->createSession([
'amount' => $orderTransaction->getAmount()->getTotalPrice(),
'currency' => $orderTransaction->getOrder()->getCurrency()->getIsoCode(),
'return_url' => $returnUrl,
'reference' => $orderTransaction->getOrder()->getOrderNumber(),
]);
// Store provider reference in custom fields for finalize()
$this->orderTransactionRepository->update([[
'id' => $transactionId,
'customFields' => ['provider_session_id' => $session->getId()],
]], $context);
return new RedirectResponse($session->getCheckoutUrl());
} catch (\Exception $e) {
throw PaymentException::asyncProcessInterrupted($transactionId, $e->getMessage());
}
}
public function finalize(
Request $request,
PaymentTransactionStruct $transaction,
Context $context
): void {
$transactionId = $transaction->getOrderTransactionId();
if ($request->query->has('cancel')) {
throw PaymentException::customerCanceled($transactionId, 'Customer cancelled.');
}
$orderTransaction = $this->orderTransactionRepository
->search(new Criteria([$transactionId]), $context)
->first();
$sessionId = $orderTransaction->getCustomFields()['provider_session_id'] ?? null;
$result = $this->paymentApi->verifyPayment($sessionId);
if (!$result->isSuccessful()) {
throw PaymentException::asyncFinalizeInterrupted(
$transactionId,
'Verification failed: ' . $result->getMessage()
);
}
$this->stateHandler->paid($transactionId, $context);
}
}
Pre-order Validation
Use validate() when you need to process something before the order is created - for example, validating a payment token captured in the checkout form. The struct returned from validate() is passed into pay() as $validateStruct.
class TokenPaymentHandler extends AbstractPaymentHandler
{
public function validate(Cart $cart, RequestDataBag $dataBag, SalesChannelContext $context): ?Struct
{
$token = $dataBag->get('paymentToken');
if (!$token) {
throw PaymentException::validatePreparedPaymentInterrupted('Payment token is required.');
}
$tokenData = $this->paymentApi->validateToken($token);
if (!$tokenData->isValid()) {
throw PaymentException::validatePreparedPaymentInterrupted('Invalid payment token.');
}
return new ArrayStruct(['provider_reference' => $tokenData->getReference()]);
}
public function pay(
Request $request,
PaymentTransactionStruct $transaction,
Context $context,
?Struct $validateStruct
): ?RedirectResponse {
$transactionId = $transaction->getOrderTransactionId();
$providerReference = $validateStruct?->get('provider_reference');
try {
$orderTransaction = $this->orderTransactionRepository
->search(new Criteria([$transactionId]), $context)
->first();
$result = $this->paymentApi->capture([
'reference' => $providerReference,
'amount' => $orderTransaction->getAmount()->getTotalPrice(),
]);
if (!$result->isSuccessful()) {
throw PaymentException::syncProcessInterrupted(
$transactionId,
'Capture failed: ' . $result->getMessage()
);
}
$this->stateHandler->paid($transactionId, $context);
} catch (\Exception $e) {
throw PaymentException::syncProcessInterrupted($transactionId, $e->getMessage());
}
return null;
}
public function supports(PaymentHandlerType $type, string $paymentMethodId, Context $context): bool
{
return false;
}
}
Register in services.xml
All handlers use the same tag regardless of whether they redirect or not:
<service id="YourPlugin\Payment\SyncPaymentHandler">
<argument type="service" id="Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionStateHandler"/>
<argument type="service" id="order_transaction.repository"/>
<argument type="service" id="your_plugin.payment_api"/>
<tag name="shopware.payment.method"/>
</service>
<service id="YourPlugin\Payment\AsyncPaymentHandler">
<argument type="service" id="Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionStateHandler"/>
<argument type="service" id="order_transaction.repository"/>
<argument type="service" id="your_plugin.payment_api"/>
<tag name="shopware.payment.method"/>
</service>
The old tags (shopware.payment.method.sync, shopware.payment.method.async, shopware.payment.method.prepared) no longer exist.
Transaction State Machine
Shopware manages payment states through OrderTransactionStateHandler. The most common transitions:
$this->stateHandler->paid($transactionId, $context);
$this->stateHandler->cancel($transactionId, $context);
$this->stateHandler->fail($transactionId, $context);
$this->stateHandler->process($transactionId, $context); // "in progress"
$this->stateHandler->paidPartially($transactionId, $context);
$this->stateHandler->refund($transactionId, $context);
Shopware does not automatically transition to paid - your handler must call the state handler explicitly after confirming the charge succeeded.
Supports Method
supports() is only called for PaymentHandlerType::REFUND and PaymentHandlerType::RECURRING. Return false from both if your handler does not implement those features:
public function supports(PaymentHandlerType $type, string $paymentMethodId, Context $context): bool
{
return match ($type) {
PaymentHandlerType::REFUND => true, // if you implement refund()
PaymentHandlerType::RECURRING => false,
};
}
If supports() returns true for REFUND but refund() throws PaymentException::paymentHandlerTypeUnsupported(), Shopware treats it as unsupported at runtime.
Integrating a payment provider into Shopware? We've done it across multiple projects.