Sylius13 min

Sylius 2.0 : Architecture des Payment Requests et Migration Depuis Payum

Par Pierre-Arthur Demengel
SyliusPaiementMigrationSymfony MessengerPHPPayum

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 GatewayInterface Payum 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 PayumPaymentController est 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-plugin integre)
  • PayPal Express Checkout

Si vous utilisiez ces passerelles, vous avez trois options :

  1. Installer un plugin dedie : des plugins communautaires comme flux-se/sylius-stripe-plugin ou sylius/paypal-plugin fournissent des integrations maintenues
  2. Conserver via Payum : installer payum/stripe et payum/paypal-express-checkout directement et recreer les factories de passerelles
  3. 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=true et 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.

Questions fréquentes

13 projets livresGrand-Est & BelgiqueLighthouse >90Disponible immédiatement

Un projet en tete ?

Discutons de votre site web. Réponse garantie sous 24h.

Ou appelez directement :06 95 41 30 25

WhatsApp
Appeler