Ce guide vous explique comment créer une application e-commerce complète avec Symfony 8.0, incluant la gestion des produits, catégories, commandes, authentification utilisateur et interface d'administration.
Prérequis : Avoir Symfony 8.0 installé et configuré.
📋 Vue d'ensemble
Ce guide vous permettra de créer :
- ✅ Catalogue produits avec catégories (relation ManyToMany)
- ✅ Gestion des commandes (Order et OrderItem)
- ✅ Authentification utilisateur (inscription, connexion, vérification email)
- ✅ Interface d'administration (CRUD pour toutes les entités)
- ✅ Sécurité (protection des routes par rôles)
🚀 Étape 1 : Créer le projet Symfony
Créer un nouveau projet
# Créer un nouveau projet Symfony 8.0 avec webapp
symfony new mon-ecommerce --version='8.0.*' --webapp
# Ou avec Composer
composer create-project symfony/skeleton:'8.0.*' mon-ecommerce
cd mon-ecommerce
composer require webapp
Installer les packages nécessaires
cd mon-ecommerce
# ORM Doctrine (base de données)
composer require symfony/orm-pack
# Maker Bundle (générateur de code)
composer require --dev symfony/maker-bundle
# Security Bundle (authentification)
composer require symfony/security-bundle
# Formulaires
composer require symfony/form
# Validation
composer require symfony/validator
# Twig (templates)
composer require symfony/twig-pack
# Mailer (emails)
composer require symfony/mailer
# Vérification d'email
composer require symfonycasts/verify-email-bundle
# Profiler (débogage)
composer require --dev symfony/profiler-pack
🗄️ Étape 2 : Configuration de la base de données
Configurer la connexion
Éditez le fichier .env :
DATABASE_URL='mysql://root:password@127.0.0.1:3306/ecommerce_db?serverVersion=8.0.32&charset=utf8mb4'
Créer la base de données
php bin/console doctrine:database:create
📦 Étape 3 : Créer les entités
Entité Category (Catégorie)
php bin/console make:entity Category
Remplissez les champs suivants :
name(string, 255, not null)description(string, 255, nullable)slug(string, 255, not null)createdAt(datetime_immutable, nullable)updatedAt(datetime_immutable, nullable)
Entité Product (Produit)
php bin/console make:entity Product
Remplissez les champs suivants :
name(string, 255, not null)description(string, 255, nullable)price(decimal, precision: 10, scale: 2, not null)stock(integer, not null)image(string, 255, nullable)slug(string, 255, not null)createdAt(datetime_immutable, nullable)updatedAt(datetime_immutable, nullable)categories(relation ManyToMany vers Category)
Important : Lors de la création de la relation ManyToMany, choisissez :
- Type de relation :
ManyToMany - Entité cible :
Category - Propriété dans Category :
products - Voulez-vous ajouter une nouvelle propriété dans Category ? :
yes - Relation propriétaire :
Product(côté Product)
Entité User (Utilisateur)
php bin/console make:user User
Choisissez :
- Utiliser un email comme identifiant :
yes - Stocker le mot de passe dans la base de données :
yes
Ensuite, ajoutez des champs supplémentaires :
php bin/console make:entity User
Ajoutez :
firstName(string, 255, not null)lastName(string, 255, not null)address(string, 255, nullable)city(string, 255, nullable)postalCode(string, 20, nullable)phone(string, 50, nullable)isVerified(boolean, not null, default: false)
Entité Order (Commande)
php bin/console make:entity Order
Remplissez les champs suivants :
orderNumber(string, 50, not null)status(string, 50, not null)total(decimal, precision: 10, scale: 2, not null)createdAt(datetime_immutable, nullable)updatedAt(datetime_immutable, nullable)user(relation ManyToOne vers User, not null)
Note : Doctrine créera automatiquement une table nommée `order` (avec backticks car "order" est un mot réservé SQL).
Entité OrderItem (Article de commande)
php bin/console make:entity OrderItem
Remplissez les champs suivants :
quantity(integer, not null)price(decimal, precision: 10, scale: 2, not null)orderId(relation ManyToOne vers Order, not null)product(relation ManyToOne vers Product, not null)
Explication de la relation ManyToMany
La relation ManyToMany entre Product et Category permet :
- Un produit peut appartenir à plusieurs catégories : Un produit peut être classé dans plusieurs catégories simultanément
- Une catégorie peut contenir plusieurs produits : Une catégorie peut regrouper de nombreux produits
- Table de jointure automatique : Doctrine crée automatiquement la table
product_categorypour gérer la relation
Structure de la table de jointure :
product_id(clé étrangère versproduct)category_id(clé étrangère verscategory)- Clé primaire composite :
(product_id, category_id)
Générer les migrations
php bin/console make:migration
php bin/console doctrine:migrations:migrate
🔐 Étape 4 : Configuration de l'authentification
Créer l'authentification
php bin/console make:auth
Choisissez :
- Type d'authentification :
Login form authenticator - Nom de la classe :
AppAuthenticator - Route de redirection après connexion :
/home
Configurer Security
Le fichier config/packages/security.yaml est automatiquement configuré. Vérifiez qu'il contient :
security:
password_hashers:
SymfonyComponentSecurityCoreUserPasswordAuthenticatedUserInterface: 'auto'
providers:
app_user_provider:
entity:
class: AppEntityUser
property: email
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|assets|build)/
security: false
main:
lazy: true
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
csrf_token_id: authenticate
username_parameter: email
password_parameter: password
logout:
path: app_logout
target: app_home
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/profile, roles: ROLE_USER }
Créer le contrôleur d'authentification
php bin/console make:controller SecurityController
Le contrôleur généré contient les méthodes login() et logout().
Créer le contrôleur d'inscription
php bin/console make:registration-form
Choisissez :
- Voulez-vous ajouter la vérification d'email ? :
yes - Classe EmailVerifier :
AppSecurityEmailVerifier
Le système génère automatiquement :
RegistrationControlleravec gestion de l'inscription et vérification d'emailRegistrationFormTypeavec les champs : firstName, lastName, email, agreeTerms, plainPasswordEmailVerifierpour la vérification d'email- Templates Twig pour l'inscription et la confirmation d'email
Adapter le formulaire d'inscription
Le formulaire RegistrationFormType est généré automatiquement avec les champs de base. Il inclut :
firstNameetlastName(mappés à l'entité User)email(mappé à l'entité User)agreeTerms(non mappé, avec validation IsTrue)plainPassword(non mappé, avec validation NotBlank et Length)
Le mot de passe est automatiquement hashé dans le contrôleur avant la persistance.
🛍️ Étape 5 : Créer les CRUD avec make:crud
CRUD pour Category
php bin/console make:crud Category
Choisissez :
- Voulez-vous générer les routes avec des préfixes ? :
no - Classe du contrôleur :
CategoryController
Le système génère automatiquement :
CategoryControlleravec les actions : index, new, show, edit, deleteCategoryType(formulaire)- Templates Twig : index, new, show, edit, _form, _delete_form
CRUD pour Product
php bin/console make:crud Product
Le système génère automatiquement :
ProductControlleravec les actions : index, new, show, edit, deleteProductType(formulaire)- Templates Twig : index, new, show, edit, _form, _delete_form
CRUD pour Order
php bin/console make:crud Order
Le système génère automatiquement :
OrderControlleravec les actions : index, new, show, edit, deleteOrderType(formulaire)- Templates Twig : index, new, show, edit, _form, _delete_form
CRUD pour OrderItem
php bin/console make:crud OrderItem
Le système génère automatiquement :
OrderItemControlleravec les actions : index, new, show, edit, deleteOrderItemType(formulaire)- Templates Twig : index, new, show, edit, _form, _delete_form
Adapter les formulaires
Les formulaires générés par make:crud incluent tous les champs de l'entité. Pour les relations ManyToMany et ManyToOne, adaptez les formulaires :
Dans ProductType.php :
- Le champ
categoriesest automatiquement généré enEntityTypeavecmultiple => true - Vous pouvez améliorer l'affichage en changeant
choice_labeldeidàname
Dans OrderType.php :
- Le champ
userest automatiquement généré enEntityType - Vous pouvez améliorer l'affichage en changeant
choice_labeldeidàemail
Dans OrderItemType.php :
- Les champs
orderIdetproductsont automatiquement générés enEntityType - Vous pouvez améliorer l'affichage en changeant
choice_labeldeidà un champ plus lisible
Protéger les routes CRUD
Ajoutez l'attribut #[IsGranted('ROLE_ADMIN')] au-dessus de chaque classe de contrôleur CRUD :
use SymfonyComponentSecurityHttpAttributeIsGranted;
#[IsGranted('ROLE_ADMIN')]
#[Route('/product')]
final class ProductController extends AbstractController
{
// ...
}
Cela protège toutes les routes du contrôleur et nécessite le rôle ROLE_ADMIN pour y accéder.
🏠 Étape 6 : Créer le HomeController
php bin/console make:controller HomeController
Créez une route protégée par ROLE_USER :
use SymfonyComponentSecurityHttpAttributeIsGranted;
#[IsGranted('ROLE_USER')]
#[Route('/home', name: 'app_home')]
public function index(): Response
{
return $this->render('home/index.html.twig');
}
🎨 Étape 7 : Créer le template de base
Créez templates/base.html.twig avec une structure de sidebar pour l'interface d'administration, incluant :
- Navigation vers les différentes sections (Catégories, Produits, Commandes, etc.)
- Bouton de déconnexion
- Intégration Bootstrap et Font Awesome
📝 Étape 8 : Migrations supplémentaires
Si vous ajoutez des champs après la création initiale (comme isVerified pour User), créez une nouvelle migration :
php bin/console make:migration
php bin/console doctrine:migrations:migrate
🗂️ Étape 9 : Générer des données de test avec Doctrine Fixtures
Installer les packages nécessaires
composer require --dev orm-fixtures
composer require --dev fakerphp/faker
Créer les fixtures avec make:fixtures
php bin/console make:fixtures CategoryFixtures
php bin/console make:fixtures ProductFixtures
php bin/console make:fixtures UserFixtures
php bin/console make:fixtures OrderFixtures
php bin/console make:fixtures OrderItemFixtures
Structure des fixtures
Les fixtures sont créées dans src/DataFixtures/ et permettent de générer des données de test pour votre application.
CategoryFixtures : Crée 8 catégories prédéfinies (Électronique, Vêtements, Maison & Jardin, etc.)
ProductFixtures : Génère 50 produits avec Faker
- Nom, description, prix, stock générés aléatoirement
- Images optionnelles (70% de chance)
- Association à 1 catégorie aléatoire
- Dates de création et mise à jour réalistes
UserFixtures : Génère 21 utilisateurs (1 admin + 20 utilisateurs)
- Admin :
admin@example.com/admin123avecROLE_ADMIN - Utilisateurs : emails, noms, adresses générés avec Faker
- Mots de passe hashés avec
UserPasswordHasherInterface - 80% des utilisateurs vérifiés
OrderFixtures : Génère 30 commandes
- Numéros de commande au format
CMD-####-#### - Statuts aléatoires : pending, processing, shipped, delivered, cancelled
- Dates entre les 6 derniers mois
- Total initialisé à 0 (recalculé dans OrderItemFixtures)
OrderItemFixtures : Génère les articles de commande
- 1 à 5 articles par commande
- Quantité entre 1 et 3 par article
- Prix récupéré du produit
- Recalcule le total de chaque commande
Gérer les dépendances entre fixtures
Utilisez DependentFixtureInterface pour définir l'ordre de chargement :
use DoctrineCommonDataFixturesDependentFixtureInterface;
class ProductFixtures extends Fixture implements DependentFixtureInterface
{
public function getDependencies(): array
{
return [
CategoryFixtures::class,
];
}
}
Charger les fixtures
# Charger toutes les fixtures (vide la base avant)
php bin/console doctrine:fixtures:load
# Ajouter les fixtures sans vider la base
php bin/console doctrine:fixtures:load --append
# Charger un groupe spécifique
php bin/console doctrine:fixtures:load --group=CategoryFixtures
Concepts importants
$manager->persist($entity) : Prépare l'entité pour la sauvegarde (mise en file d'attente)
$manager->flush() : Exécute toutes les requêtes SQL en base de données
$this->addReference('name', $entity) : Stocke une référence à une entité pour la récupérer dans d'autres fixtures avec $this->getReference('name')
Faker : Bibliothèque pour générer des données réalistes (noms, adresses, dates, etc.)
Exemple de fixture complète
<?php
namespace AppDataFixtures;
use AppEntityCategory;
use DoctrineBundleFixturesBundleFixture;
use DoctrinePersistenceObjectManager;
class CategoryFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
$categories = [
['name' => 'Électronique', 'description' => 'Appareils électroniques'],
['name' => 'Vêtements', 'description' => 'Mode et habillement'],
];
foreach ($categories as $categoryData) {
$category = new Category();
$category->setName($categoryData['name']);
$category->setDescription($categoryData['description']);
$category->setSlug(strtolower(str_replace(' ', '-', $categoryData['name'])));
$category->setCreatedAt(new DateTimeImmutable());
$category->setUpdatedAt(new DateTimeImmutable());
$manager->persist($category);
}
$manager->flush();
}
}
🏠 Étape 10 : Créer la page d'accueil publique
Créer le controller Accueil
Créez src/Controller/AccueilController.php :
<?php
namespace AppController;
use AppRepositoryProductRepository;
use SymfonyBundleFrameworkBundleControllerAbstractController;
use SymfonyComponentHttpFoundationResponse;
use SymfonyComponentRoutingAttributeRoute;
final class AccueilController extends AbstractController
{
#[Route('/', name: 'app_accueil')]
public function index(ProductRepository $productRepository): Response
{
$products = $productRepository->findAll();
return $this->render('accueil/index.html.twig', [
'products' => $products,
]);
}
}
Points importants :
- Route
/: Page d'accueil accessible à tous (pas de protection) - Injection de
ProductRepository: Récupère tous les produits - Template
accueil/index.html.twig: Affiche les produits en grille
Créer le template de base pour l'accueil
Créez templates/base_accueil.html.twig :
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Accueil{% endblock %}</title>
{% block stylesheets %}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" rel="stylesheet">
<style>
body.page-accueil {
min-height: 100vh;
display: flex;
flex-direction: column;
margin: 0;
background-color: #f8f9fa;
}
body.page-accueil .navbar {
background-color: #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
body.page-accueil .product-card {
transition: transform 0.3s ease, box-shadow 0.3s ease;
height: 100%;
}
body.page-accueil .product-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
body.page-accueil .product-image {
height: 200px;
object-fit: cover;
width: 100%;
}
body.page-accueil main {
flex: 1;
}
body.page-accueil .footer {
background-color: #343a40;
color: #fff;
padding: 40px 0;
margin-top: auto;
}
</style>
{% endblock %}
</head>
<body class="page-accueil">
<nav class="navbar navbar-expand-lg navbar-light">
<div class="container">
<a class="navbar-brand fw-bold" href="{{ path('app_accueil') }}">E-Commerce</a>
<div class="collapse navbar-collapse">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="{{ path('app_accueil') }}">Accueil</a>
</li>
{% if app.user %}
<li class="nav-item">
<a class="nav-link" href="{{ path('app_home') }}">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ path('app_logout') }}">Déconnexion</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{{ path('app_login') }}">Connexion</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ path('app_register') }}">Inscription</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
<main>
{% block body %}{% endblock %}
</main>
<footer class="footer">
<div class="container text-center">
<p class="mb-0">© 2026 E-Commerce. Tous droits réservés.</p>
</div>
</footer>
</body>
</html>
Caractéristiques du template :
- Classe
page-accueil: Scopage des styles CSS pour éviter les conflits avecbase.html.twig - Flexbox layout : Footer toujours en bas de page même si le contenu est court
- Navigation conditionnelle : Affiche Dashboard/Déconnexion si connecté, Connexion/Inscription sinon
- Responsive : Bootstrap pour l'adaptation mobile
Créer la vue accueil
Créez templates/accueil/index.html.twig :
{% extends 'base_accueil.html.twig' %}
{% block title %}Accueil - Produits{% endblock %}
{% block body %}
<div class="container my-5">
<div class="row mb-4">
<div class="col-12">
<h1 class="display-4 text-center mb-4">Nos Produits</h1>
<p class="text-center text-muted">Découvrez notre sélection de produits</p>
</div>
</div>
<div class="row g-4">
{% for product in products %}
<div class="col-md-4 col-lg-3">
<div class="card product-card">
{% if product.image %}
<img src="{{ product.image }}" class="card-img-top product-image" alt="{{ product.name }}">
{% else %}
<div class="card-img-top product-image bg-secondary d-flex align-items-center justify-content-center">
<i class="fas fa-image fa-3x text-white"></i>
</div>
{% endif %}
<div class="card-body d-flex flex-column">
<h5 class="card-title">{{ product.name }}</h5>
<p class="card-text text-muted flex-grow-1">
{% if product.description %}
{{ product.description|length > 100 ? product.description|slice(0, 100) ~ '...' : product.description }}
{% else %}
Aucune description disponible
{% endif %}
</p>
<div class="mt-auto">
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="h5 text-primary mb-0">{{ product.price }} €</span>
{% if product.stock > 0 %}
<span class="badge bg-success">En stock ({{ product.stock }})</span>
{% else %}
<span class="badge bg-danger">Rupture de stock</span>
{% endif %}
</div>
<div class="d-grid gap-2">
<a href="{{ path('app_product_show', {'id': product.id}) }}" class="btn btn-primary">
<i class="fas fa-eye me-2"></i>Voir les détails
</a>
</div>
</div>
</div>
</div>
</div>
{% else %}
<div class="col-12">
<div class="alert alert-info text-center">
<i class="fas fa-info-circle me-2"></i>
Aucun produit disponible pour le moment.
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock %}
Fonctionnalités de la vue :
- Grille de produits : Affichage en cartes Bootstrap (4 colonnes sur desktop, responsive)
- Images produits : Affiche l'image si disponible, sinon icône placeholder
- Description tronquée : Limite à 100 caractères avec "..."
- Badge de stock : Vert si en stock, rouge si rupture
- Lien vers détails : Bouton vers la page de détail du produit
- Message si vide : Affiche un message si aucun produit
Différences entre base.html.twig et base_accueil.html.twig
base.html.twig (Interface d'administration) :
- Sidebar fixe à gauche
- Navigation verticale
- Protégé par
ROLE_USER - Utilisé pour les pages CRUD
base_accueil.html.twig (Page publique) :
- Navigation horizontale en haut
- Footer en bas
- Accessible à tous (pas de protection)
- Utilisé pour la page d'accueil publique
Points importants
- Scopage CSS : Utilisez
body.page-accueilpour éviter les conflits de styles - Flexbox pour footer :
display: flex+flex-direction: column+margin-top: autosur le footer - Responsive : Utilisez les classes Bootstrap
col-md-4 col-lg-3pour l'adaptation mobile - Performance : Considérez la pagination si vous avez beaucoup de produits
🛒 Étape 11 : Créer la page produit détaillée
Générer le contrôleur
php bin/console make:controller ProductShowController
Modifiez src/Controller/ProductShowController.php pour afficher les détails d'un produit. Ce contrôleur est public (pas de protection admin) :
<?php
namespace App\Controller;
use App\Entity\Product;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ProductShowController extends AbstractController
{
#[Route('/produit/{slug}', name: 'app_product_detail')]
public function show(Product $product): Response
{
return $this->render('product/show.html.twig', [
'product' => $product,
]);
}
}
Points importants :
- Route
/produit/{slug}: Utilise le slug du produit pour une URL SEO-friendly - ParamConverter automatique : Symfony convertit automatiquement le
{slug}en objetProductgrâce à Doctrine - Pas de protection : La page produit est accessible à tous les visiteurs
Créer le template product/show.html.twig
Créez templates/product/show.html.twig :
{% extends 'base_accueil.html.twig' %}
{% block title %}{{ product.name }} - E-Commerce{% endblock %}
{% block body %}
<div class="container my-5">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ path('app_accueil') }}">Accueil</a></li>
<li class="breadcrumb-item active">{{ product.name }}</li>
</ol>
</nav>
<div class="row">
<div class="col-md-6">
{% if product.image %}
<img src="{{ product.image }}" class="img-fluid rounded shadow" alt="{{ product.name }}">
{% else %}
<div class="bg-light d-flex align-items-center justify-content-center rounded shadow" style="height: 400px;">
<i class="fas fa-image fa-5x text-secondary"></i>
</div>
{% endif %}
</div>
<div class="col-md-6">
<h1 class="mb-3">{{ product.name }}</h1>
<div class="mb-3">
{% for category in product.categories %}
<span class="badge bg-info me-1">{{ category.name }}</span>
{% endfor %}
</div>
<p class="text-muted fs-5">{{ product.description }}</p>
<h2 class="text-primary mb-3">{{ product.price|number_format(2, ',', ' ') }} €</h2>
{% if product.stock > 0 %}
<span class="badge bg-success fs-6 mb-3">En stock ({{ product.stock }} disponibles)</span>
<form action="{{ path('app_cart_add', {id: product.id}) }}" method="POST" class="mt-3">
<div class="input-group mb-3" style="max-width: 200px;">
<label class="input-group-text" for="quantity">Qté</label>
<input type="number" class="form-control" id="quantity" name="quantity"
value="1" min="1" max="{{ product.stock }}">
</div>
<button type="submit" class="btn btn-primary btn-lg">
<i class="fas fa-cart-plus me-2"></i>Ajouter au panier
</button>
</form>
{% else %}
<span class="badge bg-danger fs-6 mb-3">Rupture de stock</span>
{% endif %}
<hr>
<a href="{{ path('app_accueil') }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>Retour aux produits
</a>
</div>
</div>
</div>
{% endblock %}
Fonctionnalités de la page produit :
- Fil d'Ariane (breadcrumb) : Navigation Accueil > Produit
- Image ou placeholder : Affiche l'image du produit ou une icône par défaut
- Catégories : Badges pour chaque catégorie du produit (relation ManyToMany)
- Prix formaté : Affichage avec 2 décimales et séparateur
- Gestion du stock : Formulaire d'ajout au panier si en stock, badge "Rupture" sinon
- Sélecteur de quantité : Champ numérique limité au stock disponible
Mettre à jour le lien sur la page d'accueil
Dans templates/accueil/index.html.twig, modifiez le bouton "Voir les détails" pour pointer vers la nouvelle route basée sur le slug :
<a href="{{ path('app_product_detail', {'slug': product.slug}) }}" class="btn btn-primary">
<i class="fas fa-eye me-2"></i>Voir les détails
</a>
🛒 Étape 12 : Système de panier et page panier
Concept du panier par session
Le panier est stocké dans la session PHP de l'utilisateur. Pas besoin de base de données pour le panier temporaire. On utilise directement $request->getSession() dans les contrôleurs, sans créer de service supplémentaire.
La structure en session est un tableau associatif simple :
// Structure de la session 'cart'
[
product_id => quantity,
// ex: 5 => 2 (2 exemplaires du produit #5)
// ex: 12 => 1 (1 exemplaire du produit #12)
]
Avantages :
- Pas de requête SQL pour gérer le panier
- Le panier persiste tant que la session est active
- Fonctionne même pour les utilisateurs non connectés
- Utilise uniquement les composants natifs de Symfony (
Request,Session)
Générer le CartController
php bin/console make:controller CartController
Modifiez src/Controller/CartController.php pour gérer toutes les opérations du panier :
<?php
namespace App\Controller;
use App\Repository\ProductRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/panier')]
final class CartController extends AbstractController
{
#[Route('', name: 'app_cart', methods: ['GET'])]
public function index(Request $request, ProductRepository $productRepository): Response
{
$cart = $request->getSession()->get('cart', []);
$cartData = [];
$total = 0;
foreach ($cart as $productId => $quantity) {
$product = $productRepository->find($productId);
if ($product) {
$subtotal = $product->getPrice() * $quantity;
$cartData[] = [
'product' => $product,
'quantity' => $quantity,
'subtotal' => $subtotal,
];
$total += $subtotal;
}
}
return $this->render('cart/index.html.twig', [
'cart' => $cartData,
'total' => $total,
]);
}
#[Route('/ajouter/{id}', name: 'app_cart_add', methods: ['POST'])]
public function add(int $id, Request $request): Response
{
$session = $request->getSession();
$cart = $session->get('cart', []);
$quantity = $request->request->getInt('quantity', 1);
if (isset($cart[$id])) {
$cart[$id] += $quantity;
} else {
$cart[$id] = $quantity;
}
$session->set('cart', $cart);
$this->addFlash('success', 'Produit ajouté au panier !');
return $this->redirectToRoute('app_cart');
}
#[Route('/supprimer/{id}', name: 'app_cart_remove', methods: ['POST'])]
public function remove(int $id, Request $request): Response
{
$session = $request->getSession();
$cart = $session->get('cart', []);
unset($cart[$id]);
$session->set('cart', $cart);
$this->addFlash('success', 'Produit retiré du panier.');
return $this->redirectToRoute('app_cart');
}
#[Route('/modifier/{id}', name: 'app_cart_update', methods: ['POST'])]
public function update(int $id, Request $request): Response
{
$session = $request->getSession();
$cart = $session->get('cart', []);
$quantity = $request->request->getInt('quantity', 1);
if ($quantity <= 0) {
unset($cart[$id]);
} else {
$cart[$id] = $quantity;
}
$session->set('cart', $cart);
$this->addFlash('success', 'Panier mis à jour.');
return $this->redirectToRoute('app_cart');
}
#[Route('/vider', name: 'app_cart_clear', methods: ['POST'])]
public function clear(Request $request): Response
{
$request->getSession()->remove('cart');
$this->addFlash('success', 'Panier vidé.');
return $this->redirectToRoute('app_cart');
}
}
Explication des méthodes :
| Méthode | Rôle |
|---|---|
index() |
Lit la session, récupère les objets Product via le ProductRepository, calcule les sous-totaux et le total |
add() |
Ajoute un produit à la session ou incrémente la quantité s'il existe déjà |
remove() |
Supprime un produit de la session avec unset() |
update() |
Met à jour la quantité (supprime si ≤ 0) |
clear() |
Vide le panier avec $session->remove('cart') |
Points importants :
$request->getSession(): Accès natif à la session Symfony, pas besoin de service customProductRepository: Injecté par Symfony grâce à l'autowiring pour récupérer les objetsProduct- Préfixe
/panier: L'attribut#[Route('/panier')]au niveau de la classe préfixe toutes les routes - Méthode POST : Toutes les actions de modification utilisent POST (sécurité contre les modifications accidentelles via URL)
- Flash messages :
$this->addFlash()stocke un message en session, affiché une seule fois au prochain chargement de page - Redirection PRG : Pattern Post/Redirect/Get pour éviter la re-soumission du formulaire au rafraîchissement
Créer le template cart/index.html.twig
Créez templates/cart/index.html.twig :
{% extends 'base_accueil.html.twig' %}
{% block title %}Mon Panier - E-Commerce{% endblock %}
{% block body %}
<div class="container my-5">
<h1 class="mb-4"><i class="fas fa-shopping-cart me-2"></i>Mon Panier</h1>
{% for message in app.flashes('success') %}
<div class="alert alert-success alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% if cart|length > 0 %}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-dark">
<tr>
<th>Produit</th>
<th>Prix unitaire</th>
<th style="width: 180px;">Quantité</th>
<th>Sous-total</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for item in cart %}
<tr>
<td>
<div class="d-flex align-items-center">
{% if item.product.image %}
<img src="{{ item.product.image }}" alt="{{ item.product.name }}"
class="rounded me-3" style="width: 60px; height: 60px; object-fit: cover;">
{% else %}
<div class="bg-light rounded me-3 d-flex align-items-center justify-content-center"
style="width: 60px; height: 60px;">
<i class="fas fa-image text-secondary"></i>
</div>
{% endif %}
<div>
<h6 class="mb-0">{{ item.product.name }}</h6>
</div>
</div>
</td>
<td>{{ item.product.price|number_format(2, ',', ' ') }} €</td>
<td>
<form action="{{ path('app_cart_update', {id: item.product.id}) }}" method="POST"
class="d-flex align-items-center">
<input type="number" name="quantity" value="{{ item.quantity }}"
min="1" max="{{ item.product.stock }}"
class="form-control form-control-sm" style="width: 70px;">
<button type="submit" class="btn btn-sm btn-outline-primary ms-2"
title="Mettre à jour">
<i class="fas fa-sync-alt"></i>
</button>
</form>
</td>
<td class="fw-bold">{{ item.subtotal|number_format(2, ',', ' ') }} €</td>
<td>
<form action="{{ path('app_cart_remove', {id: item.product.id}) }}" method="POST"
class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Supprimer">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="table-light">
<td colspan="3" class="text-end fw-bold fs-5">Total :</td>
<td class="fw-bold fs-5 text-primary">{{ total|number_format(2, ',', ' ') }} €</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
<div class="d-flex justify-content-between mt-4">
<form action="{{ path('app_cart_clear') }}" method="POST">
<button type="submit" class="btn btn-outline-danger"
onclick="return confirm('Êtes-vous sûr de vouloir vider le panier ?')">
<i class="fas fa-trash-alt me-2"></i>Vider le panier
</button>
</form>
<div>
<a href="{{ path('app_accueil') }}" class="btn btn-outline-secondary me-2">
<i class="fas fa-arrow-left me-2"></i>Continuer les achats
</a>
<a href="{{ path('app_checkout') }}" class="btn btn-success btn-lg">
<i class="fas fa-credit-card me-2"></i>Passer à la caisse
</a>
</div>
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-shopping-cart fa-4x text-muted mb-3 d-block"></i>
<h3 class="text-muted">Votre panier est vide</h3>
<p class="text-muted mb-4">Découvrez nos produits et ajoutez-les à votre panier.</p>
<a href="{{ path('app_accueil') }}" class="btn btn-primary btn-lg">
<i class="fas fa-shopping-bag me-2"></i>Voir les produits
</a>
</div>
{% endif %}
</div>
{% endblock %}
Fonctionnalités de la page panier :
- Tableau produits : Image, nom, prix unitaire, quantité modifiable, sous-total
- Modification en ligne : Champ de quantité avec bouton de mise à jour
- Suppression : Bouton poubelle pour retirer un produit
- Total calculé : Affiché dans le pied de tableau
- Actions : Vider le panier (avec confirmation), continuer les achats, passer à la caisse
- État vide : Message et lien vers les produits si le panier est vide
- Flash messages : Affichage des messages de confirmation
Ajouter le lien panier dans la navigation
Dans templates/base_accueil.html.twig, ajoutez un lien vers le panier dans la navbar, avant les liens de connexion :
<li class="nav-item">
<a class="nav-link" href="{{ path('app_cart') }}">
<i class="fas fa-shopping-cart"></i> Panier
</a>
</li>
💳 Étape 13 : Page de validation de commande
Générer le CheckoutController
php bin/console make:controller CheckoutController
Modifiez src/Controller/CheckoutController.php :
<?php
namespace App\Controller;
use App\Repository\ProductRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_USER')]
final class CheckoutController extends AbstractController
{
#[Route('/commande', name: 'app_checkout')]
public function index(Request $request, ProductRepository $productRepository): Response
{
$cart = $request->getSession()->get('cart', []);
if (empty($cart)) {
$this->addFlash('warning', 'Votre panier est vide.');
return $this->redirectToRoute('app_cart');
}
$cartData = [];
$total = 0;
foreach ($cart as $productId => $quantity) {
$product = $productRepository->find($productId);
if ($product) {
$subtotal = $product->getPrice() * $quantity;
$cartData[] = [
'product' => $product,
'quantity' => $quantity,
'subtotal' => $subtotal,
];
$total += $subtotal;
}
}
return $this->render('checkout/index.html.twig', [
'cart' => $cartData,
'total' => $total,
'user' => $this->getUser(),
]);
}
}
Points importants :
#[IsGranted('ROLE_USER')]: L'utilisateur doit être connecté pour accéder à la page de validation- Lecture de la session : On lit directement
$request->getSession()->get('cart', []), même logique que dans leCartController - Vérification panier vide : Redirige vers le panier avec un message d'avertissement si le panier est vide
- Données utilisateur : Les informations du profil (adresse, ville, etc.) sont envoyées au template pour pré-remplir le formulaire
Créer le template checkout/index.html.twig
Créez templates/checkout/index.html.twig :
{% extends 'base_accueil.html.twig' %}
{% block title %}Validation de commande - E-Commerce{% endblock %}
{% block body %}
<div class="container my-5">
<h1 class="mb-4"><i class="fas fa-clipboard-check me-2"></i>Validation de commande</h1>
<div class="row">
<div class="col-md-7">
<div class="card shadow-sm mb-4">
<div class="card-header bg-dark text-white">
<h5 class="mb-0"><i class="fas fa-truck me-2"></i>Informations de livraison</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-bold">Prénom</label>
<input type="text" class="form-control" value="{{ user.firstName }}" readonly>
</div>
<div class="col-md-6">
<label class="form-label fw-bold">Nom</label>
<input type="text" class="form-control" value="{{ user.lastName }}" readonly>
</div>
<div class="col-12">
<label class="form-label fw-bold">Email</label>
<input type="email" class="form-control" value="{{ user.email }}" readonly>
</div>
<div class="col-12">
<label class="form-label fw-bold">Adresse</label>
<input type="text" class="form-control"
value="{{ user.address is defined and user.address ? user.address : '' }}" readonly>
</div>
<div class="col-md-4">
<label class="form-label fw-bold">Code postal</label>
<input type="text" class="form-control"
value="{{ user.postalCode is defined and user.postalCode ? user.postalCode : '' }}" readonly>
</div>
<div class="col-md-4">
<label class="form-label fw-bold">Ville</label>
<input type="text" class="form-control"
value="{{ user.city is defined and user.city ? user.city : '' }}" readonly>
</div>
<div class="col-md-4">
<label class="form-label fw-bold">Téléphone</label>
<input type="text" class="form-control"
value="{{ user.phone is defined and user.phone ? user.phone : '' }}" readonly>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-header bg-dark text-white">
<h5 class="mb-0"><i class="fas fa-receipt me-2"></i>Récapitulatif de la commande</h5>
</div>
<div class="card-body">
{% for item in cart %}
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<strong>{{ item.product.name }}</strong>
<br><small class="text-muted">{{ item.product.price|number_format(2, ',', ' ') }} € × {{ item.quantity }}</small>
</div>
<span class="fw-bold">{{ item.subtotal|number_format(2, ',', ' ') }} €</span>
</div>
{% if not loop.last %}<hr class="my-2">{% endif %}
{% endfor %}
<hr>
<div class="d-flex justify-content-between align-items-center">
<strong class="fs-5">Total à payer</strong>
<strong class="fs-4 text-primary">{{ total|number_format(2, ',', ' ') }} €</strong>
</div>
</div>
<div class="card-footer">
<form action="{{ path('app_payment_checkout') }}" method="POST">
<button type="submit" class="btn btn-success btn-lg w-100">
<i class="fas fa-lock me-2"></i>Payer {{ total|number_format(2, ',', ' ') }} € par carte
</button>
</form>
<p class="text-center text-muted mt-2 mb-1">
<i class="fas fa-shield-alt me-1"></i>Paiement sécurisé par Stripe
</p>
<a href="{{ path('app_cart') }}" class="btn btn-outline-secondary w-100 mt-2">
<i class="fas fa-arrow-left me-2"></i>Modifier le panier
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
Fonctionnalités de la page de validation :
- Colonne gauche : Informations de livraison pré-remplies depuis le profil utilisateur (en lecture seule)
- Colonne droite : Récapitulatif de commande avec détail des articles, prix × quantité, et total
- Bouton de paiement : Lance le processus de paiement Stripe Checkout
- Mention sécurité : Rassure l'utilisateur sur la sécurité du paiement
- Retour panier : Lien pour modifier le panier avant de payer
💰 Étape 14 : Intégration Stripe pour le paiement par carte bancaire
Créer un compte Stripe de test
- Rendez-vous sur https://dashboard.stripe.com/register
- Créez un compte (gratuit, aucune carte bancaire requise)
- Une fois connecté, activez le mode test avec le toggle en haut à droite du dashboard
- Allez dans Développeurs > Clés API pour récupérer :
- Clé publique (publishable key) : commence par
pk_test_... - Clé secrète (secret key) : commence par
sk_test_...
- Clé publique (publishable key) : commence par
⚠️ Ne partagez jamais votre clé secrète et ne la committez pas dans Git.
Installer le SDK Stripe
composer require stripe/stripe-php
Configurer les clés API
Ajoutez les variables d'environnement dans .env (et .env.local pour les vraies valeurs) :
# .env (valeurs par défaut / documentation)
STRIPE_SECRET_KEY=sk_test_VOTRE_CLE_SECRETE
STRIPE_PUBLIC_KEY=pk_test_VOTRE_CLE_PUBLIQUE
STRIPE_WEBHOOK_SECRET=whsec_VOTRE_SECRET_WEBHOOK
Puis référencez-les dans config/services.yaml :
# config/services.yaml
parameters:
app.stripe_secret_key: '%env(STRIPE_SECRET_KEY)%'
app.stripe_public_key: '%env(STRIPE_PUBLIC_KEY)%'
app.stripe_webhook_secret: '%env(STRIPE_WEBHOOK_SECRET)%'
Générer le PaymentController
php bin/console make:controller PaymentController
Modifiez src/Controller/PaymentController.php :
<?php
namespace App\Controller;
use App\Entity\Order;
use App\Entity\OrderItem;
use App\Repository\ProductRepository;
use Doctrine\ORM\EntityManagerInterface;
use Stripe\Checkout\Session as StripeSession;
use Stripe\Stripe;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_USER')]
final class PaymentController extends AbstractController
{
#[Route('/paiement/checkout', name: 'app_payment_checkout', methods: ['POST'])]
public function checkout(Request $request, ProductRepository $productRepository): Response
{
$cart = $request->getSession()->get('cart', []);
if (empty($cart)) {
$this->addFlash('warning', 'Votre panier est vide.');
return $this->redirectToRoute('app_cart');
}
Stripe::setApiKey($this->getParameter('app.stripe_secret_key'));
$lineItems = [];
foreach ($cart as $productId => $quantity) {
$product = $productRepository->find($productId);
if ($product) {
$lineItems[] = [
'price_data' => [
'currency' => 'eur',
'product_data' => [
'name' => $product->getName(),
],
'unit_amount' => (int) ($product->getPrice() * 100),
],
'quantity' => $quantity,
];
}
}
$stripeSession = StripeSession::create([
'payment_method_types' => ['card'],
'line_items' => $lineItems,
'mode' => 'payment',
'success_url' => $this->generateUrl(
'app_payment_success',
[],
UrlGeneratorInterface::ABSOLUTE_URL
) . '?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => $this->generateUrl(
'app_payment_cancel',
[],
UrlGeneratorInterface::ABSOLUTE_URL
),
'customer_email' => $this->getUser()->getEmail(),
]);
return $this->redirect($stripeSession->url);
}
#[Route('/paiement/succes', name: 'app_payment_success')]
public function success(Request $request, ProductRepository $productRepository, EntityManagerInterface $em): Response
{
$session = $request->getSession();
$cart = $session->get('cart', []);
if (!empty($cart)) {
$order = new Order();
$order->setUser($this->getUser());
$order->setOrderNumber('CMD-' . strtoupper(substr(uniqid(), -8)));
$order->setStatus('paid');
$order->setCreatedAt(new \DateTimeImmutable());
$order->setUpdatedAt(new \DateTimeImmutable());
$total = 0;
foreach ($cart as $productId => $quantity) {
$product = $productRepository->find($productId);
if ($product) {
$orderItem = new OrderItem();
$orderItem->setOrderId($order);
$orderItem->setProduct($product);
$orderItem->setQuantity($quantity);
$orderItem->setPrice($product->getPrice());
$em->persist($orderItem);
$total += $product->getPrice() * $quantity;
}
}
$order->setTotal($total);
$em->persist($order);
$em->flush();
$session->remove('cart');
}
return $this->render('payment/success.html.twig');
}
#[Route('/paiement/annulation', name: 'app_payment_cancel')]
public function cancel(): Response
{
return $this->render('payment/cancel.html.twig');
}
}
Explication du flux de paiement :
-
checkout(): Crée une Stripe Checkout Session avec les articles du panier- Lit la session pour récupérer le panier
[productId => quantity] - Récupère chaque
Productvia leProductRepository unit_amount: Stripe attend le montant en centimes (19,99 € → 1999){CHECKOUT_SESSION_ID}: Placeholder remplacé automatiquement par Stripe dans l'URL de succèscustomer_email: Pré-remplit l'email dans le formulaire Stripe- Redirige le navigateur vers la page de paiement hébergée par Stripe
- Lit la session pour récupérer le panier
-
success(): Appelé après un paiement réussi- Lit la session pour récupérer le panier
- Crée l'entité
Orderavec le statutpaid - Crée les
OrderItempour chaque produit du panier - Vide le panier avec
$session->remove('cart')
-
cancel(): Appelé si l'utilisateur annule le paiement- Affiche un message, le panier reste intact en session
Créer les templates de paiement
templates/payment/success.html.twig :
{% extends 'base_accueil.html.twig' %}
{% block title %}Paiement réussi - E-Commerce{% endblock %}
{% block body %}
<div class="container my-5 text-center">
<div class="py-5">
<i class="fas fa-check-circle fa-5x text-success mb-4 d-block"></i>
<h1 class="text-success mb-3">Paiement réussi !</h1>
<p class="fs-5 text-muted">Merci pour votre commande. Vous recevrez un email de confirmation sous peu.</p>
<div class="mt-4">
<a href="{{ path('app_accueil') }}" class="btn btn-primary btn-lg me-2">
<i class="fas fa-home me-2"></i>Retour à l'accueil
</a>
<a href="{{ path('app_home') }}" class="btn btn-outline-secondary btn-lg">
<i class="fas fa-list me-2"></i>Mes commandes
</a>
</div>
</div>
</div>
{% endblock %}
templates/payment/cancel.html.twig :
{% extends 'base_accueil.html.twig' %}
{% block title %}Paiement annulé - E-Commerce{% endblock %}
{% block body %}
<div class="container my-5 text-center">
<div class="py-5">
<i class="fas fa-times-circle fa-5x text-warning mb-4 d-block"></i>
<h1 class="text-warning mb-3">Paiement annulé</h1>
<p class="fs-5 text-muted">Le paiement a été annulé. Votre panier est toujours disponible.</p>
<div class="mt-4">
<a href="{{ path('app_cart') }}" class="btn btn-primary btn-lg me-2">
<i class="fas fa-shopping-cart me-2"></i>Retour au panier
</a>
<a href="{{ path('app_accueil') }}" class="btn btn-outline-secondary btn-lg">
<i class="fas fa-home me-2"></i>Retour à l'accueil
</a>
</div>
</div>
</div>
{% endblock %}
Générer le contrôleur Webhook Stripe
Les webhooks permettent à Stripe de notifier votre serveur quand un événement se produit (paiement réussi, échoué, remboursement, etc.).
php bin/console make:controller StripeWebhookController
Modifiez src/Controller/StripeWebhookController.php :
<?php
namespace App\Controller;
use App\Repository\OrderRepository;
use Doctrine\ORM\EntityManagerInterface;
use Stripe\Event;
use Stripe\Stripe;
use Stripe\Webhook;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class StripeWebhookController extends AbstractController
{
#[Route('/webhook/stripe', name: 'app_stripe_webhook', methods: ['POST'])]
public function handleWebhook(
Request $request,
OrderRepository $orderRepository,
EntityManagerInterface $em
): JsonResponse {
Stripe::setApiKey($this->getParameter('app.stripe_secret_key'));
$payload = $request->getContent();
$sigHeader = $request->headers->get('Stripe-Signature');
$webhookSecret = $this->getParameter('app.stripe_webhook_secret');
try {
$event = Webhook::constructEvent($payload, $sigHeader, $webhookSecret);
} catch (\UnexpectedValueException $e) {
return new JsonResponse(
['error' => 'Payload invalide'],
Response::HTTP_BAD_REQUEST
);
} catch (\Stripe\Exception\SignatureVerificationException $e) {
return new JsonResponse(
['error' => 'Signature invalide'],
Response::HTTP_BAD_REQUEST
);
}
switch ($event->type) {
case 'checkout.session.completed':
$session = $event->data->object;
// Le paiement a été accepté
// $session->customer_email contient l'email du client
// $session->amount_total contient le montant total en centimes
// $session->payment_status contient le statut ('paid')
break;
case 'payment_intent.succeeded':
$paymentIntent = $event->data->object;
// Le paiement a été effectivement encaissé
break;
case 'payment_intent.payment_failed':
$paymentIntent = $event->data->object;
// Le paiement a échoué
// $paymentIntent->last_payment_error->message contient le message d'erreur
break;
}
return new JsonResponse(['status' => 'success']);
}
}
Points importants sur les webhooks :
- Vérification de signature :
Webhook::constructEvent()vérifie que la requête vient réellement de Stripe (protection contre les requêtes falsifiées) - Pas d'authentification : L'endpoint webhook ne doit pas être protégé par le firewall Symfony
- Réponse JSON : Stripe attend une réponse HTTP 200 pour confirmer la réception
- Idempotence : Les webhooks peuvent être envoyés plusieurs fois, votre code doit le gérer
Désactiver le firewall pour le webhook
Dans config/packages/security.yaml, ajoutez un firewall spécifique pour le webhook avant le firewall main :
# config/packages/security.yaml
security:
firewalls:
webhook:
pattern: ^/webhook
security: false
dev:
pattern: ^/(_(profiler|wdt)|assets|build)/
security: false
main:
lazy: true
# ... reste de la configuration
Important : Le firewall webhook doit être déclaré avant main car Symfony les évalue dans l'ordre.
Installer Stripe CLI
Stripe CLI est un outil en ligne de commande pour tester les webhooks en local.
# macOS (Homebrew)
brew install stripe/stripe-cli/stripe
# Linux (téléchargement direct)
curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg
echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee /etc/apt/sources.list.d/stripe.list
sudo apt update && sudo apt install stripe
# Windows (Scoop)
scoop install stripe
Se connecter à Stripe CLI
stripe login
Cette commande ouvre une page dans le navigateur pour autoriser l'accès à votre compte Stripe.
Transférer les webhooks vers votre serveur local (forward-to)
C'est la commande clé pour tester les webhooks en développement :
stripe listen --forward-to http://localhost:8000/webhook/stripe
Ce que fait cette commande :
- Se connecte au serveur Stripe en temps réel
- Intercepte tous les événements Stripe (paiements, remboursements, etc.)
- Les redirige vers votre endpoint local
http://localhost:8000/webhook/stripe - Affiche en temps réel chaque événement reçu et la réponse de votre serveur
Au lancement, la commande affiche un webhook signing secret :
> Ready! Your webhook signing secret is whsec_xxxxxxxxxxxxxxxxxxxxxxxx
⚠️ Copiez ce whsec_... et mettez-le dans votre fichier .env.local :
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx
Déclencher des événements de test
Dans un second terminal, vous pouvez simuler des événements Stripe :
# Déclencher un checkout complet (le plus utile pour tester)
stripe trigger checkout.session.completed
# Déclencher un paiement réussi
stripe trigger payment_intent.succeeded
# Déclencher un échec de paiement
stripe trigger payment_intent.payment_failed
# Déclencher un remboursement
stripe trigger charge.refunded
# Lister tous les événements disponibles
stripe trigger --list
Flux de test complet
Ouvrez 3 terminaux pour tester l'intégralité du flux de paiement :
Terminal 1 — Lancez le serveur Symfony :
symfony serve
Terminal 2 — Lancez le forwarding Stripe CLI :
stripe listen --forward-to http://localhost:8000/webhook/stripe
Terminal 3 — (Optionnel) Déclenchez des événements manuellement :
stripe trigger checkout.session.completed
Dans le navigateur :
- Allez sur
http://localhost:8000 - Connectez-vous avec un compte utilisateur
- Ajoutez des produits au panier
- Allez sur la page panier, cliquez "Passer à la caisse"
- Sur la page de validation, cliquez "Payer par carte"
- Vous êtes redirigé vers Stripe Checkout (page hébergée par Stripe)
- Utilisez une carte de test (voir ci-dessous)
- Après le paiement, vous êtes redirigé vers la page de succès
- Dans le Terminal 2, vous voyez les événements reçus en temps réel
Cartes de test Stripe
| Numéro de carte | Comportement |
|---|---|
