---
title: 'Créer un site e-commerce avec Symfony, React et Stripe, et le déployer'
description: "Monter une boutique avec Symfony et React, encaisser via Stripe Checkout, sécuriser les webhooks et déployer sur un VPS : l'architecture complète."
pubDate: 2023-11-17
updatedDate: 2026-09-27
family: 'php-symfony'
tags: []
heroImage: '/images/creer-et-deployer-une-application-e-commerce-avec-symfony-react-et-stripe.webp'
videoUrl: 'https://youtu.be/NtRDYo7w4s8'
faq:
  - question: 'Stripe Checkout ou Payment Intents ?'
    answer: "Stripe recommande l'API Checkout Sessions pour la plupart des intégrations, et réserve Payment Intents aux cas où tu veux maîtriser toi-même la taxe, les remises, les abonnements ou la devise. Une boutique classique part donc sur Checkout Sessions, avec moins de code à écrire et le formulaire de carte rendu par Stripe."
  - question: 'Où stocker la clé secrète Stripe ?'
    answer: "Jamais dans le code ni dans un fichier versionné. La clé secrète (préfixe `sk`) vit dans une variable d'environnement côté serveur ou dans un coffre de secrets, tandis que la clé publique (préfixe `pk`) est la seule à pouvoir être envoyée au navigateur. Chaque environnement a ses clés — segment `test` ou `live` — et le webhook possède son propre secret de signature (préfixe `whsec`)."
  - question: 'Comment tester le paiement sans vraie carte ?'
    answer: "Dans un sandbox Stripe, la carte 4242 4242 4242 4242 accompagnée d'une date future et d'un CVC à trois chiffres suffit. En code, préfère un moyen de paiement de test du type pm_card_visa à un numéro recopié ; un sandbox ne traite rien sur les réseaux de cartes et ne sert pas à faire du test de charge."
  - question: 'Pourquoi la signature du webhook est-elle refusée ?'
    answer: "Presque toujours parce que le corps de la requête a été lu comme du JSON déjà analysé, puis ré-encodé : la vérification a besoin du corps brut, en UTF-8, tel que Stripe l'a envoyé. Utilise la méthode constructEvent() de la bibliothèque stripe-php avec $request->getContent(), l'en-tête Stripe-Signature et le secret de l'endpoint, et garde la route accessible sans authentification."
  - question: 'Faut-il du React pour un site e-commerce Symfony ?'
    answer: "Non, et il ne faut pas le croire : Symfony UX React monte des composants React dans une page Twig, ce n'est pas une application SPA. C'est pratique pour quelques zones interactives (panier, filtres, sélecteur de variante) sans réécrire le site, et le chargement paresseux des composants n'est pas supporté : si tu les enregistres avec import.meta.glob(), passe l'option eager: true."
---

Dans la vidéo, je crée et je déploie une application e-commerce avec Symfony, React et Stripe. La vidéo
date de 2023 : les versions et les commandes de cet article sont à jour, la démarche reste la même. Le
point le plus important, celui qui décide si ta boutique est fiable, c'est le traitement des webhooks —
la partie que tout le monde remet au lendemain.

## L'architecture en une page

Trois responsabilités, trois outils :

- **Symfony** porte le catalogue, les commandes et les prix.
- **Stripe Checkout** encaisse : c'est Stripe qui affiche le formulaire de carte, donc tu ne manipules
  jamais de numéro de carte et ton périmètre PCI se réduit au questionnaire le plus léger, le SAQ A
  ([sécurité](https://docs.stripe.com/security/guide)).
- **React**, monté dans les pages Twig, s'occupe des zones interactives. Ce n'est pas un front séparé.

Cette séparation évite la double peine du SPA e-commerce : deux déploiements, deux jeux de routes, et le
référencement à reconstruire.

## Installer Symfony et modéliser le catalogue

La documentation actuelle exige PHP 8.4 ou plus et Composer, avec les extensions Ctype, iconv, PCRE,
Session, SimpleXML et Tokenizer ([setup](https://symfony.com/doc/current/setup.html)) :

```bash
composer create-project symfony/skeleton:"8.1.*" boutique
cd boutique
composer require webapp
```

Le pack `webapp` embarque déjà Doctrine, le MakerBundle en développement et le reste des composants
courants. La connexion à la base passe par `DATABASE_URL` dans le `.env`
([doctrine](https://symfony.com/doc/current/doctrine.html)). Crée tes entités avec `make:entity`, puis :

```bash
php bin/console make:migration
php bin/console doctrine:migrations:migrate
```

Deux réflexes qui évitent des bugs pénibles : stocke les **prix en entiers, en centimes** — c'est aussi le
format attendu par Stripe ([devises](https://docs.stripe.com/currencies)) — et joue les migrations **au
déploiement**, jamais à la main sur le serveur.

## Monter React dans les pages Twig

Symfony UX React rend des composants React depuis Twig, avec transfert de propriétés
([UX React](https://symfony.com/bundles/ux-react/current/index.html)). Trois points à retenir :

1. Tout fichier placé dans `assets/react/controllers/` est rendable, après enregistrement par
   `registerReactControllerComponents()`.
2. Si tu enregistres les composants avec `import.meta.glob()`, passe l'option `{ eager: true }` : le bundle
   ne gère pas les composants chargés à la demande. Avec Webpack Encore, l'enregistrement passe par
   `require.context()` et la recette active `.enableReactPreset()` ; avec AssetMapper, précompile les
   fichiers `.jsx` en JavaScript.
3. Le rendu se fait dans Twig avec `react_component()`. Les propriétés sont sérialisées en JSON : passe des
   scalaires préparés dans le contrôleur, jamais une collection d'entités Doctrine.

```twig
{# lignesPanier = [{ id, nom, quantite, prixUnitaire }, …] #}
<div {{ react_component('Panier', { items: lignesPanier }) }}></div>
```

## Encaisser avec Stripe Checkout

La règle de base : **la session de paiement se crée côté serveur**, parce qu'elle exige une clé secrète qui
ne doit jamais atteindre le navigateur
([quickstart PHP](https://docs.stripe.com/payments/quickstart-checkout-sessions?lang=php)). Installe la
bibliothèque officielle, puis injecte la clé par variable d'environnement :

```bash
composer require stripe/stripe-php
```

```php
public function payer(Produit $produit, StripeClient $stripe): Response
{
    $session = $stripe->checkout->sessions->create([
        'mode' => 'payment',
        'line_items' => [[
            'quantity' => 1,
            'price_data' => [
                'currency' => 'eur',
                'unit_amount' => $produit->getPrix(),   // en centimes
                'product_data' => ['name' => $produit->getNom()],
            ],
        ]],
        'success_url' => $url . '?session_id={CHECKOUT_SESSION_ID}',
        'cancel_url' => $urlAnnulation,
    ]);

    return $this->redirect($session->url, 303);
}
```

`price_data` évite d'avoir deux sources de vérité pour le prix : ta base décide, Stripe encaisse. Le `mode`
accepte `payment` pour un achat ponctuel, `subscription` pour un abonnement et `setup` pour enregistrer un
moyen de paiement sans débiter. Dans ce mode hébergé, tu rediriges l'acheteur vers `$session->url` ; le
`client_secret` ne concerne que les modes intégrés (`embedded`, `custom`)
([API Checkout](https://docs.stripe.com/api/checkout/sessions)).

Côté clés : la clé publique porte le préfixe `pk`, la secrète `sk`, la restreinte `rk`, et le segment
`test` ou `live` dit l'environnement ([clés](https://docs.stripe.com/keys)). Le sandbox se teste avec la carte
4242 4242 4242 4242 ; en code, préfère `pm_card_visa`
([tests](https://docs.stripe.com/testing)).

## Les webhooks : ce qui rend la boutique fiable

Le navigateur de l'acheteur peut fermer l'onglet, perdre le réseau ou revenir en arrière après le
paiement. La seule source de vérité est le webhook : `checkout.session.completed` **quand
`payment_status` vaut `paid`**, sinon c'est `checkout.session.async_payment_succeeded` — ou
`checkout.session.async_payment_failed` — qui tranche pour les moyens asynchrones
([fulfillment](https://docs.stripe.com/checkout/fulfillment)).

```php
#[Route('/webhook/stripe', methods: ['POST'])]
public function webhook(Request $request, string $secret): Response
{
    try {
        $event = Webhook::constructEvent(
            $request->getContent(),                          // corps brut
            $request->headers->get('Stripe-Signature', ''),
            $secret,
        );
    } catch (SignatureVerificationException) {
        return new Response('Signature invalide', 400);
    }

    // traiter l'événement, puis répondre vite
    return new Response('ok', 200);
}
```

Quatre règles, toutes documentées :

1. **Vérifie la signature** avec le corps **brut**, l'en-tête `Stripe-Signature` et le secret de
   l'endpoint : un corps JSON analysé puis ré-encodé ne valide plus
   ([signature](https://docs.stripe.com/webhooks/signature)).
2. **Laisse la route accessible sans authentification** : elle ne doit pas tomber sous un `access_control`
   qui exige un utilisateur connecté. La signature remplace l'authentification
   ([sécurité Symfony](https://symfony.com/doc/current/security.html)).
3. **Sois idempotent** : un événement peut arriver plusieurs fois et l'ordre n'est pas garanti. Journalise
   son identifiant et ignore ce que tu as déjà traité ([webhooks](https://docs.stripe.com/webhooks)).
4. **Réponds vite** et traite le reste en asynchrone, avec Symfony Messenger.

En local, la CLI Stripe fait le travail — en adaptant le port à ton serveur Symfony, 8000 par défaut :
`stripe listen --forward-to localhost:8000/webhook/stripe`, puis
`stripe trigger checkout.session.completed` pour provoquer l'événement
([CLI](https://docs.stripe.com/stripe-cli)).

## Déployer

La page de déploiement de Symfony donne les étapes : envoyer le code, installer les dépendances **sans les
paquets de développement**, jouer les migrations, vider le cache
([deployment](https://symfony.com/doc/current/deployment.html)). L'ordre compte, et l'environnement doit
être en production **avant** les commandes console :

```bash
composer install --no-dev --optimize-autoloader
composer dump-env prod
APP_ENV=prod APP_DEBUG=0 php bin/console doctrine:migrations:migrate --no-interaction
APP_ENV=prod APP_DEBUG=0 php bin/console asset-map:compile   # ou npm run build avec Encore
APP_ENV=prod APP_DEBUG=0 php bin/console cache:clear
```

Les variables d'environnement doivent venir du serveur, et `var/cache/` comme `var/log/` doivent être
accessibles en écriture au serveur web
([permissions](https://symfony.com/doc/current/setup/file_permissions.html)). Nginx doit être configuré
pour Symfony : la documentation fournit les blocs à recopier
([web server](https://symfony.com/doc/current/setup/web_server_configuration.html)).

Sur un VPS Hostinger, tu te connectes en SSH ou par la console web du hPanel, dont la documentation
explique l'accès ([SSH](https://www.hostinger.com/support/5723772-how-to-connect-to-your-vps-via-ssh-at-hostinger/)).
Pour le TLS, la méthode recommandée par Certbot reste la référence
([certbot.eff.org](https://certbot.eff.org/)) — vérifie la procédure en vigueur pour ton image, les
tutoriels d'hébergeur vieillissent vite. Si tu préfères les conteneurs, le Docker Manager du hPanel
déploie un fichier compose depuis l'interface
([Docker](https://www.hostinger.com/support/12040815-how-to-deploy-your-first-container-with-hostinger-docker-manager/)),
ce qui rejoint la mise en production d'un projet Symfony
[avec Docker et FrankenPHP](/blog/mise-en-production-dun-projet-symfony-avec-docker-et-frankenphp/).

Si tu débutes sur le framework, la
[série de tutoriels Symfony](/blog/creer-une-application-web-complete-avec-symfony-5-et-php-8/) part de
Symfony 5 : les concepts tiennent toujours, mais vérifie les versions des composants avant de recopier une
commande.

Dans la vidéo, je parcours le projet complet, du modèle de données au paiement encaissé.

## Pour approfondir

**[Symfony et backend : du premier écran à la production](https://pentiminax.fr/blog/symfony-backend.md)**

Un parcours Symfony par objectif : Twig et Doctrine, projets concrets, déploiement continu, permissions et diagnostic à partir des articles du site.

[Parcourir le guide Symfony et backend](https://pentiminax.fr/blog/symfony-backend.md)

## Questions fréquentes

**Stripe Checkout ou Payment Intents ?**

Stripe recommande l'API Checkout Sessions pour la plupart des intégrations, et réserve Payment Intents aux cas où tu veux maîtriser toi-même la taxe, les remises, les abonnements ou la devise. Une boutique classique part donc sur Checkout Sessions, avec moins de code à écrire et le formulaire de carte rendu par Stripe.

**Où stocker la clé secrète Stripe ?**

Jamais dans le code ni dans un fichier versionné. La clé secrète (préfixe `sk`) vit dans une variable d'environnement côté serveur ou dans un coffre de secrets, tandis que la clé publique (préfixe `pk`) est la seule à pouvoir être envoyée au navigateur. Chaque environnement a ses clés — segment `test` ou `live` — et le webhook possède son propre secret de signature (préfixe `whsec`).

**Comment tester le paiement sans vraie carte ?**

Dans un sandbox Stripe, la carte 4242 4242 4242 4242 accompagnée d'une date future et d'un CVC à trois chiffres suffit. En code, préfère un moyen de paiement de test du type pm_card_visa à un numéro recopié ; un sandbox ne traite rien sur les réseaux de cartes et ne sert pas à faire du test de charge.

**Pourquoi la signature du webhook est-elle refusée ?**

Presque toujours parce que le corps de la requête a été lu comme du JSON déjà analysé, puis ré-encodé : la vérification a besoin du corps brut, en UTF-8, tel que Stripe l'a envoyé. Utilise la méthode constructEvent() de la bibliothèque stripe-php avec $request->getContent(), l'en-tête Stripe-Signature et le secret de l'endpoint, et garde la route accessible sans authentification.

**Faut-il du React pour un site e-commerce Symfony ?**

Non, et il ne faut pas le croire : Symfony UX React monte des composants React dans une page Twig, ce n'est pas une application SPA. C'est pratique pour quelques zones interactives (panier, filtres, sélecteur de variante) sans réécrire le site, et le chargement paresseux des composants n'est pas supporté : si tu les enregistres avec import.meta.glob(), passe l'option eager: true.
