Payment Modules
Payment modules handle the checkout payment step. They talk to a payment gateway, process the transaction, and react to the gateway's callbacks.
Payment flow
- Customer selects payment method at checkout
isValidPayment()determines if method is available- Customer clicks "Pay"
pay()method is called with the order- Customer is redirected to gateway or payment is processed
- Gateway callback confirms payment
- Order status is updated
Creating a payment module
Main class
Payment modules extend AbstractPaymentModule:
MyPayment.php:
<?php
declare(strict_types=1);
namespace MyPayment;
use Thelia\Model\Order;
use Thelia\Module\AbstractPaymentModule;
use Symfony\Component\HttpFoundation\Response;
final class MyPayment extends AbstractPaymentModule
{
public const DOMAIN_NAME = 'mypayment';
/**
* Check if this payment method is available.
*/
public function isValidPayment(): bool
{
// Check if module is configured
if (empty($this->getApiKey())) {
return false;
}
// Check cart total (e.g., minimum order)
$orderTotal = $this->getCurrentOrderTotalAmount();
if ($orderTotal < 1.00) {
return false;
}
// Check maximum amount
if ($orderTotal > 10000) {
return false;
}
return true;
}
/**
* Process the payment.
*/
public function pay(Order $order): ?Response
{
// Option 1: Redirect to payment gateway
return $this->redirectToGateway($order);
// Option 2: Direct API payment
// return $this->processDirectPayment($order);
// Option 3: Show payment form (card details)
// return $this->showPaymentForm($order);
}
/**
* Should stock be decremented when order is created?
* Return false to decrement only when paid.
*/
public function manageStockOnCreation(): bool
{
return false; // Decrement stock when payment confirmed
}
private function redirectToGateway(Order $order): Response
{
$gatewayUrl = $this->getGatewayUrl();
$params = [
'merchant_id' => $this->getMerchantId(),
'order_id' => $order->getRef(),
'amount' => $order->getTotalAmount(),
'currency' => $order->getCurrency()->getCode(),
'return_url' => $this->getPaymentSuccessPageUrl($order->getId()),
'cancel_url' => $this->getPaymentFailurePageUrl($order->getId(), null),
'callback_url' => $this->getCallbackUrl(),
];
// Sign the request
$params['signature'] = $this->generateSignature($params);
return $this->generateGatewayFormResponse($order, $gatewayUrl, $params);
}
private function getApiKey(): string
{
return \Thelia\Model\ConfigQuery::read('mypayment_api_key', '');
}
private function getMerchantId(): string
{
return \Thelia\Model\ConfigQuery::read('mypayment_merchant_id', '');
}
private function getGatewayUrl(): string
{
$testMode = \Thelia\Model\ConfigQuery::read('mypayment_test_mode', '1');
return $testMode === '1'
? 'https://sandbox.payment.com/pay'
: 'https://payment.com/pay';
}
private function getCallbackUrl(): string
{
return $this->getBaseUrl() . '/mypayment/callback';
}
private function generateSignature(array $params): string
{
$secretKey = \Thelia\Model\ConfigQuery::read('mypayment_secret_key', '');
$data = implode('', $params);
return hash_hmac('sha256', $data, $secretKey);
}
}
Only generateGatewayFormResponse(), getPaymentSuccessPageUrl(), getPaymentFailurePageUrl() and manageStockOnCreation() come from AbstractPaymentModule. pay() and isValidPayment() come from PaymentModuleInterface. getCurrentOrderTotalAmount(), getRequest(), getDispatcher() and getContainer() come from BaseModule.
The methods getApiKey(), getMerchantId(), getGatewayUrl(), getCallbackUrl(), getBaseUrl() and refund() are your own helpers in this example. The framework does not provide them. In particular there is no refund() in AbstractPaymentModule or PaymentModuleInterface; implement it yourself if your gateway supports refunds.
getPaymentFailurePageUrl() requires two arguments: getPaymentFailurePageUrl(int $order_id, ?string $message). Pass null for a generic failure message, or a string to display a specific reason on the failure page.
// AbstractPaymentModule.php
public function getPaymentSuccessPageUrl(int $order_id): string;
public function getPaymentFailurePageUrl(int $order_id, ?string $message): string;
The payment module is auto-wired through configureServices() (with autoconfigure enabled). Its controllers' routes are declared with PHP 8 #[Route] attributes, so you don't need a routing.xml.
module.xml
Config/module.xml:
<?xml version="1.0" encoding="UTF-8"?>
<module xmlns="http://thelia.net/schema/dic/module"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://thelia.net/schema/dic/module http://thelia.net/schema/dic/module/module-2_2.xsd">
<fullnamespace>MyPayment\MyPayment</fullnamespace>
<descriptive locale="en_US">
<title>My Payment Gateway</title>
<description>Accept payments via My Payment</description>
</descriptive>
<version>1.0.0</version>
<type>payment</type>
<thelia>2.5.0</thelia>
<stability>prod</stability>
</module>
isValidPayment()
Determine when the payment method appears:
public function isValidPayment(): bool
{
// Check configuration
if (!$this->isConfigured()) {
return false;
}
// Get current cart/order info
$orderTotal = $this->getCurrentOrderTotalAmount();
$currency = $this->getRequest()->getSession()->getCurrency();
// Amount limits
if ($orderTotal < 1.00 || $orderTotal > 50000) {
return false;
}
// Currency support
$supportedCurrencies = ['EUR', 'USD', 'GBP'];
if (!in_array($currency->getCode(), $supportedCurrencies)) {
return false;
}
// Customer requirements
// Inside a module, the customer comes from the session (no getSecurityContext()
// helper here: that one lives on the controllers, not on BaseModule).
$customer = $this->getRequest()->getSession()->getCustomerUser();
if ($customer && $this->isCustomerBlocked($customer)) {
return false;
}
// IP restrictions for testing
if ($this->isTestMode()) {
$allowedIps = explode(',', \Thelia\Model\ConfigQuery::read('mypayment_test_ips', ''));
if (!in_array($this->getRequest()->getClientIp(), $allowedIps)) {
return false;
}
}
return true;
}
pay() method patterns
Pattern 1: gateway redirect
Submit form data to an external gateway:
public function pay(Order $order): ?Response
{
$params = $this->buildGatewayParams($order);
return $this->generateGatewayFormResponse(
$order,
'https://gateway.payment.com/checkout',
$params
);
}
This renders the checkout-gateway template to auto-submit the form to the payment gateway.
Pattern 2: direct API payment
Process the payment directly through the API:
use Symfony\Component\HttpFoundation\RedirectResponse;
use Thelia\Core\Event\Order\OrderEvent;
use Thelia\Core\Event\TheliaEvents;
use Thelia\Log\Tlog;
use Thelia\Model\OrderStatusQuery;
public function pay(Order $order): ?Response
{
try {
$result = $this->paymentApi->createPayment([
'amount' => $order->getTotalAmount(),
'currency' => $order->getCurrency()->getCode(),
'order_ref' => $order->getRef(),
'customer_email' => $order->getCustomer()->getEmail(),
'card_token' => $this->getRequest()->request->get('card_token'),
]);
if ($result['status'] === 'success') {
// Mark the order as paid - the same pattern the core FreeOrder module uses
$event = new OrderEvent($order);
$event->setStatus(OrderStatusQuery::getPaidStatus()->getId());
$this->getDispatcher()->dispatch($event, TheliaEvents::ORDER_UPDATE_STATUS);
return new RedirectResponse($this->getPaymentSuccessPageUrl($order->getId()));
}
// Payment failed
return new RedirectResponse($this->getPaymentFailurePageUrl($order->getId(), null));
} catch (\Exception $e) {
Tlog::getInstance()->error('Payment failed: ' . $e->getMessage());
return new RedirectResponse($this->getPaymentFailurePageUrl($order->getId(), null));
}
}
AbstractPaymentModule gives you generateGatewayFormResponse(), getPaymentSuccessPageUrl(), getPaymentFailurePageUrl(), plus getRequest() and getDispatcher() from BaseModule. It does not give you generateRedirect(), getLog(), confirmPayment() or cancelPayment(). Those live on the controllers (BaseController, BasePaymentModuleController). Inside pay(), return a plain Symfony RedirectResponse and dispatch TheliaEvents::ORDER_UPDATE_STATUS yourself, as shown above.
In your callback controller (which extends BasePaymentModuleController), prefer the confirmPayment(EventDispatcherInterface $eventDispatcher, int $orderId) and cancelPayment() helpers instead.
Pattern 3: hosted payment page
Redirect to the gateway's hosted page:
public function pay(Order $order): ?Response
{
$session = $this->paymentApi->createCheckoutSession([
'amount' => $order->getTotalAmount(),
'currency' => $order->getCurrency()->getCode(),
'order_ref' => $order->getRef(),
'success_url' => $this->getPaymentSuccessPageUrl($order->getId()),
'cancel_url' => $this->getPaymentFailurePageUrl($order->getId(), null),
]);
return new RedirectResponse($session['checkout_url']);
}
Callback handling
Process gateway notifications:
Controller/CallbackController.php:
<?php
declare(strict_types=1);
namespace MyPayment\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Thelia\Module\BasePaymentModuleController;
final class CallbackController extends BasePaymentModuleController
{
protected function getModuleCode(): string
{
return 'MyPayment';
}
#[Route('/mypayment/callback', name: 'mypayment.callback', methods: ['POST'])]
public function callbackAction(Request $request): Response
{
// Log incoming callback
$this->getLog()->info('Payment callback received', [
'data' => $request->request->all(),
]);
// Verify signature
if (!$this->verifySignature($request)) {
$this->getLog()->error('Invalid callback signature');
return new Response('Invalid signature', 400);
}
// Get order
$orderRef = $request->request->get('order_ref');
$order = $this->getOrderByRef($orderRef);
if (!$order) {
$this->getLog()->error('Order not found: ' . $orderRef);
return new Response('Order not found', 404);
}
// Process based on status
$status = $request->request->get('status');
switch ($status) {
case 'paid':
case 'captured':
$this->confirmPayment($this->getDispatcher(), $order->getId());
$this->getLog()->info('Payment confirmed for order: ' . $orderRef);
break;
case 'cancelled':
case 'failed':
$this->cancelPayment($this->getDispatcher(), $order->getId());
$this->getLog()->info('Payment cancelled for order: ' . $orderRef);
break;
case 'refunded':
$this->handleRefund($order, $request);
break;
default:
$this->getLog()->warning('Unknown payment status: ' . $status);
}
// Acknowledge receipt
return new Response('OK', 200);
}
private function verifySignature(Request $request): bool
{
$receivedSignature = $request->headers->get('X-Signature');
$payload = $request->getContent();
$secretKey = \Thelia\Model\ConfigQuery::read('mypayment_secret_key', '');
$expectedSignature = hash_hmac('sha256', $payload, $secretKey);
return hash_equals($expectedSignature, $receivedSignature);
}
private function getOrderByRef(string $ref): ?\Thelia\Model\Order
{
return \Thelia\Model\OrderQuery::create()
->filterByRef($ref)
->findOne();
}
private function handleRefund($order, Request $request): void
{
// Update order status or create refund record
// Implementation depends on your business logic
}
}
Customer return pages
Handle customer returns from the gateway:
#[Route('/mypayment/return', name: 'mypayment.return')]
public function returnAction(Request $request): Response
{
$orderId = (int) $request->query->get('order_id');
$status = $request->query->get('status');
// redirectToSuccessPage()/redirectToFailurePage() return void: they throw a
// RedirectException that the kernel turns into the actual HTTP redirect.
if ($status === 'success') {
$this->redirectToSuccessPage($orderId);
}
$this->redirectToFailurePage($orderId, null);
}
#[Route('/mypayment/cancel', name: 'mypayment.cancel')]
public function cancelAction(Request $request): Response
{
$orderId = (int) $request->query->get('order_id');
$this->redirectToFailurePage($orderId, null);
}
Stock management
Control when stock is decremented:
/**
* Return true to decrement stock when order is created.
* Return false to decrement when payment is confirmed.
*/
public function manageStockOnCreation(): bool
{
// For instant payments (credit card), decrement on creation
// For delayed payments (bank transfer), decrement when paid
return false;
}
Refunds
Refunds are not part of the payment module contract. Neither AbstractPaymentModule nor PaymentModuleInterface declares a refund() method. The example below is a helper you write yourself and call from your own back-office action or callback handler.
public function refund(Order $order, float $amount): bool
{
try {
$result = $this->paymentApi->createRefund([
'payment_id' => $order->getTransactionRef(),
'amount' => $amount,
'reason' => 'Customer request',
]);
if ($result['status'] === 'success') {
// Log refund
$this->logRefund($order, $amount, $result['refund_id']);
return true;
}
$this->getLog()->error('Refund failed', $result);
return false;
} catch (\Exception $e) {
$this->getLog()->error('Refund exception: ' . $e->getMessage());
return false;
}
}
Logging
Use a dedicated log file for debugging:
// In BasePaymentModuleController
$this->getLog()->info('Payment initiated', [
'order_ref' => $order->getRef(),
'amount' => $order->getTotalAmount(),
]);
$this->getLog()->error('Payment failed', [
'order_ref' => $order->getRef(),
'error' => $errorMessage,
]);
Logs are stored in log/mypayment.log.
Admin configuration
Controller/Admin/ConfigController.php:
#[Route('/admin/module/MyPayment', name: 'mypayment.admin.config')]
public function indexAction(): Response
{
return $this->render('module-config', [
'api_key' => ConfigQuery::read('mypayment_api_key', ''),
'merchant_id' => ConfigQuery::read('mypayment_merchant_id', ''),
'test_mode' => ConfigQuery::read('mypayment_test_mode', '1'),
]);
}
#[Route('/admin/module/MyPayment', name: 'mypayment.admin.config.save', methods: ['POST'])]
public function saveAction(): Response
{
// Validate and save configuration
$form = $this->createForm(ConfigurationForm::getName());
try {
$data = $this->validateForm($form)->getData();
ConfigQuery::write('mypayment_api_key', $data['api_key']);
ConfigQuery::write('mypayment_merchant_id', $data['merchant_id']);
ConfigQuery::write('mypayment_test_mode', $data['test_mode'] ? '1' : '0');
return $this->generateSuccessRedirect($form);
} catch (\Exception $e) {
$this->setupFormErrorContext('Configuration', $e->getMessage(), $form);
return $this->render('module-config');
}
}
Security considerations
Signature verification
Always verify webhook signatures:
private function verifyWebhookSignature(Request $request): bool
{
$signature = $request->headers->get('X-Webhook-Signature');
$payload = $request->getContent();
$timestamp = $request->headers->get('X-Webhook-Timestamp');
// Check timestamp to prevent replay attacks
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$expectedSignature = hash_hmac(
'sha256',
$timestamp . '.' . $payload,
$this->getWebhookSecret()
);
return hash_equals($expectedSignature, $signature);
}
Secure configuration
Store sensitive data securely:
// Never log full card numbers or CVV
$this->getLog()->info('Payment attempt', [
'card_last_four' => substr($cardNumber, -4),
// Never log: 'card_number' => $cardNumber
]);
Testing
Test mode
Support sandbox and test environments:
private function getApiEndpoint(): string
{
return $this->isTestMode()
? 'https://sandbox.api.payment.com'
: 'https://api.payment.com';
}
private function isTestMode(): bool
{
return ConfigQuery::read('mypayment_test_mode', '1') === '1';
}
Test cards
Document test card numbers in your module's README:
Test Cards (Sandbox):
- Success: 4242 4242 4242 4242
- Decline: 4000 0000 0000 0002
- 3D Secure: 4000 0000 0000 3220
Best practices
Do
- Always verify signatures on callbacks
- Log all payment events, for debugging and audit
- Handle every error case gracefully
- Use HTTPS for all payment URLs
- Store transaction references for reconciliation
- Implement idempotency to handle duplicate callbacks
Don't
- Never log sensitive data (full card numbers, CVV)
- Never trust client-side data for payment amounts
- Don't skip callback verification, even in test mode
- Don't process payments without validating the order