Projet de Synthèse : Marketplace de Produits Numériques avec Laravel 12

Construire une plateforme e-commerce multi-vendeurs complète

📚 10 séances • Dernière mise à jour : Décembre 2025

🎯 Objectif du Projet

Ce tutoriel vous guide pas à pas dans la création d'une marketplace de produits numériques multi-vendeurs complète avec Laravel 12. Vous apprendrez à implémenter les fonctionnalités exactes de notre application : l'authentification avec rôles, le système de boutiques vendeurs, le panier d'achat, les paiements Stripe et PayPal, la gestion des médias, et bien plus encore.

🛠️ Technologies Utilisées

Backend

  • Laravel 12
  • PHP 8.2+
  • MySQL/MariaDB
  • Eloquent ORM

Frontend

  • Blade
  • Bootstrap 5
  • JavaScript ES6+
  • Fetch API

Paiements

  • Stripe PHP

Médias

  • Spatie Media Library

Auth & Permissions

  • Laravel Breeze
  • Spatie Permission

Autres

  • Spatie Translatable
  • Spatie Activitylog
  • Laravel Localization

📖 Plan du Cours

Séance 1

1.1 Introduction et Configuration

Bienvenue ! Nous allons construire une Marketplace complète. Nous allons suivre à la lettre l'architecture professionnelle de l'application finale.

⚠️ Prérequis

  • PHP 8.2+, Composer, Node.js & NPM, MySQL.
Séance 1

1.2 Installation et Packages

étape 1 : Télécharger Laravel

TERMINAL
composer create-project laravel/laravel boutique cd boutique

étape 2 : Installer les dépendances (Traductions & UI)

TERMINAL
composer require spatie/laravel-medialibrary php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" php artisan migrate composer require spatie/laravel-activitylog php artisan vendor:publish --provider="Spatie\Activitylog\ActivitylogServiceProvider" --tag="activitylog-migrations" php artisan migrate composer require spatie/laravel-translatable npm install bootstrap @popperjs/core sass bootstrap-icons

⚠️ TRÈS IMPORTANT : Installez ces packages !

1. spatie/laravel-translatable : Indispensable pour éviter les erreurs `Unknown column 'name'`.
2. bootstrap : Pour le design professionnel que nous allons mettre en place via Vite.

étape 3 : Configurer Vite pour Bootstrap (Sans SCSS)

Nous allons charger Bootstrap via Vite en conservant app.css standard.

Dans resources/js/app.js, importez le CSS et le JS de Bootstrap :

JS (resources/js/app.js)
import './bootstrap'; // Importer le CSS de Bootstrap import 'bootstrap/dist/css/bootstrap.min.css'; // Importer les icônes (optionnel, sinon utilisez CDN) import 'bootstrap-icons/font/bootstrap-icons.css'; // Importer le JS de Bootstrap import * as bootstrap from 'bootstrap';

Vérifiez que vite.config.js pointe bien vers app.css (par défaut) :

JS (vite.config.js)
import { defineConfig } from 'vite'; import laravel from 'laravel-vite-plugin'; export default defineConfig({ plugins: [ laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], refresh: true, }), ], });

Et dans votre layout, appelez le CSS correctement :

@vite(['resources/css/app.css', 'resources/js/app.js'])

étape 4 : Personnaliser le Style (app.css)

Pour un look plus moderne (Police Inter, ombres, boutons...), ajoutez ceci :

CSS (resources/css/app.css)
/* Importer une belle police */ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); body { font-family: 'Inter', sans-serif; background-color: #f8f9fa; color: #1e293b; } /* Amélioration des cartes */ .card { border: none; border-radius: 0.75rem; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03); transition: transform 0.2s ease, box-shadow 0.2s ease; } .card:hover { transform: translateY(-2px); box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.025); } /* Boutons plus modernes */ .btn-primary { background-color: #3b82f6; /* Bleu moderne */ border-color: #3b82f6; padding: 0.5rem 1.25rem; font-weight: 500; } .btn-primary:hover { background-color: #2563eb; border-color: #2563eb; } /* Navigation */ .navbar { backdrop-filter: blur(8px); background-color: rgba(255, 255, 255, 0.95) !important; }

étape 5 : Configurer la BDD

.ENV
DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=boutique_db DB_USERNAME=root DB_PASSWORD=

Lancez le serveur de développement :

TERMINAL
npm run dev

N'oubliez pas le lien de stockage :

TERMINAL
php artisan storage:link
Séance 1

1.3 Structure des Tables

Nous allons créer les tables users, stores, products et categories.

A. Table Stores (Boutiques)

TERMINAL
php artisan make:model Store -m

Modifiez la migration create_stores_table.php :

PHP
public function up(): void { Schema::create('stores', function (Blueprint $table) { $table->id(); // Vendeur propriétaire (Sera relié à users) $table->foreignId('user_id')->constrained()->onDelete('cascade'); $table->string('name'); $table->string('slug')->unique(); $table->text('description')->nullable(); $table->boolean('is_active')->default(true); $table->boolean('is_featured')->default(false); $table->timestamp('verified_at')->nullable(); $table->timestamp('suspended_at')->nullable(); // Stats dénormalisées (pour performance) $table->unsignedInteger('products_count')->default(0); $table->unsignedInteger('orders_count')->default(0); $table->timestamps(); $table->softDeletes(); }); }

B. Table Categories

TERMINAL
php artisan make:model Category -m
PHP (create_categories_table.php)
public function up(): void { Schema::create('categories', function (Blueprint $table) { $table->id(); $table->foreignId('parent_id')->nullable()->constrained('categories')->nullOnDelete(); // JSON pour Spatie Translatable $table->json('name'); $table->json('description')->nullable(); $table->string('slug')->unique(); $table->string('image')->nullable(); $table->string('icon')->nullable(); $table->boolean('is_active')->default(true); $table->integer('order')->default(0); $table->timestamps(); $table->softDeletes(); $table->index('is_active'); $table->index('order'); }); }

C. Table Products

TERMINAL
php artisan make:model Product -m
PHP (create_products_table.php)
public function up(): void { Schema::create('products', function (Blueprint $table) { $table->id(); // Relation avec Store $table->foreignId('store_id')->constrained()->onDelete('cascade'); // Champs traduisibles (JSON) $table->json('name'); $table->string('slug')->unique(); $table->json('description')->nullable(); $table->json('short_description')->nullable(); $table->enum('type', ['digital', 'subscription', 'course', 'license'])->default('digital'); $table->decimal('price', 10, 2); $table->decimal('compare_price', 10, 2)->nullable(); $table->string('currency', 3)->default('EUR'); $table->integer('stock')->nullable(); $table->boolean('track_stock')->default(false); $table->string('main_file')->nullable(); $table->string('preview_file')->nullable(); $table->boolean('is_active')->default(true); $table->boolean('is_featured')->default(false); $table->boolean('is_new')->default(true); $table->timestamp('published_at')->nullable(); // Stats $table->unsignedInteger('views_count')->default(0); $table->unsignedInteger('sales_count')->default(0); $table->decimal('average_rating', 3, 2)->default(0); $table->unsignedInteger('reviews_count')->default(0); $table->timestamps(); $table->softDeletes(); $table->index('is_active'); }); }

D. Table Pivot Product_Category

Pour lier les produits aux catégories.

TERMINAL
php artisan make:migration create_product_category_table
PHP
public function up(): void { Schema::create('product_category', function (Blueprint $table) { $table->foreignId('product_id')->constrained()->onDelete('cascade'); $table->foreignId('category_id')->constrained()->onDelete('cascade'); $table->primary(['product_id', 'category_id']); }); }

Lancez les migrations maintenant :

TERMINAL
php artisan migrate
Séance 1

1.4 Modèles (Configuration Spatie)

Modèle Product.php

Utilisez HasTranslations pour le nom et la description, et configurez l'accesseur pour les images (LoremFlickr).

PHP (app/Models/Product.php)
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Spatie\Translatable\HasTranslations; use Illuminate\Support\Str; class Product extends Model { use SoftDeletes, HasTranslations; // Champs traduisibles public array $translatable = ['name', 'description', 'short_description']; protected $fillable = [ 'store_id', 'name', 'slug', 'description', 'type', 'price', 'compare_price', 'is_active', 'is_featured', 'published_at' ]; protected $casts = [ 'price' => 'decimal:2', 'is_active' => 'boolean', 'published_at' => 'datetime', ]; // Relations public function store(): BelongsTo { return $this->belongsTo(Store::class); } public function categories(): BelongsToMany { return $this->belongsToMany(Category::class, 'product_category'); } // Accessors public function getFormattedPriceAttribute() { return number_format($this->price, 2, ',', ' ') . ' €'; } public function getThumbnailUrlAttribute() { // Image de technologie via LoremFlickr (stable) avec lock sur l'ID pour la consistance return "https://loremflickr.com/400/300/computer,technology?lock=" . $this->id; } }

Modèle Store.php

PHP (app/Models/Store.php)
class Store extends Model { use SoftDeletes; protected $fillable = ['user_id', 'name', 'slug', 'is_active']; }

Modèle Category.php

PHP (app/Models/Category.php)
use Spatie\Translatable\HasTranslations; class Category extends Model { use SoftDeletes, HasTranslations; public array $translatable = ['name', 'description']; protected $fillable = ['parent_id', 'name', 'slug', 'is_active']; public function children() { return $this->hasMany(Category::class, 'parent_id'); } }
Séance 1

1.5 Jeu de Données (Seeder)

Créez des données réalistes pour tester la navigation.

PHP (database/seeders/DatabaseSeeder.php)
public function run(): void { // 1. Créer un Vendeur $vendeur = \App\Models\User::factory()->create([ 'name' => 'Vendeur Pro', 'email' => 'vendeur@boutique.com', 'password' => bcrypt('password'), ]); // 2. Créer sa Boutique $store = \App\Models\Store::create([ 'user_id' => $vendeur->id, 'name' => 'TechMaster Digital', 'slug' => 'techmaster-digital', 'is_active' => true, 'is_featured' => true, 'verified_at' => now(), ]); // 3. Catégories $cats = []; $catNames = ['Ebooks', 'Formations', 'Logiciels', 'Templates']; foreach ($catNames as $name) { $cats[$name] = \App\Models\Category::create([ 'name' => ['fr' => $name, 'en' => $name], 'slug' => \Illuminate\Support\Str::slug($name), 'is_active' => true ]); } // 4. Produits Diversifiés $productsData = [ ['name' => 'Guide Ultime Laravel 12', 'cat' => 'Ebooks', 'price' => 29.99], ['name' => 'Maîtriser React JS', 'cat' => 'Ebooks', 'price' => 24.99], ['name' => 'Formation Fullstack 2025', 'cat' => 'Formations', 'price' => 199.00], ['name' => 'SaaS Starter Kit', 'cat' => 'Logiciels', 'price' => 89.00], ['name' => 'Admin Dashboard Theme', 'cat' => 'Templates', 'price' => 49.00], ['name' => 'Python pour Data Science', 'cat' => 'Formations', 'price' => 149.00], ['name' => 'Figma UI Kit Pro', 'cat' => 'Templates', 'price' => 39.00], ['name' => 'Docker pour Débutants', 'cat' => 'Ebooks', 'price' => 19.00], ]; foreach ($productsData as $p) { $product = \App\Models\Product::create([ 'store_id' => $store->id, 'name' => ['fr' => $p['name'], 'en' => $p['name']], 'slug' => \Illuminate\Support\Str::slug($p['name']), 'description' => ['fr' => 'Description détaillée de ' . $p['name'] . '. Apprenez et progressez rapidement.'], 'price' => $p['price'], 'type' => 'digital', 'is_active' => true, 'published_at' => now(), 'views_count' => rand(100, 5000), 'sales_count' => rand(10, 500) ]); if (isset($cats[$p['cat']])) { $product->categories()->attach($cats[$p['cat']]->id); } } }

Action : php artisan migrate:fresh --seed

Séance 1

1.6 Routes

Ajoutez ces routes dans routes/web.php pour gérer l'accueil, les catégories et les produits.

PHP (routes/web.php)
<?php use Illuminate\Support\Facades\Route; use App\Models\Product; use App\Models\Category; use App\Models\Store; // 1. Accueil Route::get('/', function () { $products = Product::where('is_active', true) ->with(['store', 'categories']) ->orderBy('created_at', 'desc') ->take(8) ->get(); $categories = Category::where('is_active', true)->take(6)->get(); $featuredStores = Store::where('is_active', true)->take(4)->get(); return view('welcome', compact('products', 'categories', 'featuredStores')); })->name('home'); // 2. Page Catégorie (Nouveau) Route::get('/category/{slug}', function ($slug) { $category = Category::where('slug', $slug)->where('is_active', true)->firstOrFail(); $products = Product::where('is_active', true) ->whereHas('categories', function($q) use ($category) { $q->where('categories.id', $category->id); }) ->with(['store', 'categories']) ->paginate(12); return view('categories.show', compact('category', 'products')); })->name('categories.show'); // 3. Fiche Produit (Nouveau) Route::get('/product/{slug}', function ($slug) { $product = Product::where('slug', $slug) ->where('is_active', true) ->with(['store', 'categories']) ->firstOrFail(); $product->increment('views_count'); $relatedProducts = Product::where('is_active', true) ->where('id', '!=', $product->id) ->whereHas('categories', function($q) use ($product) { $q->whereIn('categories.id', $product->categories->pluck('id')); }) ->take(4) ->get(); return view('products.show', compact('product', 'relatedProducts')); })->name('products.show');
Séance 1

1.7 Composants et Vues

Nous allons créer le layout et les vues pour la navigation.

A. Composant Product Card

Fichier: resources/views/components/product-card.blade.php

HTML
@props(['product']) <div class="col-md-6 col-lg-3"> <div class="card h-100 shadow-sm border-0 product-card"> <a href="{{ route('products.show', $product->slug) }}"> <img src="{{ $product->thumbnail_url }}" class="card-img-top" style="height: 200px; object-fit: cover;" alt="{{ $product->name }}"> </a> <div class="card-body"> <div class="text-muted small mb-2"> @foreach($product->categories as $cat) <span class="badge bg-light text-secondary">{{ $cat->name }}</span> @endforeach </div> <h5 class="card-title h6"> <a href="{{ route('products.show', $product->slug) }}" class="text-dark text-decoration-none stretched-link"> {{ $product->name }} </a> </h5> <div class="d-flex justify-content-between align-items-center mt-3"> <span class="text-primary fw-bold fs-5">{{ $product->formatted_price }}</span> <button class="btn btn-sm btn-outline-primary"><i class="bi bi-cart-plus"></i></button> </div> </div> </div> </div>

B. Composant Header

Fichier: resources/views/components/header.blade.php

HTML
<header class="bg-dark text-white py-2"> <div class="container"> <div class="row align-items-center"> <div class="col-12 col-md-3 text-center text-md-start mb-2 mb-md-0"> <a href="{{ url('/') }}" class="text-decoration-none text-white d-inline-flex align-items-center"> <span class="fs-4 fw-bold">Boutique</span> </a> </div> <div class="col-12 col-md-6 mb-2 mb-md-0"> <form action="{{ url('/search') }}" method="GET" class="d-flex"> <div class="input-group"> <input type="text" name="q" class="form-control" placeholder="Rechercher..." value="{{ request('q') }}"> <button class="btn btn-primary" type="submit"><i class="bi bi-search"></i></button> </div> </form> </div> <div class="col-12 col-md-3 text-end"> <div class="d-flex justify-content-end gap-2"> <a href="#" class="btn btn-outline-light btn-sm"><i class="bi bi-cart3"></i></a> <a href="#" class="btn btn-outline-light btn-sm"><i class="bi bi-person-circle"></i></a> </div> </div> </div> </div> </header>

C. Composant Navbar

Fichier: resources/views/components/main-navbar.blade.php

HTML
<nav class="navbar navbar-expand-lg navbar-light bg-white shadow-sm sticky-top"> <div class="container"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav"> <span class="navbar-toggler-icon"></span> </button> <div class="collapse navbar-collapse" id="mainNav"> <ul class="navbar-nav me-auto mb-2 mb-lg-0"> <li class="nav-item"><a class="nav-link active" href="{{ url('/') }}">Accueil</a></li> <li class="nav-item"><a class="nav-link" href="#">Catégories</a></li> <li class="nav-item"><a class="nav-link text-danger" href="#">Promos</a></li> </ul> <ul class="navbar-nav ms-auto"> <li class="nav-item"> <a class="nav-link text-primary fw-bold" href="#">Devenir vendeur</a> </li> </ul> </div> </div> </nav>

D. Composant Footer

Fichier: resources/views/components/footer.blade.php

HTML
<footer class="bg-dark text-white mt-auto py-5"> <div class="container"> <div class="row g-4"> <div class="col-md-4"> <h5>Boutique</h5> <p class="text-secondary small">La meilleure marketplace pour vos produits numériques.</p> </div> <div class="col-md-4"> <h6>Liens rapides</h6> <ul class="list-unstyled"> <li><a href="#" class="text-secondary text-decoration-none">Accueil</a></li> <li><a href="#" class="text-secondary text-decoration-none">Contact</a></li> </ul> </div> <div class="col-md-4"> <h6>Newsletter</h6> <form class="mt-2"> <div class="input-group"> <input type="email" class="form-control" placeholder="Email..."> <button class="btn btn-primary">Ok</button> </div> </form> </div> </div> <div class="border-top border-secondary mt-4 pt-3 text-center text-secondary small"> &copy; {{ date('Y') }} Boutique. Tous droits réservés. </div> </div> </footer>

E. Layout Principal

Fichier: resources/views/components/layouts/app.blade.php

HTML
<!DOCTYPE html> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{{ $title ?? 'Boutique' }}</title> @vite(['resources/css/app.css', 'resources/js/app.js']) </head> <body class="d-flex flex-column min-vh-100 bg-light"> <x-header /> <x-main-navbar /> <main class="flex-grow-1"> {{ $slot }} </main> <x-footer /> </body> </html>

F. Page d'Accueil (Welcome)

Fichier: resources/views/welcome.blade.php

HTML
<x-layouts.app title="Accueil"> <section class="bg-primary text-white py-5 mb-5 rounded-3 mt-4"> <div class="container py-4"> <div class="row align-items-center"> <div class="col-lg-7"> <h1 class="display-4 fw-bold mb-3">Produits Numériques Premium</h1> <p class="lead mb-4">Ebooks, Formations, Logiciels. Une qualité garantie.</p> <a href="#catalogue" class="btn btn-light btn-lg text-primary fw-bold">Voir le catalogue</a> </div> <div class="col-lg-5 text-center d-none d-lg-block"> <img src="https://loremflickr.com/500/350/technology,computer" class="img-fluid rounded shadow-lg" alt="Hero"> </div> </div> </div> </section> <section class="container mb-5"> <h2 class="h4 mb-4 fw-bold border-start border-4 border-primary ps-3">Catégories</h2> <div class="row g-3"> @foreach($categories as $category) <div class="col-6 col-md-4 col-lg-2"> <a href="{{ route('categories.show', $category->slug) }}" class="text-decoration-none"> <div class="card text-center h-100 border-0 shadow-sm hover-shadow"> <div class="card-body"> <div class="fs-2 mb-2">📁</div> <h6 class="card-title text-dark mb-0">{{ $category->name }}</h6> </div> </div> </a> </div> @endforeach </div> </section> <section id="catalogue" class="container mb-5"> <h2 class="h4 fw-bold border-start border-4 border-primary ps-3 mb-4">Nouveautés</h2> <div class="row g-4"> @foreach($products as $product) <x-product-card :product="$product" /> @endforeach </div> </section> </x-layouts.app>

G. Page Catégorie (Nouveau)

Fichier: resources/views/categories/show.blade.php

HTML
<x-layouts.app :title="$category->name"> <div class="bg-light py-4 mb-4"> <div class="container"> <nav aria-label="breadcrumb"> <ol class="breadcrumb mb-2"> <li class="breadcrumb-item"><a href="{{ route('home') }}">Accueil</a></li> <li class="breadcrumb-item active">{{ $category->name }}</li> </ol> </nav> <h1 class="h2 fw-bold">{{ $category->name }}</h1> </div> </div> <div class="container mb-5"> <div class="row g-4"> @forelse($products as $product) <x-product-card :product="$product" /> @empty <div class="col-12 text-center py-5"> <p class="text-muted">Aucun produit dans cette catégorie.</p> </div> @endforelse </div> <div class="mt-4">{{ $products->links() }}</div> </div> </x-layouts.app>

H. Fiche Produit (Nouveau)

Fichier: resources/views/products/show.blade.php

HTML
<x-layouts.app :title="$product->name"> <div class="container py-5"> <nav aria-label="breadcrumb" class="mb-4"> <ol class="breadcrumb"> <li class="breadcrumb-item"><a href="{{ route('home') }}">Accueil</a></li> @if($product->categories->isNotEmpty()) <li class="breadcrumb-item"> <a href="{{ route('categories.show', $product->categories->first()->slug) }}"> {{ $product->categories->first()->name }} </a> </li> @endif <li class="breadcrumb-item active">{{ $product->name }}</li> </ol> </nav> <div class="row g-5"> <div class="col-lg-6"> <div class="card border-0 shadow-sm overflow-hidden rounded-3"> <img src="{{ $product->thumbnail_url }}" class="img-fluid w-100" alt="{{ $product->name }}"> </div> </div> <div class="col-lg-6"> <div class="ps-lg-4"> <h1 class="display-6 fw-bold mb-3">{{ $product->name }}</h1> <h2 class="h3 text-primary fw-bold mb-4">{{ $product->formatted_price }}</h2> <div class="prose text-muted mb-4">{{ $product->description }}</div> <div class="d-grid gap-2 d-md-flex mb-4"> <button class="btn btn-primary btn-lg flex-grow-1"> <i class="bi bi-cart-plus me-2"></i>Ajouter au panier </button> </div> </div> </div> </div> @if($relatedProducts->isNotEmpty()) <div class="mt-5 pt-5 border-top"> <h3 class="h4 fw-bold mb-4">Produits similaires</h3> <div class="row g-4"> @foreach($relatedProducts as $related) <x-product-card :product="$related" /> @endforeach </div> </div> @endif </div> </x-layouts.app>
Séance 2

2.1 Installation de Laravel Breeze

Pour l'inscription et la connexion, nous utilisons Laravel Breeze.

étape 1 : Installer le package

TERMINAL
composer require laravel/breeze --dev php artisan breeze:install blade

Répondez "No" au support du Dark Mode et "PHPUnit" pour les tests.

Installez les dépendances front-end :

TERMINAL
npm install npm run dev

⚠️ IMPORTANT : Restauration des fichiers

L'installation de Breeze a écrasé web.php, app.js et app.css. Nous devons remettre notre configuration Bootstrap et nos routes de la Séance 1.

1. Réparer resources/js/app.js

Gardez Alpine.js (pour Breeze) mais remettez Bootstrap :

JS
import './bootstrap'; // Réintégration de Bootstrap (Séance 1) import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap-icons/font/bootstrap-icons.css'; import * as bootstrap from 'bootstrap'; // Alpine.js (Breeze) import Alpine from 'alpinejs'; window.Alpine = Alpine; Alpine.start();

2. Réparer resources/css/app.css

Remettez votre CSS personnalisé :

CSS
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); body { font-family: 'Inter', sans-serif; background-color: #f8f9fa; color: #1e293b; } .card { border: none; border-radius: 0.75rem; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03); transition: transform 0.2s ease, box-shadow 0.2s ease; } .card:hover { transform: translateY(-2px); box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.025); } .btn-primary { background-color: #3b82f6; border-color: #3b82f6; padding: 0.5rem 1.25rem; font-weight: 500; } .btn-primary:hover { background-color: #2563eb; border-color: #2563eb; } .navbar { backdrop-filter: blur(8px); background-color: rgba(255, 255, 255, 0.95) !important; }

3. Réparer routes/web.php

Combinez les routes de la Séance 1 et celles de Breeze :

PHP
<?php use Illuminate\Support\Facades\Route; use App\Models\Product; use App\Models\Category; use App\Models\Store; use App\Http\Controllers\ProfileController; // --- ROUTES SÉANCE 1 (Rétablies) --- Route::get('/', function () { $products = Product::where('is_active', true) ->with(['store', 'categories']) ->orderBy('created_at', 'desc') ->take(8) ->get(); $categories = Category::where('is_active', true)->take(6)->get(); $featuredStores = Store::where('is_active', true)->take(4)->get(); return view('welcome', compact('products', 'categories', 'featuredStores')); })->name('home'); Route::get('/category/{slug}', function ($slug) { $category = Category::where('slug', $slug)->where('is_active', true)->firstOrFail(); $products = Product::where('is_active', true) ->whereHas('categories', function($q) use ($category) { $q->where('categories.id', $category->id); }) ->with(['store', 'categories']) ->paginate(12); return view('categories.show', compact('category', 'products')); })->name('categories.show'); Route::get('/product/{slug}', function ($slug) { $product = Product::where('slug', $slug) ->where('is_active', true) ->with(['store', 'categories']) ->firstOrFail(); $product->increment('views_count'); $relatedProducts = Product::where('is_active', true) ->where('id', '!=', $product->id) ->whereHas('categories', function($q) use ($product) { $q->whereIn('categories.id', $product->categories->pluck('id')); }) ->take(4) ->get(); return view('products.show', compact('product', 'relatedProducts')); })->name('products.show'); // --- ROUTES BREEZE (Gardées) --- Route::get('/dashboard', function () { return view('dashboard'); })->middleware(['auth', 'verified'])->name('dashboard'); Route::middleware('auth')->group(function () { Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); }); require __DIR__.'/auth.php';
Séance 2

2.2 Activation du compte par Email

Configuration SMTP (Gmail)

Ajoutez ces variables dans votre fichier .env :

ENV
MAIL_MAILER=smtp MAIL_HOST=smtp.gmail.com MAIL_PORT=465 MAIL_USERNAME=ousrah@gmail.com MAIL_PASSWORD=xxxxxxxxxxxxx # Mot de passe d'application Google MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="ousrah@gmail.com" MAIL_FROM_NAME="portaledu" MAIL_SCHEME=null
Important : Utilisez un "Mot de passe d'application" généré dans votre compte Google (Sécurité > Validation en 2 étapes), pas votre mot de passe habituel.

Implémenter MustVerifyEmail

Dans app/Models/User.php :

PHP
use Illuminate\Contracts\Auth\MustVerifyEmail; class User extends Authenticatable implements MustVerifyEmail { // ... }
Séance 2

2.3 Personnalisation du Login (Remember Me)

Assurez-vous que la case "Se souvenir de moi" est présente dans resources/views/auth/login.blade.php :

HTML
<!-- Remember Me --> <div class="block mt-4"> <label for="remember_me" class="inline-flex items-center"> <input id="remember_me" type="checkbox" class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500" name="remember"> <span class="ms-2 text-sm text-gray-600">{{ __('Se souvenir de moi') }}</span> </label> </div>
Séance 2

2.4 Rôles et Permissions (Spatie)

Nous avons 3 types d'utilisateurs : Admin, Vendeur et Client.

TERMINAL
composer require spatie/laravel-permission php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider" php artisan migrate
Séance 2

2.4a Modification de la table Users

Nous devons ajouter le champ is_vendor et vendor_verified_at à la table users.

TERMINAL
php artisan make:migration add_vendor_fields_to_users_table --table=users

Dans la nouvelle migration créée (database/migrations/xxxx_xx_xx_xxxxxx_add_vendor_fields_to_users_table.php) :

PHP
public function up(): void { Schema::table('users', function (Blueprint $table) { $table->boolean('is_vendor')->default(false)->after('email'); $table->timestamp('vendor_verified_at')->nullable()->after('is_vendor'); }); } public function down(): void { Schema::table('users', function (Blueprint $table) { $table->dropColumn(['is_vendor', 'vendor_verified_at']); }); }
TERMINAL
php artisan migrate

ℹ️ Configuration des Middlewares

Pour utiliser role:admin dans vos routes, vous devez enregistrer les middlewares de Spatie.

Ouvrez bootstrap/app.php et ajoutez les alias dans withMiddleware :

PHP
->withMiddleware(function (Middleware $middleware) { $middleware->alias([ 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, 'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, 'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class, ]); })
Séance 2

2.5 Configuration du Modèle User

Ouvrez app/Models/User.php. Ajoutez le trait HasRoles.

PHP (app/Models/User.php)
<?php namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Spatie\Permission\Traits\HasRoles; // <-- AJOUT class User extends Authenticatable { use HasFactory, Notifiable, HasRoles; protected $fillable = [ 'name', 'email', 'password', 'is_vendor', 'vendor_verified_at' ]; protected $hidden = ['password', 'remember_token']; protected function casts(): array { return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', 'is_vendor' => 'boolean', ]; } // Helpers public function isVerifiedVendor(): bool { return $this->is_vendor && $this->vendor_verified_at !== null; } public function isAdmin(): bool { return $this->hasRole('admin'); } public function isCustomer(): bool { return $this->hasRole('customer'); } }
Séance 2

2.6 Seeder des Rôles

TERMINAL
php artisan make:seeder RolesAndPermissionsSeeder

Dans database/seeders/RolesAndPermissionsSeeder.php :

PHP
public function run(): void { app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions(); $roleAdmin = \Spatie\Permission\Models\Role::create(['name' => 'admin']); $roleVendor = \Spatie\Permission\Models\Role::create(['name' => 'vendor']); $roleCustomer = \Spatie\Permission\Models\Role::create(['name' => 'customer']); }

Dans database/seeders/DatabaseSeeder.php, appelez ce seeder en premier :

PHP
$this->call(RolesAndPermissionsSeeder::class);
TERMINAL
php artisan migrate:fresh --seed
Séance 2

2.7 Redirection Intelligente

Nous voulons rediriger l'utilisateur vers son dashboard spécifique après le login.

Modifiez app/Http/Controllers/Auth/AuthenticatedSessionController.php, méthode store :

PHP
public function store(LoginRequest $request): RedirectResponse { $request->authenticate(); $request->session()->regenerate(); // Redirection selon le rôle if ($request->user()->hasRole('admin')) { return redirect()->intended('admin/dashboard'); } if ($request->user()->hasRole('vendor')) { return redirect()->intended('vendor/dashboard'); } return redirect()->intended('dashboard'); // Client par défaut }
Séance 2

2.8 Création des Contrôleurs

Créez les contrôleurs pour chaque espace.

TERMINAL
php artisan make:controller Admin/DashboardController php artisan make:controller Vendor/DashboardController

app/Http/Controllers/Admin/DashboardController.php :

PHP
<?php namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\User; use App\Models\Store; class DashboardController extends Controller { public function index() { $stats = [ 'users' => User::count(), 'stores' => Store::count(), 'pending_stores' => Store::where('is_active', false)->count() ]; return view('admin.dashboard', compact('stats')); } }

app/Http/Controllers/Vendor/DashboardController.php :

PHP
<?php namespace App\Http\Controllers\Vendor; use App\Http\Controllers\Controller; use Illuminate\Support\Facades\Auth; class DashboardController extends Controller { public function index() { $store = Auth::user()->store; // Suppose une relation hasOne dans User return view('vendor.dashboard', compact('store')); } }
Séance 2

2.9 Configuration des Routes

Ajoutez ces groupes de routes dans routes/web.php :

PHP (routes/web.php)
// ... Auth Routes (Breeze) ... // Groupe Admin Route::middleware(['auth', 'role:admin'])->prefix('admin')->name('admin.')->group(function () { Route::get('/dashboard', [App\Http\Controllers\Admin\DashboardController::class, 'index'])->name('dashboard'); }); // Groupe Vendeur Route::middleware(['auth', 'role:vendor'])->prefix('vendor')->name('vendor.')->group(function () { Route::get('/dashboard', [App\Http\Controllers\Vendor\DashboardController::class, 'index'])->name('dashboard'); }); // Tableau de bord Client (Breeze par défaut modifié) Route::middleware(['auth', 'verified'])->get('/dashboard', function () { return view('dashboard'); })->name('dashboard');
Séance 2

2.10 Vues des Dashboards

Créez les dossiers resources/views/admin et resources/views/vendor.

A. Dashboard Admin (admin/dashboard.blade.php)

HTML
<x-app-layout> <x-slot name="header"> <h2 class="font-semibold text-xl text-gray-800 leading-tight">Admin Dashboard</h2> </x-slot> <div class="py-12"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <!-- Carte Stats --> <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6"> <div class="text-gray-900 font-bold text-xl">{{ $stats['users'] }}</div> <div class="text-gray-600">Utilisateurs</div> </div> <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6"> <div class="text-gray-900 font-bold text-xl">{{ $stats['stores'] }}</div> <div class="text-gray-600">Boutiques</div> </div> <div class="bg-red-50 overflow-hidden shadow-sm sm:rounded-lg p-6"> <div class="text-red-900 font-bold text-xl">{{ $stats['pending_stores'] }}</div> <div class="text-red-600">En attente</div> </div> </div> </div> </div> </x-app-layout>

B. Dashboard Vendeur (vendor/dashboard.blade.php)

HTML
<x-app-layout> <x-slot name="header"> <h2 class="font-semibold text-xl text-gray-800 leading-tight">Espace Vendeur</h2> </x-slot> <div class="py-12"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg"> <div class="p-6 text-gray-900"> @if($store) <h3 class="text-lg font-bold mb-4">Bienvenue, {{ $store->name }}</h3> <div class="grid grid-cols-2 gap-4"> <a href="#" class="block p-4 border rounded hover:bg-gray-50"> <span class="font-bold text-blue-600">📦 Mes Produits</span> </a> <a href="#" class="block p-4 border rounded hover:bg-gray-50"> <span class="font-bold text-green-600">💰 Mes Ventes</span> </a> </div> @else <div class="alert alert-warning"> Vous n'avez pas encore de boutique active. <a href="#" class="underline">Créer ma boutique</a>. </div> @endif </div> </div> </div> </div> </x-app-layout>

C. Dashboard Client (dashboard.blade.php)

Laissez ou modifiez le fichier par défaut de Breeze pour afficher "Mes Commandes" et "Mes Téléchargements".

Séance 3

3.1 Tableau de Bord Vendeur & Gestion Boutique

Dans cette séance, nous permettons aux utilisateurs authentifiés de devenir vendeurs en créant leur propre boutique. Une fois la boutique créée, ils auront accès à leur tableau de bord vendeur.

Architecture : Nous séparons les routes "Vendeur" (/vendor/*) des routes "Client". Un middleware vendor protégera l'accès.

Séance 3

3.2 Routes Vendeur & Middleware

étape 1 : Le Middleware Vendor

TERMINAL
php artisan make:middleware VendorMiddleware

Vérifiez que l'utilisateur a le rôle 'vendor' ou 'admin'. Fichier : app/Http/Middleware/VendorMiddleware.php

PHP
public function handle(Request $request, Closure $next): Response { // Si Admin, on laisse passer if ($request->user()->hasRole('admin')) { return $next($request); } // Si pas vendeur, redirection if (!$request->user()->is_vendor) { return redirect()->route('become.vendor'); } return $next($request); }

Enregistrez le middleware (Laravel 11/12 : bootstrap/app.php)

PHP (bootstrap/app.php)
->withMiddleware(function (Middleware $middleware) { $middleware->alias([ 'vendor' => \App\Http\Middleware\VendorMiddleware::class, ]); })

étape 2 : Routes (routes/web.php)

Ajoutez ces routes pour gérer la création de boutique (pour devenir vendeur) et l'espace vendeur.

PHP (routes/web.php)
// Routes pour devenir vendeur (Accessible aux connectés non-vendeurs) Route::middleware(['auth', 'verified'])->group(function () { Route::get('/become-vendor', [\App\Http\Controllers\Vendor\StoreController::class, 'create'])->name('become.vendor'); Route::post('/become-vendor', [\App\Http\Controllers\Vendor\StoreController::class, 'store'])->name('vendor.store.store2'); }); // Espace Vendeur (Accessible uniquement aux Vendeurs) Route::prefix('vendor')->name('vendor.')->middleware(['auth', 'verified', 'vendor'])->group(function () { Route::get('/dashboard', [\App\Http\Controllers\Vendor\DashboardController::class, 'index'])->name('dashboard'); // Gestion de la boutique (Vendeur existant) Route::get('/store', [\App\Http\Controllers\Vendor\StoreController::class, 'edit'])->name('store.edit'); Route::put('/store', [\App\Http\Controllers\Vendor\StoreController::class, 'update'])->name('store.update'); });
Séance 3

3.3a Mise à jour du Modèle Store

Assurez-vous que le champ description est bien présent dans la propriété $fillable de votre modèle app/Models/Store.php pour permettre sa modification.

PHP (app/Models/Store.php)
<?php namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\Translatable\HasTranslations; use Spatie\Activitylog\Traits\LogsActivity; use Spatie\Activitylog\LogOptions; use Illuminate\Database\Eloquent\SoftDeletes; class Store extends Model implements HasMedia { use HasFactory, SoftDeletes, InteractsWithMedia, HasTranslations, LogsActivity; public array $translatable = ['name', 'description', 'short_description', 'meta_title', 'meta_description']; protected $fillable = [ 'user_id', 'name', 'slug', 'description', 'logo', 'banner', 'is_active', 'is_featured', 'commission_rate' ]; // ... reste du modèle }
Séance 3

3.3b Migration Commission Rate

Ajoutez le champ commission_rate à la table stores pour gérer les commissions.

TERMINAL
php artisan make:migration add_commission_rate_to_stores_table --table=stores

Cochez le fichier de migration :

PHP
public function up(): void { Schema::table('stores', function (Blueprint $table) { $table->decimal('commission_rate', 5, 2)->default(10.00)->after('description'); }); } public function down(): void { Schema::table('stores', function (Blueprint $table) { $table->dropColumn('commission_rate'); }); }
TERMINAL
php artisan migrate
Séance 3

3.3 Logique : Vendor\StoreController

TERMINAL
php artisan make:controller Vendor/StoreController

Copiez la méthode store() exacte pour la création de boutique.

PHP (app/Http/Controllers/Vendor/StoreController.php)
public function create() { // Si l'utilisateur a déjà une boutique, redirection if (Auth()->user()->is_vendor) { return redirect()->route('vendor.dashboard'); } return view('vendor.store.create'); } public function store(Request $request) { $validated = $request->validate([ 'name' => 'required|string|max:255', 'description' => 'nullable|string|max:1000', 'logo' => 'nullable|image|max:1024', // Max 1Mo ]); $user = Auth()->user(); // 1. Créer la boutique $store = \App\Models\Store::create([ 'user_id' => $user->id, 'name' => ['fr' => $validated['name']], // Translatable 'description' => ['fr' => $validated['description'] ?? null], 'slug' => \Illuminate\Support\Str::slug($validated['name']), 'commission_rate' => 10.00, 'is_active' => true, ]); // 2. Assigner le rôle Vendeur $user->update(['is_vendor' => true]); $user->assignRole('vendor'); // 3. Gestion du Logo (Spatie Media Library) if ($request->hasFile('logo')) { $store->addMediaFromRequest('logo')->toMediaCollection('logo'); } return redirect()->route('vendor.dashboard') ->with('success', 'Boutique créée avec succès !'); }

Remarque : Notez l'utilisation de $user->assignRole('vendor') qui met à jour les permissions de l'utilisateur automatiquement.

Séance 3

3.4 La Vue de Création de Boutique

Créez le fichier resources/views/vendor/store/create.blade.php.

HTML (create.blade.php)
<x-app-layout> <x-slot name="header"> <h2 class="font-semibold text-xl text-gray-800 leading-tight"> {{ __("Ouvrir ma boutique") }} </h2> </x-slot> <div class="py-12"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg"> <div class="p-6 text-gray-900"> {{-- Affichage des erreurs globales --}} @if ($errors->any()) <div class="alert alert-danger mb-4"> <ul class="mb-0"> @foreach ($errors->all() as $error) <li>{{ $error }}</li> @endforeach </ul> </div> @endif <form method="POST" action="{{ route('vendor.store.store2') }}" enctype="multipart/form-data"> @csrf {{-- Nom --}} <div class="mb-3"> <label class="form-label">Nom de la boutique <span class="text-danger">*</span></label> <input type="text" name="name" class="form-control @error('name') is-invalid @enderror" required value="{{ old('name') }}"> @error('name') <div class="invalid-feedback">{{ $message }}</div> @enderror </div> {{-- Description --}} <div class="mb-3"> <label class="form-label">Description</label> <textarea name="description" class="form-control @error('description') is-invalid @enderror" rows="4">{{ old('description') }}</textarea> @error('description') <div class="invalid-feedback">{{ $message }}</div> @enderror </div> {{-- Logo --}} <div class="mb-3"> <label class="form-label">Logo</label> <input type="file" name="logo" class="form-control @error('logo') is-invalid @enderror" accept="image/*"> @error('logo') <div class="invalid-feedback">{{ $message }}</div> @enderror </div> <div class="d-grid"> <button type="submit" class="btn btn-primary btn-lg"> <i class="bi bi-shop me-2"></i>Créer ma boutique </button> </div> </form> </div> </div> </div> </div> </x-app-layout>
Séance 3

3.5 Mise à jour de la Navigation

Pour accéder facilement à ces pages, mettez à jour votre barre de navigation (ex: resources/views/layouts/navigation.blade.php ou votre layout principal).

HTML
@auth @if(Auth::user()->is_vendor) <a href="{{ route('vendor.dashboard') }}" class="nav-link">Dashboard Vendeur</a> @elseif(Auth::user()->hasRole('admin')) <a href="{{ route('admin.dashboard') }}" class="nav-link">Dashboard Admin</a> @else <a href="{{ route('become.vendor') }}" class="btn btn-outline-primary">Devenir Vendeur</a> @endif @else <a href="{{ route('login') }}" class="nav-link">Connexion</a> <a href="{{ route('register') }}" class="nav-link">Inscription</a> @endauth &nbsp;
Séance 3

3.5b Mise à jour du Dashboard Vendeur

Modifiez resources/views/vendor/dashboard.blade.php pour afficher le nom de la boutique si elle existe, sinon le lien de création.

HTML (extrait)
<div class="p-6 text-gray-900"> @if(Auth::user()->store) <h3 class="text-lg font-bold mb-4">Bienvenue, {{ Auth::user()->store->name }}</h3> @else <div class="alert alert-warning"> Vous n'avez pas encore de boutique active. <a href="{{ route('become.vendor') }}" class="underline">Créer ma boutique</a>. </div> @endif </div>
Séance 3

3.5b Mise à jour du Dashboard Vendeur

Modifiez app/Models/User.php pour ajouter la relation avec le modèle Store.

PHP (app/Models/User.php)
public function store(): HasOne { return $this->hasOne(Store::class); }
Séance 3

3.6 Testez votre travail

  1. Connectez-vous avec un compte utilisateur normal.
  2. Cliquez sur "Devenir Vendeur" ou allez sur /become-vendor.
  3. Remplissez le formulaire et validez.
  4. Vérifiez que vous êtes redirigé (vers le dashboard vendeur ou store.edit) et que vous avez maintenant le rôle 'vendor' en base de données.
Séance 4

4.1 Gestion des Catégories (Admin)

Pour organiser les produits, nous avons besoin de catégories. Seul l'Administrateur peut les gérer. Nous allons utiliser Spatie Media Library pour gérer les images (thumbnails) des catégories.

Rappel : Vous avez déjà installé spatie/laravel-medialibrary et spatie/laravel-translatable lors de la Séance 1. Nous allons maintenant les utiliser concrètement.

Séance 4

4.1b Configuration de l'environnement (.env)

Pour que les images s'affichent correctement via Spatie Media Library, vous devez configurer l'URL de l'application dans votre fichier .env.

ENV (.env)
APP_URL=http://localhost:8000

Cela permet de générer des URL absolues correctes pour les images uploadées.

Séance 4

4.2 Configuration du Modèle Category

Vérifiez votre fichier app/Models/Category.php. Il doit implémenter HasMedia et utiliser les traits nécessaires.

PHP
<?php namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Str; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\Translatable\HasTranslations; class Category extends Model implements HasMedia { use HasFactory, SoftDeletes, InteractsWithMedia, HasTranslations; // Définir les champs traduisibles public array $translatable = ['name', 'description', 'meta_title', 'meta_description']; protected $fillable = [ 'parent_id', 'name', 'description', 'slug', 'image', 'icon', 'is_active', 'order', 'meta_title', 'meta_description', ]; protected function casts(): array { return ['is_active' => 'boolean']; } // Configuration Spatie Media Library public function registerMediaCollections(): void { $this->addMediaCollection('image')->singleFile(); } // Relations public function parent(): BelongsTo { return $this->belongsTo(Category::class, 'parent_id'); } public function children(): HasMany { return $this->hasMany(Category::class, 'parent_id'); } public function products(): BelongsToMany { return $this->belongsToMany(Product::class, 'product_category'); } // Helpers public function getImageUrlAttribute(): string { return $this->hasMedia('image') ? $this->getFirstMediaUrl('image') : asset('images/default-category.jpg'); } }
Séance 4

4.3 CategoryController (Admin)

Créez le contrôleur pour l'administration.

TERMINAL
php artisan make:controller Admin/CategoryController --resource

Voici le code complet pour gérer le CRUD et l'upload d'images :

PHP (Admin/CategoryController.php)
<?php namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\Category; use Illuminate\Http\Request; use Illuminate\Support\Str; class CategoryController extends Controller { public function index() { // On charge les catégories racines et leurs enfants eagérément $categories = Category::whereNull('parent_id') ->with('children') ->orderBy('order') ->get(); return view('admin.categories.index', compact('categories')); } public function create() { $parents = Category::whereNull('parent_id')->get(); return view('admin.categories.create', compact('parents')); } public function store(Request $request) { $validated = $request->validate([ 'name' => 'required|string|max:255', 'parent_id' => 'nullable|exists:categories,id', 'description' => 'nullable|string', 'image' => 'nullable|image|max:2048', // 2Mo max // 'is_active' => 'nullable|boolean', ]); // Création avec traduction basique (FR par défaut) // Pour un vrai multi-langue, on demanderait un tableau $category = Category::create([ 'name' => ['fr' => $request['name']], 'description' => ['fr' => $request['description'] ?? null], 'parent_id' => $request['parent_id'], 'slug' => Str::slug($request['name']), 'is_active' => $request->has('is_active'), ]); if ($request->hasFile('image')) { $category->addMediaFromRequest('image')->toMediaCollection('image'); } return redirect()->route('admin.categories.index') ->with('success', 'Catégorie créée avec succès.'); } public function edit(Category $category) { $parents = Category::whereNull('parent_id')->where('id', '!=', $category->id)->get(); return view('admin.categories.edit', compact('category', 'parents')); } public function update(Request $request, Category $category) { $validated = $request->validate([ 'name' => 'required|string|max:255', 'parent_id' => 'nullable|exists:categories,id', 'description' => 'nullable|string', 'image' => 'nullable|image|max:2048', // 'is_active' => 'nullable|boolean', ]); $category->update([ 'name' => ['fr' => $validated['name']], // Mise à jour FR 'parent_id' => $validated['parent_id'], 'is_active' => $request->has('is_active'), ]); if ($request->hasFile('image')) { $category->clearMediaCollection('image'); $category->addMediaFromRequest('image')->toMediaCollection('image'); } return redirect()->route('admin.categories.index') ->with('success', 'Catégorie mise à jour.'); } public function destroy(Category $category) { if ($category->children()->count() > 0) { return back()->with('error', 'Impossible de supprimer une catégorie qui a des enfants.'); } $category->delete(); return redirect()->route('admin.categories.index') ->with('success', 'Catégorie supprimée.'); } }
Séance 4

4.4 Routes Admin

Ajoutez la route resource dans le groupe Admin de routes/web.php :

PHP (routes/web.php)
// Groupe Admin existant... Route::middleware(['auth', 'role:admin'])->prefix('admin')->name('admin.')->group(function () { Route::get('/dashboard', [App\Http\Controllers\Admin\DashboardController::class, 'index'])->name('dashboard'); // --> AJOUTER ICI : Route::resource('categories', App\Http\Controllers\Admin\CategoryController::class); });
Séance 4

4.5 Vues Admin (Blade)

Créez le dossier resources/views/admin/categories.

A. Liste (index.blade.php)

HTML
<x-app-layout> <x-slot name="header"> <div class="flex justify-between items-center"> <h2 class="font-semibold text-xl text-gray-800 leading-tight">Gestion des Catégories</h2> <a href="{{ route('admin.categories.create') }}" class="btn btn-primary"> + Nouvelle Catégorie </a> </div> </x-slot> <div class="py-12"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6"> @if(session('success')) <div class="alert alert-success mb-4">{{ session('success') }}</div> @endif @if(session('error')) <div class="alert alert-danger mb-4">{{ session('error') }}</div> @endif <table class="table align-middle"> <thead class="table-light"> <tr> <th>Image</th> <th>Nom</th> <th>Slug</th> <th>Parent</th> <th>Statut</th> <th class="text-end">Actions</th> </tr> </thead> <tbody> @foreach($categories as $category) <!-- Catégorie Parent --> <tr> <td> <img src="{{ $category->image_url }}" class="rounded" style="width: 40px; height: 40px; object-fit: cover;"> </td> <td class="fw-bold">{{ $category->name }}</td> <td class="text-muted">{{ $category->slug }}</td> <td>-</td> <td> @if($category->is_active) <span class="badge bg-success">Actif</span> @else <span class="badge bg-secondary">Inactif</span> @endif </td> <td class="text-end"> <a href="{{ route('admin.categories.edit', $category) }}" class="btn btn-sm btn-outline-primary">Éditer</a> </td> </tr> <!-- Sous-catégories --> @foreach($category->children as $child) <tr> <td> <div class="ms-4"> <img src="{{ $child->image_url }}" class="rounded" style="width: 30px; height: 30px; object-fit: cover;"> </div> </td> <td> <div class="ms-4">↳ {{ $child->name }}</div> </td> <td class="text-muted">{{ $child->slug }}</td> <td class="text-sm text-muted">{{ $category->name }}</td> <td> @if($child->is_active) <span class="badge bg-success">Actif</span> @else <span class="badge bg-secondary">Inactif</span> @endif </td> <td class="text-end"> <a href="{{ route('admin.categories.edit', $child) }}" class="btn btn-sm btn-outline-primary">Éditer</a> <form action="{{ route('admin.categories.destroy', $child) }}" method="POST" class="d-inline" onsubmit="return confirm('Confirmer la suppression ?')"> @csrf @method('DELETE') <button class="btn btn-sm btn-outline-danger">Supprimer</button> </form> </td> </tr> @endforeach @endforeach </tbody> </table> </div> </div> </div> </x-app-layout>

B. Création (create.blade.php)

HTML
<x-app-layout> <x-slot name="header"> <h2 class="font-semibold text-xl text-gray-800 leading-tight">Nouvelle Catégorie</h2> </x-slot> <div class="py-12"> <div class="max-w-2xl mx-auto sm:px-6 lg:px-8"> <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6"> <form action="{{ route('admin.categories.store') }}" method="POST" enctype="multipart/form-data"> @csrf <div class="mb-3"> <label class="form-label">Nom <span class="text-danger">*</span></label> <input type="text" name="name" class="form-control @error('name') is-invalid @enderror" required value="{{ old('name') }}"> @error('name') <div class="invalid-feedback">{{ $message }}</div> @enderror </div> <div class="mb-3"> <label class="form-label">Catégorie Parente</label> <select name="parent_id" class="form-select @error('parent_id') is-invalid @enderror"> <option value="">Aucune (Catégorie principale)</option> @foreach($parents as $parent) <option value="{{ $parent->id }}" {{ old('parent_id') == $parent->id ? 'selected' : '' }}>{{ $parent->name }}</option> @endforeach </select> @error('parent_id') <div class="invalid-feedback">{{ $message }}</div> @enderror </div> <div class="mb-3"> <label class="form-label">Description</label> <textarea name="description" class="form-control @error('description') is-invalid @enderror" rows="3">{{ old('description') }}</textarea> @error('description') <div class="invalid-feedback">{{ $message }}</div> @enderror </div> <div class="mb-3"> <label class="form-label">Image</label> <input type="file" name="image" class="form-control @error('image') is-invalid @enderror" accept="image/*"> @error('image') <div class="invalid-feedback">{{ $message }}</div> @enderror </div> <div class="mb-3 form-check form-switch"> <input class="form-check-input" type="checkbox" name="is_active" id="activeCheck" checked> <label class="form-check-label" for="activeCheck">Catégorie active</label> </div> <div class="d-flex justify-content-end"> <a href="{{ route('admin.categories.index') }}" class="btn btn-link me-2">Annuler</a> <button type="submit" class="btn btn-primary">Enregistrer</button> </div> </form> </div> </div> </div> </x-app-layout>

C. Édition (edit.blade.php)

Similaire à create, mais avec @method('PUT') et les valeurs pré-remplies.

HTML
<x-app-layout> <x-slot name="header"> <h2 class="font-semibold text-xl text-gray-800 leading-tight">Éditer : {{ $category->name }}</h2> </x-slot> <div class="py-12"> <div class="max-w-2xl mx-auto sm:px-6 lg:px-8"> <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6"> <form action="{{ route('admin.categories.update', $category) }}" method="POST" enctype="multipart/form-data"> @csrf @method('PUT') <div class="mb-3"> <label class="form-label">Nom <span class="text-danger">*</span></label> <input type="text" name="name" class="form-control" required value="{{ $category->name }}"> </div> <div class="mb-3"> <label class="form-label">Catégorie Parente</label> <select name="parent_id" class="form-select"> <option value="">Aucune (Catégorie principale)</option> @foreach($parents as $parent) <option value="{{ $parent->id }}" @selected($category->parent_id == $parent->id)> {{ $parent->name }} </option> @endforeach </select> </div> <div class="mb-3"> <label class="form-label">Image Actuelle</label> <div class="mb-2"> <img src="{{ $category->image_url }}" class="rounded" width="100"> </div> <input type="file" name="image" class="form-control" accept="image/*"> </div> <div class="mb-3 form-check form-switch"> <input class="form-check-input" type="checkbox" name="is_active" id="activeCheck" @checked($category->is_active)> <label class="form-check-label" for="activeCheck">Catégorie active</label> </div> <div class="d-flex justify-content-end"> <a href="{{ route('admin.categories.index') }}" class="btn btn-link me-2">Annuler</a> <button type="submit" class="btn btn-primary">Mettre à jour</button> </div> </form> </div> </div> </div> </x-app-layout>
Séance 4

4.6 Ajout au Menu de Navigation

Modifiez resources/views/layouts/navigation.blade.php pour ajouter le lien vers la gestion des catégories pour les admins :

HTML (layouts/navigation.blade.php)
<!-- Navigation Links --> <div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex"> <x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')"> {{ __('Dashboard') }} </x-nav-link> @auth @if(Auth::user()->hasRole('admin')) <x-nav-link :href="route('admin.categories.index')" :active="request()->routeIs('admin.categories.*')"> Gestion Catégories </x-nav-link> @endif @endauth </div>
Séance 4

✅ Vérification

  1. Connectez-vous en tant qu'Admin.
  2. Allez sur /admin/categories.
  3. Créez une catégorie "Logiciels" avec une image.
  4. Créez une sous-catégorie "Antivirus" (parent: Logiciels).
  5. Vérifiez que les images s'affichent bien dans la liste.
Séance 5

5.1 Configuration Multi-langues (Prérequis)

Avant de permettre aux vendeurs de créer des produits, nous devons gérer le multi-langue pour que les produits puissent avoir un nom et une description en Français, Anglais, etc.

1. Installation et Configuration

TERMINAL
composer require mcamara/laravel-localization php artisan vendor:publish --provider="Mcamara\LaravelLocalization\LaravelLocalizationServiceProvider"

Ajoutez le middleware dans bootstrap/app.php :

PHP
->withMiddleware(function (Middleware $middleware) { $middleware->alias([ 'localize' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationRoutes::class, 'localizationRedirect' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationRedirectFilter::class, 'localeSessionRedirect' => \Mcamara\LaravelLocalization\Middleware\LocaleSessionRedirect::class, 'localeCookieRedirect' => \Mcamara\LaravelLocalization\Middleware\LocaleCookieRedirect::class, 'localeViewPath' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationViewPath::class, ]); })

1.b Configuration des Routes (web.php)

Pour que le préfixe de langue (ex: /en/admin/categories) fonctionne, vous devez envelopper toutes vos routes web dans le groupe LaravelLocalization.

PHP (routes/web.php)
Route::group([ 'prefix' => \Mcamara\LaravelLocalization\Facades\LaravelLocalization::setLocale(), 'middleware' => ['localeSessionRedirect', 'localizationRedirect', 'localeViewPath', 'localize'] // 'localize' est important ici ! ], function() { // --- COLLET TOUTES VOS ROUTES ICI --- Route::get('/', [HomeController::class, 'index'])->name('home'); // Auth Routes require __DIR__.'/auth.php'; // Admin Routes Route::middleware(['auth', 'role:admin'])->prefix('admin')->name('admin.')->group(function () { // ... }); // Vendor Routes Route::middleware(['auth', 'role:vendor'])->prefix('vendor')->name('vendor.')->group(function () { // ... }); });

2. Fichiers de Traduction

Créez la structure de dossiers pour les langues (ex: lang/fr, lang/en, lang/ar).

Exemple : lang/fr/messages.php

PHP
<?php return [ 'dashboard' => 'Tableau de bord', 'vendor_dashboard' => 'Espace Vendeur', 'admin_dashboard' => 'Administration', 'my_shop' => 'Ma Boutique', 'products' => 'Produits', 'sales' => 'Ventes', 'welcome' => 'Bienvenue', ];

Exemple : lang/en/messages.php

PHP
<?php return [ 'dashboard' => 'Dashboard', 'vendor_dashboard' => 'Vendor Area', 'admin_dashboard' => 'Administration', 'my_shop' => 'My Shop', 'products' => 'Products', 'sales' => 'Sales', 'welcome' => 'Welcome', ];

Exemple : lang/ar/messages.php

PHP
<?php return [ 'dashboard' => 'لوحة التحكم', 'vendor_dashboard' => 'منطقة البائع', 'admin_dashboard' => 'الإدارة', 'my_shop' => 'متجري', 'products' => 'المنتجات', 'sales' => 'المبيعات', 'welcome' => 'مرحباً', ];

3. Sélecteur de Langue (Navbar)

Ajoutez ce menu déroulant dans votre navigation (ex: resources/views/layouts/navigation.blade.php) :

HTML
<div class="hidden sm:flex sm:items-center sm:ms-6"> <x-dropdown align="right" width="48"> <x-slot name="trigger"> <button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150"> <div>{{ LaravelLocalization::getCurrentLocaleNative() }}</div> <div class="ms-1"> <svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /> </svg> </div> </button> </x-slot> <x-slot name="content"> @foreach(LaravelLocalization::getSupportedLocales() as $localeCode => $properties) <x-dropdown-link :href="LaravelLocalization::getLocalizedURL($localeCode, null, [], true)"> {{ $properties['native'] }} </x-dropdown-link> @endforeach </x-slot> </x-dropdown> </div>

4. Utilisation dans les Vues

Remplacez les textes en dur par la fonction __().

Dans le Dashboard Admin :

HTML
<h2 class="font-semibold text-xl text-gray-800 leading-tight"> {{ __('messages.admin_dashboard') }} </h2>

Dans le Dashboard Vendeur :

HTML
<h2 class="font-semibold text-xl text-gray-800 leading-tight"> {{ __('messages.vendor_dashboard') }} </h2> <h3>{{ __('messages.welcome') }}, {{ $store->name }}</h3> <a href="#">{{ __('messages.my_shop') }}</a>
Séance 5

5.2 Modèle Product avec Spatie Media

Modifiez app/Models/Product.php pour gérer les collections d'images (Thumbnail, Galerie) et les fichiers numériques.

PHP
<?php namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\SoftDeletes; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\Translatable\HasTranslations; use Illuminate\Support\Str; class Product extends Model implements HasMedia { use HasFactory, SoftDeletes, InteractsWithMedia, HasTranslations; public array $translatable = ['name', 'description', 'short_description']; protected $fillable = [ 'store_id', 'name', 'slug', 'description', 'short_description', 'price', 'compare_price', 'quantity', 'type', 'is_active', 'is_featured', 'published_at' ]; protected $casts = [ 'price' => 'decimal:2', 'compare_price' => 'decimal:2', 'is_active' => 'boolean', 'is_featured' => 'boolean', 'published_at' => 'datetime', ]; // DEFINITION DES COLLECTIONS MEDIA public function registerMediaCollections(): void { // 1. Image principale (Thumbnail) $this->addMediaCollection('thumbnail') ->singleFile() // Une seule image principale ->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp']); // 2. Galerie photos (Plusieurs images) $this->addMediaCollection('gallery') ->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp']); // 3. Fichier numérique (Le produit lui-même) $this->addMediaCollection('digital_file') ->singleFile() ->acceptsMimeTypes(['application/pdf', 'application/zip', 'application/epub+zip']); } // RELATIONS public function store(): BelongsTo { return $this->belongsTo(Store::class); } public function categories(): BelongsToMany { return $this->belongsToMany(Category::class, 'product_category'); } // HELPERS public function getThumbnailUrlAttribute(): string { return $this->hasMedia('thumbnail') ? $this->getFirstMediaUrl('thumbnail') : asset('images/default-product.jpg'); } }
Séance 5

5.3 Routes Gestion Produits (Vendeur)

Ajoutez la resource products dans le groupe Vendeur de routes/web.php.

PHP (routes/web.php)
// Groupe Vendeur Route::prefix('vendor')->name('vendor.')->middleware(['auth', 'verified', 'vendor'])->group(function () { // ... dashboard, store ... // AJOUTER ICI : Route::resource('products', App\Http\Controllers\Vendor\ProductController::class); });
Séance 5

5.4 ProductController Vendeur

TERMINAL
php artisan make:controller Vendor/ProductController --resource

Implémentez les méthodes index, create et store en gérant les médias :

PHP (Admin/ProductController.php)
<?php namespace App\Http\Controllers\Vendor; use App\Http\Controllers\Controller; use App\Models\Product; use App\Models\Category; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Str; class ProductController extends Controller { public function index() { // Produits de la boutique du vendeur connecté $products = Auth::user()->store->products()->latest()->paginate(10); return view('vendor.products.index', compact('products')); } public function create() { $categories = Category::whereNull('parent_id')->with('children')->get(); return view('vendor.products.create', compact('categories')); } public function store(Request $request) { $validated = $request->validate([ 'name' => 'required|string|max:255', 'description' => 'nullable|string', 'price' => 'required|numeric|min:0', 'categories' => 'required|array', 'thumbnail' => 'required|image|max:2048', // Obligatoire 'gallery.*' => 'image|max:2048', // Galerie optionnelle 'digital_file' => 'nullable|file|mimes:pdf,zip,epub|max:10240', // 10Mo max ]); $store = Auth::user()->store; // Création Produit $product = $store->products()->create([ 'name' => ['fr' => $validated['name']], // Défaut FR pour l'instant 'description' => ['fr' => $validated['description'] ?? ''], 'price' => $validated['price'], 'slug' => Str::slug($validated['name']) . '-' . uniqid(), 'is_active' => true, ]); // Catégories (Pivot) $product->categories()->attach($validated['categories']); // 1. Thumbnail if ($request->hasFile('thumbnail')) { $product->addMediaFromRequest('thumbnail')->toMediaCollection('thumbnail'); } // 2. Galerie if ($request->hasFile('gallery')) { foreach ($request->file('gallery') as $image) { $product->addMedia($image)->toMediaCollection('gallery'); } } // 3. Fichier numérique if ($request->hasFile('digital_file')) { $product->addMediaFromRequest('digital_file')->toMediaCollection('digital_file'); } return redirect()->route('vendor.products.index') ->with('success', 'Produit ajouté avec succès !'); } }
Séance 5

5.5 Vues : Liste & Formulaire

A. Liste des Produits (index.blade.php)

Créez resources/views/vendor/products/index.blade.php :

HTML
<x-app-layout> <x-slot name="header"> <div class="flex justify-between items-center"> <h2 class="font-semibold text-xl text-gray-800 leading-tight"> {{ __('messages.products') }} </h2> <a href="{{ route('vendor.products.create') }}" class="btn btn-primary"> <i class="bi bi-plus-lg"></i> Nouveau Produit </a> </div> </x-slot> <div class="py-12"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg"> <div class="p-6 text-gray-900"> @if(session('success')) <div class="alert alert-success mb-4">{{ session('success') }}</div> @endif <table class="table align-middle"> <thead class="table-light"> <tr> <th>Image</th> <th>Nom</th> <th>Prix</th> <th>Statut</th> <th class="text-end">Actions</th> </tr> </thead> <tbody> @forelse($products as $product) <tr> <td> <img src="{{ $product->thumbnail_url }}" class="rounded shadow-sm" style="width: 50px; height: 50px; object-fit: cover;"> </td> <td class="fw-bold">{{ $product->name }}</td> <td>{{ number_format($product->price, 2) }} €</td> <td> @if($product->is_active) <span class="badge bg-success">Actif</span> @else <span class="badge bg-secondary">Inactif</span> @endif </td> <td class="text-end"> <div class="btn-group"> <a href="#" class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a> <button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button> </div> </td> </tr> @empty <tr> <td colspan="5" class="text-center py-5 text-muted"> <i class="bi bi-box2 display-6 d-block mb-3"></i> Aucun produit trouvé. </td> </tr> @endforelse </tbody> </table> <div class="mt-4"> {{ $products->links() }} </div> </div> </div> </div> </div> </x-app-layout>

B. Formulaire de Création (create.blade.php)

Créez resources/views/vendor/products/create.blade.php :

HTML
<x-app-layout> <x-slot name="header"> <h2 class="font-semibold text-xl text-gray-800 leading-tight">Nouveau Produit</h2> </x-slot> <div class="py-12"> <div class="max-w-4xl mx-auto sm:px-6 lg:px-8"> <form action="{{ route('vendor.products.store') }}" method="POST" enctype="multipart/form-data" class="bg-white p-6 shadow rounded"> @csrf <h3 class="text-lg font-bold mb-4">Informations de base</h3> <!-- Nom --> <div class="mb-4"> <label class="block font-medium text-sm text-gray-700">Nom du produit</label> <input type="text" name="name" class="form-input rounded-md shadow-sm mt-1 block w-full" required> </div> <!-- Prix --> <div class="mb-4"> <label class="block font-medium text-sm text-gray-700">Prix (€)</label> <input type="number" step="0.01" name="price" class="form-input rounded-md shadow-sm mt-1 block w-full" required> </div> <!-- Catégories --> <div class="mb-4"> <label class="block font-medium text-sm text-gray-700 mb-2">Catégories</label> <div class="h-32 overflow-y-auto border p-2 rounded"> @foreach($categories as $category) <div class="font-bold text-gray-800"> <input type="checkbox" name="categories[]" value="{{ $category->id }}"> {{ $category->name }} </div> @foreach($category->children as $child) <div class="ml-4 text-gray-600"> <input type="checkbox" name="categories[]" value="{{ $child->id }}"> {{ $child->name }} </div> @endforeach @endforeach </div> </div> <h3 class="text-lg font-bold mb-4 mt-8 border-t pt-4">Médias</h3> <!-- Thumbnail --> <div class="mb-4"> <label class="block font-medium text-sm text-gray-700">Image principale (Vignette)</label> <input type="file" name="thumbnail" class="mt-1 block w-full" accept="image/*" required> </div> <!-- Galerie --> <div class="mb-4"> <label class="block font-medium text-sm text-gray-700">Galerie photos (Multiple)</label> <input type="file" name="gallery[]" class="mt-1 block w-full" accept="image/*" multiple> <p class="text-xs text-gray-500 mt-1">Maintenez CTRL pour sélectionner plusieurs images.</p> </div> <!-- Fichier Numérique --> <div class="mb-4 bg-blue-50 p-4 rounded border border-blue-200"> <label class="block font-medium text-sm text-blue-900">Fichier à télécharger (PDF, ZIP...)</label> <input type="file" name="digital_file" class="mt-1 block w-full"> </div> <div class="flex justify-end mt-6"> <button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded shadow hover:bg-blue-700"> Publier le produit </button> </div> </form> </div> </div> </x-app-layout>
Séance 5

5.6 Mise à jour de la Navigation

Ajoutez le lien "Mes Produits" dans la barre de navigation (resources/views/layouts/navigation.blade.php) pour les vendeurs :

HTML
@if(Auth::user()->is_vendor) <x-nav-link :href="route('vendor.products.index')" :active="request()->routeIs('vendor.products.*')"> {{ __('messages.products') }} </x-nav-link> @endif
Séance 6

6.1 Architecture du Panier

Pour le panier, nous allons utiliser une approche hybride moderne :

  • Stockage Session (PHP) : Le panier est stocké côté serveur dans la session (sûr et persistant).
  • Interface (JavaScript/AJAX) : Tout se passe sans rechargement de page.
  • API Interne : Des routes spéciales en routes/web.php (mais préfixées api/) servent le JSON.
Séance 6

6.2 API CartController (Session)

TERMINAL
php artisan make:controller Api/CartController

Ce contrôleur gère la session cart (panier). Copiez le code ci-dessous :

PHP (Api/CartController.php)
<?php namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Product; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class CartController extends Controller { /** * Récupérer le panier (GET /api/cart) */ public function index(): JsonResponse { $cart = session('cart', []); $total = collect($cart)->sum('price'); return response()->json([ 'items' => array_values($cart), 'count' => count($cart), 'total' => $total, 'formatted_total' => number_format($total, 2, ',', ' ') . ' €', ]); } /** * Ajouter un produit (POST /api/cart) */ public function store(Request $request): JsonResponse { $request->validate([ 'product_id' => 'required|exists:products,id', 'variant_id' => 'nullable|exists:product_variants,id', ]); $product = Product::with('store')->findOrFail($request->product_id); $cart = session('cart', []); $key = 'product_' . $product->id . ($request->variant_id ? '_' . $request->variant_id : ''); if (isset($cart[$key])) { return response()->json([ 'success' => false, 'message' => 'Ce produit est déjà dans votre panier', ], 409); } $cart[$key] = [ 'id' => $product->id, 'variant_id' => $request->variant_id, 'name' => $product->getTranslation('name', app()->getLocale()), 'slug' => $product->slug, 'price' => (float) $product->price, 'type' => $product->type, 'store_id' => $product->store_id, 'store_name' => $product->store?->getTranslation('name', app()->getLocale()), 'thumbnail' => $product->thumbnail_url, ]; session(['cart' => $cart]); return response()->json([ 'success' => true, 'message' => 'Produit ajouté au panier', 'cart_count' => count($cart), ]); } /** * Supprimer un produit (DELETE /api/cart/{id}) */ public function destroy(Request $request, int $productId): JsonResponse { $cart = session('cart', []); $key = 'product_' . $productId; // Simplifié pour le cours if (!isset($cart[$key])) { return response()->json(['success' => false, 'message' => 'Produit non trouvé'], 404); } unset($cart[$key]); session(['cart' => $cart]); $total = collect($cart)->sum('price'); return response()->json([ 'success' => true, 'message' => 'Produit retiré du panier', 'cart_count' => count($cart), 'total' => $total, ]); } /** * Vider le panier */ public function clear(): JsonResponse { session()->forget('cart'); return response()->json(['success' => true, 'message' => 'Panier vidé', 'cart_count' => 0]); } }
Séance 6

6.3 Routes API (routes/web.php)

Ajoutez ces routes dans routes/web.php. Elles doivent être dans le fichier web pour avoir accès à la session.

PHP (routes/web.php)
// Routes AJAX pour le Panier Route::prefix('api')->group(function () { Route::get('/cart', [App\Http\Controllers\Api\CartController::class, 'index'])->name('api.cart.index'); Route::post('/cart', [App\Http\Controllers\Api\CartController::class, 'store'])->name('api.cart.store'); Route::delete('/cart/{productId}', [App\Http\Controllers\Api\CartController::class, 'destroy'])->name('api.cart.destroy'); Route::delete('/cart', [App\Http\Controllers\Api\CartController::class, 'clear'])->name('api.cart.clear'); }); // Vue du Panier Route::get('/cart', fn() => view('cart.index'))->name('cart');
Séance 6

6.4 JavaScript (resources/js/cart.js)

Créez le fichier resources/js/cart.js. C'est le cœur de notre système AJAX.

JAVASCRIPT (resources/js/cart.js)
/** * Gestion du Panier (BoutiqueCart) */ const BoutiqueCart = { csrfToken: document.querySelector('meta[name="csrf-token"]').getAttribute('content'), // Récupérer le panier async getItems() { try { const response = await fetch('/api/cart'); const data = await response.json(); this.updateBadge(data.count); return data.items; } catch (error) { console.error('Erreur panier:', error); return []; } }, // Ajouter au panier async addItem(productId) { try { const response = await fetch('/api/cart', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.csrfToken }, body: JSON.stringify({ product_id: productId }) }); const data = await response.json(); if (response.ok) { this.updateBadge(data.cart_count); // Utiliser Toast si disponible, sinon alert if (typeof Swal !== 'undefined') { Swal.fire({ icon: 'success', title: 'Ajouté !', text: data.message, toast: true, position: 'top-end', showConfirmButton: false, timer: 3000 }); } else { alert(data.message); } } else { alert(data.message); } } catch (error) { console.error('Erreur ajout panier:', error); } }, // Supprimer du panier async removeItem(productId) { try { const response = await fetch(`/api/cart/${productId}`, { method: 'DELETE', headers: { 'X-CSRF-TOKEN': this.csrfToken } }); const data = await response.json(); if (response.ok) { this.updateBadge(data.cart_count); return true; // Succès } return false; } catch (error) { console.error('Erreur suppression:', error); return false; } }, // Mettre à jour le badge du menu updateBadge(count) { const badge = document.querySelector('.cart-badge'); if (badge) { badge.textContent = count; badge.style.display = count > 0 ? 'inline-block' : 'none'; } } }; // Rendre accessible globalement window.BoutiqueCart = BoutiqueCart;

IMPORTANT : Importez ce fichier dans resources/js/app.js

JAVASCRIPT (resources/js/app.js)
import './bootstrap'; import './cart'; // <-- AJOUTER CETTE LIGNE import Alpine from 'alpinejs'; // ...
Séance 6

6.5 Vue Panier et Navigation

Créez resources/views/cart/index.blade.php (voir code du projet fourni pour le HTML complet).

Ajouter le lien "Panier" dans la Navigation

Dans resources/views/layouts/navigation.blade.php ou votre header, ajoutez l'icône :

HTML
<a href="{{ route('cart') }}" class="nav-link position-relative"> <i class="bi bi-cart3 fs-5"></i> <span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger cart-badge" style="display: none;"> 0 </span> </a>

Initialiser au chargement

Pour que le badge s'affiche dès l'arrivée sur le site, ajoutez ceci dans votre layout principal (app.blade.php), juste avant la fermeture du body :

HTML
<script> document.addEventListener('DOMContentLoaded', function() { if (typeof BoutiqueCart !== 'undefined') { BoutiqueCart.getItems(); // Charge le compteur } }); </script>
Séance 6

6.6 Bouton "Ajouter au Panier"

Sur la fiche produit (resources/views/products/show.blade.php) ou dans les listes, utilisez ce bouton :

HTML
<button onclick="BoutiqueCart.addItem({{ $product->id }})" class="btn btn-primary"> <i class="bi bi-cart-plus me-2"></i> Ajouter au panier </button>
🎉

Félicitations !

Vous avez terminé ce tutoriel complet de création d'une Marketplace avec Laravel 12.

📚 Ce que vous avez appris :

  • ✅ Installation et configuration de Laravel 12
  • ✅ Système d'authentification avec rôles
  • ✅ Architecture multi-vendeurs (Marketplace)
  • ✅ Gestion du panier avec JavaScript
  • ✅ Intégration Stripe et PayPal
  • ✅ Upload et gestion des médias (Spatie)
  • ✅ Système de commandes complet
  • ✅ Dashboard vendeur avec statistiques
  • ✅ Administration de la plateforme
  • ✅ Déploiement en production
↑ Retour en haut du cours