Sylius 2.0 : Architecture des Payment Requests et Migration Depuis Payum
Sylius 2.0 introduit une nouvelle architecture de paiement event-driven baptisee Payment Requests. Cette couche, basee sur Symfony Messenger, coexiste avec Payum et represente l'avenir du traitement des paiements dans l'ecosysteme Sylius. Voici un guide technique complet pour comprendre cette architecture, migrer vos passerelles existantes, et ecrire un nouveau gateway from scratch.
Le contexte : pourquoi une nouvelle architecture de paiement ?
Payum a rendu d'immenses services a la communaute Sylius depuis la version 1.0. Mais son architecture monolithique pose des problemes croissants :
- Couplage fort : les Actions Payum melangent logique metier, communication HTTP et persistence dans des classes uniques
- Testabilite limitee : mocker un
GatewayInterfacePayum avec ses tokens, ses requetes et ses modeles de stockage est penible - Synchronisme : chaque appel Payum est bloquant, ce qui ne convient pas aux webhooks modernes et aux flux asynchrones
- Maintenance : le projet Payum a un rythme de maintenance reduit, et certaines de ses dependances accusent leur age
Les Payment Requests repondent a ces problemes en s'appuyant sur des composants Symfony standards : Messenger pour le dispatch asynchrone, les evenements kernel pour le cycle de vie, et Doctrine pour la persistence.
Architecture des Payment Requests
Le modele PaymentRequest
Au coeur du systeme se trouve l'entite PaymentRequest. C'est un objet Doctrine qui represente une intention de paiement :
namespace Sylius\Component\Payment\Model;
class PaymentRequest implements PaymentRequestInterface
{
private ?int $id = null;
private PaymentInterface $payment;
private string $action; // 'capture', 'authorize', 'refund', 'cancel', 'status'
private string $state; // 'new', 'processing', 'completed', 'failed'
private array $payload = [];
private array $responseData = [];
private ?string $hash = null;
// Le hash sert de token securise pour les callbacks/webhooks
public function getHash(): ?string
{
return $this->hash;
}
}
Contrairement a Payum ou le token est un objet separe avec sa propre table, ici le hash est un attribut de l'entite elle-meme. C'est plus simple, plus lisible, et plus facile a deboguer.
Le cycle de vie event-driven
Lorsqu'un client finalise sa commande, Sylius cree un PaymentRequest avec l'action capture et le dispatche via Messenger :
// Simplifie pour la comprehension
$paymentRequest = new PaymentRequest();
$paymentRequest->setPayment($payment);
$paymentRequest->setAction(PaymentRequestInterface::ACTION_CAPTURE);
$paymentRequest->setState(PaymentRequestInterface::STATE_NEW);
$paymentRequest->setHash(bin2hex(random_bytes(32)));
$entityManager->persist($paymentRequest);
$entityManager->flush();
// Dispatch du message dans le bus Messenger
$this->messageBus->dispatch(new ProcessPaymentRequest($paymentRequest->getHash()));
Le handler recupere le PaymentRequest par son hash, delegue au bon processeur de passerelle, et met a jour l'etat :
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
class ProcessPaymentRequestHandler
{
public function __construct(
private PaymentRequestRepositoryInterface $repository,
private PaymentRequestProcessorInterface $processor,
) {}
public function __invoke(ProcessPaymentRequest $message): void
{
$paymentRequest = $this->repository->findOneByHash($message->getHash());
if (null === $paymentRequest) {
return; // Silencieux : le PaymentRequest a pu etre annule
}
$paymentRequest->setState(PaymentRequestInterface::STATE_PROCESSING);
try {
$this->processor->process($paymentRequest);
$paymentRequest->setState(PaymentRequestInterface::STATE_COMPLETED);
} catch (\Throwable $e) {
$paymentRequest->setState(PaymentRequestInterface::STATE_FAILED);
$paymentRequest->setResponseData([
'error' => $e->getMessage(),
]);
}
}
}
Configuration du transport Messenger
Pour que les Payment Requests soient traites, configurez un transport dedie dans Messenger :
# config/packages/messenger.yaml
framework:
messenger:
transports:
payment:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
queue_name: payment
retry_strategy:
max_retries: 3
delay: 2000
multiplier: 3
max_delay: 30000
routing:
'Sylius\Component\Payment\Message\ProcessPaymentRequest': payment
En developpement, vous pouvez utiliser le transport sync:// pour un traitement synchrone. En production, privilegiez doctrine://, amqp:// ou redis:// selon votre infrastructure.
Coexistence Payum / Payment Requests dans Sylius 2.0
Point essentiel : Payum reste le systeme de paiement par defaut dans Sylius 2.0. Les Payment Requests sont une couche supplementaire, encore experimentale. Concretement :
- Les passerelles Payum existantes continuent de fonctionner sans modification
- Le
PayumPaymentControllerest toujours present et fonctionnel - Les factories de passerelles Payum sont enregistrees normalement
- Les Payment Requests ajoutent une alternative pour les nouvelles integrations
Vous pouvez avoir certaines passerelles sur Payum et d'autres sur Payment Requests dans le meme projet. La resolution se fait au niveau de la configuration de la methode de paiement dans l'admin Sylius.
Ce qui a ete retire du core
Sylius 2.0 a retire deux passerelles qui etaient livrees dans le core :
- Stripe Checkout (anciennement
sylius/payum-stripe-checkout-pluginintegre) - PayPal Express Checkout
Si vous utilisiez ces passerelles, vous avez trois options :
- Installer un plugin dedie : des plugins communautaires comme
flux-se/sylius-stripe-pluginousylius/paypal-pluginfournissent des integrations maintenues - Conserver via Payum : installer
payum/stripeetpayum/paypal-express-checkoutdirectement et recreer les factories de passerelles - Reecrire avec Payment Requests : pour les nouvelles integrations, profitez-en pour adopter la nouvelle architecture
Migrer une passerelle Payum custom
Si vous avez ecrit une passerelle Payum custom (par exemple pour un PSP local comme Mollie, Adyen ou Ingenico), voici le processus de migration.
Etape 1 : inventorier les Actions Payum
Listez toutes vos Actions Payum custom. Typiquement, vous avez :
// Avant : Payum Action
namespace App\Payum\Action;
use Payum\Core\Action\ActionInterface;
use Payum\Core\Request\Capture;
use Payum\Core\GatewayAwareInterface;
class CaptureAction implements ActionInterface, GatewayAwareInterface
{
use GatewayAwareTrait;
public function execute($request): void
{
/** @var Capture $request */
$payment = $request->getModel();
$details = $payment->getDetails();
// Appel API vers le PSP
$response = $this->apiClient->createPayment([
'amount' => $details['amount'],
'currency' => $details['currency'],
'return_url' => $request->getToken()->getAfterUrl(),
]);
$details['psp_id'] = $response['id'];
$details['status'] = $response['status'];
$payment->setDetails($details);
if (isset($response['redirect_url'])) {
throw new HttpRedirect($response['redirect_url']);
}
}
public function supports($request): bool
{
return $request instanceof Capture
&& $request->getModel() instanceof PaymentInterface;
}
}
Etape 2 : creer le PaymentRequestProcessor equivalent
// Apres : Payment Request Processor
namespace App\Payment\Processor;
use Sylius\Component\Payment\Model\PaymentRequestInterface;
use Sylius\Component\Payment\Processor\PaymentRequestProcessorInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
class MyPspCaptureProcessor implements PaymentRequestProcessorInterface
{
public function __construct(
private MyPspApiClient $apiClient,
private UrlGeneratorInterface $urlGenerator,
) {}
public function process(PaymentRequestInterface $paymentRequest): void
{
$payment = $paymentRequest->getPayment();
$order = $payment->getOrder();
$callbackUrl = $this->urlGenerator->generate(
'app_payment_callback',
['hash' => $paymentRequest->getHash()],
UrlGeneratorInterface::ABSOLUTE_URL,
);
$response = $this->apiClient->createPayment([
'amount' => $payment->getAmount(),
'currency' => $order->getCurrencyCode(),
'callback_url' => $callbackUrl,
]);
$paymentRequest->setResponseData([
'psp_id' => $response['id'],
'redirect_url' => $response['redirect_url'] ?? null,
]);
$paymentRequest->setPayload([
...$paymentRequest->getPayload(),
'psp_reference' => $response['id'],
]);
}
public function supports(PaymentRequestInterface $paymentRequest): bool
{
return $paymentRequest->getAction() === PaymentRequestInterface::ACTION_CAPTURE
&& $paymentRequest->getPayment()->getMethod()->getGatewayConfig()->getFactoryName() === 'my_psp';
}
}
Etape 3 : enregistrer le processeur
# config/services.yaml
services:
App\Payment\Processor\MyPspCaptureProcessor:
tags:
- { name: 'sylius.payment_request_processor', priority: 10 }
Le tag sylius.payment_request_processor enregistre votre processeur dans la chaine de responsabilite. Le priority permet de controler l'ordre d'execution si plusieurs processeurs matchent.
Ecrire un nouveau gateway avec Payment Requests
Pour une nouvelle integration (par exemple un PSP headless moderne), voici l'architecture complete.
Le GatewayConfig
# config/packages/sylius_payment.yaml
sylius_payment:
gateways:
acme_pay:
factory: 'acme_pay'
config:
api_key: '%env(ACME_PAY_API_KEY)%'
secret_key: '%env(ACME_PAY_SECRET_KEY)%'
sandbox: '%env(bool:ACME_PAY_SANDBOX)%'
Variables d'environnement
# .env
ACME_PAY_API_KEY=pk_test_xxxxxxxxxxxx
ACME_PAY_SECRET_KEY=sk_test_xxxxxxxxxxxx
ACME_PAY_SANDBOX=true
Le processeur de capture
namespace App\Payment\Processor;
use Sylius\Component\Payment\Model\PaymentRequestInterface;
use Sylius\Component\Payment\Processor\PaymentRequestProcessorInterface;
class AcmePayCaptureProcessor implements PaymentRequestProcessorInterface
{
public function __construct(
private AcmePayClient $client,
private UrlGeneratorInterface $urlGenerator,
) {}
public function process(PaymentRequestInterface $paymentRequest): void
{
$payment = $paymentRequest->getPayment();
$gatewayConfig = $payment->getMethod()->getGatewayConfig();
$config = $gatewayConfig->getConfig();
$session = $this->client->checkout->sessions->create([
'amount' => $payment->getAmount(),
'currency' => strtolower($payment->getCurrencyCode()),
'success_url' => $this->urlGenerator->generate(
'sylius_shop_order_thank_you', [], UrlGeneratorInterface::ABSOLUTE_URL
),
'cancel_url' => $this->urlGenerator->generate(
'sylius_shop_checkout_select_payment', [], UrlGeneratorInterface::ABSOLUTE_URL
),
'webhook_url' => $this->urlGenerator->generate(
'app_acme_webhook', ['hash' => $paymentRequest->getHash()],
UrlGeneratorInterface::ABSOLUTE_URL
),
], $config['api_key']);
$paymentRequest->setResponseData([
'session_id' => $session->id,
'redirect_url' => $session->url,
]);
}
public function supports(PaymentRequestInterface $paymentRequest): bool
{
return $paymentRequest->getAction() === PaymentRequestInterface::ACTION_CAPTURE
&& $this->isAcmePay($paymentRequest);
}
private function isAcmePay(PaymentRequestInterface $paymentRequest): bool
{
return $paymentRequest->getPayment()
->getMethod()
->getGatewayConfig()
->getFactoryName() === 'acme_pay';
}
}
Le controleur webhook
namespace App\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/payment/acme/webhook/{hash}', name: 'app_acme_webhook', methods: ['POST'])]
class AcmeWebhookController
{
public function __construct(
private PaymentRequestRepositoryInterface $repository,
private AcmePayClient $client,
private EntityManagerInterface $em,
private MessageBusInterface $bus,
) {}
public function __invoke(Request $request, string $hash): Response
{
$signature = $request->headers->get('X-Acme-Signature');
$payload = $request->getContent();
if (!$this->client->webhooks->verify($payload, $signature)) {
return new Response('Invalid signature', 400);
}
$event = json_decode($payload, true);
$paymentRequest = $this->repository->findOneByHash($hash);
if (null === $paymentRequest) {
return new Response('Not found', 404);
}
$paymentRequest->setPayload([
...$paymentRequest->getPayload(),
'webhook_event' => $event['type'],
'webhook_data' => $event['data'],
]);
// Dispatch un status check via Messenger
$this->bus->dispatch(new ProcessPaymentRequest($hash));
$this->em->flush();
return new Response('OK', 200);
}
}
Scenarios headless : Payment Requests et API Platform
Dans un contexte headless (frontend React, Vue, mobile), les Payment Requests s'integrent naturellement avec API Platform 4 :
// Requete API depuis le frontend headless
// POST /api/v2/shop/payment-requests
{
"payment": "/api/v2/shop/payments/42",
"action": "capture"
}
// Reponse
{
"@id": "/api/v2/shop/payment-requests/abc123",
"state": "new",
"hash": "a1b2c3d4...",
"responseData": {}
}
// Apres traitement asynchrone, le frontend poll :
// GET /api/v2/shop/payment-requests/abc123
{
"@id": "/api/v2/shop/payment-requests/abc123",
"state": "completed",
"responseData": {
"session_id": "cs_xxx",
"redirect_url": "https://checkout.acmepay.com/cs_xxx"
}
}
Le frontend redirige l'utilisateur vers l'URL de checkout du PSP. Le webhook notifie votre backend, qui met a jour le PaymentRequest et le Payment associe.
Tester les flux de paiement en staging
Les tests de paiement sont souvent les grands oublies de la migration. Voici une checklist :
- Variables d'environnement sandbox : ne jamais utiliser les cles de production en staging. Configurez
ACME_PAY_SANDBOX=trueet des cles de test - Webhook local : utilisez un tunnel (
ngrok,expose,cloudflared tunnel) pour recevoir les webhooks en local - Transport Messenger sync : en test, forcez le transport synchrone pour des assertions deterministes
# config/packages/test/messenger.yaml
framework:
messenger:
transports:
payment:
dsn: 'sync://'
// Test fonctionnel
class PaymentRequestTest extends WebTestCase
{
public function test_capture_creates_payment_request(): void
{
$client = static::createClient();
// Simule la soumission de commande
$client->request('POST', '/api/v2/shop/payment-requests', [
'json' => [
'payment' => '/api/v2/shop/payments/1',
'action' => 'capture',
],
]);
$this->assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame('completed', $data['state']);
$this->assertArrayHasKey('redirect_url', $data['responseData']);
}
public function test_webhook_updates_payment_state(): void
{
// Cree un PaymentRequest en base
$hash = $this->createPaymentRequestFixture();
$client = static::createClient();
$client->request('POST', "/payment/acme/webhook/{$hash}", [], [], [
'HTTP_X_ACME_SIGNATURE' => $this->generateTestSignature(),
'CONTENT_TYPE' => 'application/json',
], json_encode([
'type' => 'payment.completed',
'data' => ['session_id' => 'cs_xxx'],
]));
$this->assertResponseStatusCodeSame(200);
$payment = $this->getPaymentForHash($hash);
$this->assertSame('completed', $payment->getState());
}
}
Audit automatise avec Sylius Upgrade Analyzer
L'outil Sylius Upgrade Analyzer inclut des analyseurs dedies aux paiements dans la famille "Deprecations & Breaking Changes" :
- PayumGatewayAnalyzer : detecte les passerelles Payum custom et les Actions enregistrees
- StripePaypalRemovalAnalyzer : signale si votre projet depend des passerelles Stripe ou PayPal retirees du core
- SwiftMailerAnalyzer : detecte l'usage de SwiftMailer dans les notifications de paiement (a remplacer par
symfony/mailer)
vendor/bin/sylius-upgrade-analyzer sylius-upgrade:analyze
# Extrait du rapport :
# [BREAKING] Passerelle Payum custom detectee : App\Payum\Action\CaptureAction
# → Migration vers PaymentRequestProcessor recommandee
# Effort estime : 4h par passerelle
#
# [BREAKING] Dependance stripe/payum retiree du core Sylius 2.0
# → Installer flux-se/sylius-stripe-plugin ou reecrire
# Effort estime : 2-8h selon le scenario
#
# [WARNING] SwiftMailer detecte dans App\EventListener\PaymentNotifier
# → Migrer vers symfony/mailer
# Effort estime : 1h
Conclusion
Les Payment Requests de Sylius 2.0 representent une evolution majeure de l'architecture de paiement : event-driven, asynchrone, testable et nativement compatible avec les scenarios headless. La coexistence avec Payum vous permet de migrer progressivement sans big bang. Pour les nouveaux projets et les nouvelles integrations, adoptez directement les Payment Requests. Pour l'existant, conservez Payum et planifiez la migration quand l'API sera stabilisee. Et dans tous les cas, auditez votre code de paiement avec Sylius Upgrade Analyzer avant de vous lancer.
