Frameworks PHP & Architecture MVC
Pourquoi Laravel domine le marché PHP, comparaison avec Symfony et Zend, architecture 3-tiers MVC, Design Patterns fondamentaux (Repository, Singleton, Factory, Facade) et introduction aux API PHP.
- Pourquoi utiliser un framework PHP plutôt que du PHP natif
- Comparatif Laravel / Symfony / Zend / CodeIgniter sur 6 critères
- Architecture 3-tiers MVC — Model, View, Controller et leurs responsabilités
- Design Patterns essentiels utilisés par Laravel en interne
- Présentation des API PHP et des CMS (WordPress, PrestaShop)
En PHP natif, chaque développeur réinvente la roue : gestion des connexions BDD, sécurité CSRF, routage, validation — tout est à réécrire pour chaque projet. Une startup casablancaise qui lance une plateforme e-commerce avec PHP natif passera 60% de son temps à reconstruire de l'infrastructure plutôt qu'à créer de la valeur métier.
Un framework impose une structure et fournit des composants éprouvés, testés par des millions de projets. Résultat : une API complète CRUD en Laravel se construit en 20 minutes là où PHP natif en nécessiterait 3 heures.
| Critère | PHP natif | Avec un framework |
|---|---|---|
| Temps de démarrage | Lent — tout à construire | Rapide — structure prête |
| Sécurité | Responsabilité du dev (oublis fréquents) | CSRF, XSS, injection SQL gérés par défaut |
| Maintenance | Code souvent non structuré | Conventions partagées, lisible par tous |
| Tests | Difficiles à mettre en place | PHPUnit intégré, injection de dépendances |
| Communauté | Documentation éparpillée | Écosystème riche, packages Composer |
| Framework | Philosophie | Courbe apprentissage | Cas d'usage idéal | Tendance 2024 |
|---|---|---|---|---|
| Laravel | Élégance & productivité — convention over configuration | Moyenne | Startups, SaaS, API REST, MVP rapide | Leader absolu — 75 000+ GitHub stars |
| Symfony | Composants réutilisables, flexibilité maximale | Élevée | Applications d'entreprise complexes, long terme | Fort en entreprise |
| Zend/Laminas | Architecture modulaire stricte | Très élevée | Grands projets bancaires, télécoms | Déclin progressif |
| CodeIgniter | Légèreté, simplicité, faible empreinte | Faible | Sites simples, hébergements mutualisés | Stable mais limité |
| Lumen | Laravel micro-framework | Faible | Microservices PHP, API haute performance | Déprécié — Laravel remplace |
La communauté PHP marocaine a massivement adopté Laravel. Des entreprises comme Maroc Telecom, BMCE Bank, et de nombreuses startups à Casablanca Tech City et Technopark utilisent Laravel pour leurs back-ends. La documentation en français, les nombreux tutoriels arabophones et la simplicité de déploiement sur les hébergeurs marocains expliquent cette adoption.
MVC (Model-View-Controller) est un pattern architectural qui sépare une application en trois couches aux responsabilités distinctes. Chaque couche communique avec les autres selon des règles précises.
| Couche | Rôle | Dans Laravel | Exemple DariGo |
|---|---|---|---|
| Model | Données et logique métier — interaction avec la BDD | Classes Eloquent dans app/Models/ | Restaurant.php, Commande.php, Livreur.php |
| View | Présentation — affichage des données | Templates Blade dans resources/views/ | restaurants/index.blade.php |
| Controller | Orchestration — reçoit la requête, appelle le Model, retourne la View | Classes dans app/Http/Controllers/ | RestaurantController.php |
Navigateur → routes/api.php → RestaurantController@index
↓
Restaurant::all() ⇄ MySQL
↓
return response()->json($restaurants)
↓
JSON → App mobile DariGo
| Pattern | Définition | Où dans Laravel | Exemple concret DariGo |
|---|---|---|---|
| Singleton | Une seule instance pour toute l'application | Service Container (app()) | Connexion BDD — partagée partout |
| Factory | Créer des objets sans spécifier leur classe | Model::factory() | Restaurant::factory(10)->create() en tests |
| Repository | Abstraction de la couche d'accès aux données | Eloquent ORM masque SQL | $restaurant->commandes()->where('statut','livree')->get() |
| Facade | Interface statique vers un objet du container | Cache::, Storage::, Mail:: | Cache::remember('restaurants', 60, fn() => ...) |
| Observer | Réagir à des événements sur un modèle | Events/Listeners, Model Observers | Notifier le livreur quand une commande est créée |
Environnement Laravel & Premier projet
Installer Composer et Laravel, comprendre la structure des dossiers, maîtriser les commandes Artisan essentielles, configurer le fichier .env et lancer le serveur de développement.
- Prérequis système : PHP 8.2+, Composer, MySQL 8, Node.js
- Installer Laravel via Composer et via Laravel Installer
- Structure complète des dossiers — rôle de chaque répertoire
- Fichier
.env— variables d'environnement et configuration BDD - Commandes Artisan essentielles pour le quotidien du développeur
- Tinker — REPL interactif pour tester Eloquent en direct
php --version # PHP 8.2.x ou supérieur requis
composer --version # Composer 2.x
mysql --version # MySQL 8.0
node --version # Node.js 18+ (pour Vite)
# Méthode 1 — Via Composer (recommandée, universelle)
composer create-project laravel/laravel darigo-api
# Méthode 2 — Via Laravel Installer (plus rapide)
composer global require laravel/installer
laravel new darigo-api
Laravel 11 (mars 2024) simplifie la structure : bootstrap/app.php unifié pour middleware et providers, moins de fichiers de config par défaut, support PHP 8.2+ uniquement. Pour vérifier : php artisan --version.
darigo-api/
├── app/
│ ├── Http/
│ │ ├── Controllers/ # Contrôleurs MVC
│ │ ├── Middleware/ # Middleware personnalisés
│ │ └── Requests/ # Form Requests (validation)
│ ├── Models/ # Modèles Eloquent
│ ├── Policies/ # Politiques d'autorisation
│ ├── Jobs/ # Tâches en arrière-plan (Queue)
│ ├── Mail/ # Classes de mail (Mailable)
│ └── Notifications/ # Notifications multi-canaux
├── bootstrap/app.php # Point d'entrée Laravel 11 — Middleware, providers
├── config/ # Fichiers de configuration
├── database/
│ ├── migrations/ # Structure des tables SQL
│ ├── seeders/ # Données initiales
│ └── factories/ # Factories pour les tests
├── public/ # SEUL dossier exposé au web
│ └── index.php # Point d'entrée HTTP — TOUT passe par ici
├── resources/
│ ├── views/ # Templates Blade (.blade.php)
│ └── lang/ # Traductions (fr/, ar/, en/)
├── routes/
│ ├── web.php # Routes navigateur (session, CSRF)
│ └── api.php # Routes API (stateless, préfixe /api)
├── storage/ # Logs, cache, fichiers uploadés
├── tests/ # Tests PHPUnit
├── vendor/ # Packages Composer — jamais versionné
├── .env # Variables d'environnement — JAMAIS sur Git
└── artisan # CLI Laravel
public/ est le seul dossier exposé au webSur un serveur de production, le VirtualHost Apache/Nginx doit pointer vers darigo-api/public/, pas vers darigo-api/. Si DocumentRoot pointe vers la racine, le fichier .env serait accessible — faille de sécurité critique.
Le fichier .env contient toutes les variables qui changent entre les environnements (local, staging, production). Il ne doit jamais être versionné sur Git. Chaque développeur a son propre .env local.
APP_NAME="DariGo API"
APP_ENV=local
APP_KEY= # Généré par : php artisan key:generate
APP_DEBUG=true # false en production OBLIGATOIRE
APP_URL=http://localhost:8000
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=darigo_db
DB_USERNAME=root
DB_PASSWORD=
CACHE_STORE=file # file (dev) | redis (prod)
QUEUE_CONNECTION=sync # sync (dev) | database | redis (prod)
SESSION_DRIVER=database
SESSION_LIFETIME=120
MAIL_MAILER=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_FROM_ADDRESS=noreply@darigo.ma
MAIL_FROM_NAME="DariGo"
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
// A EVITER dans les controllers
$appName = env('APP_NAME'); // Lent, ne fonctionne plus apres config:cache
// RECOMMANDE — via le cache de config
$appName = config('app.name'); // Toujours disponible, mis en cache
// Pourquoi config() ?
// Apres php artisan config:cache, env() retourne null
// config() lit le cache compile — rapide et fiable en production
| Commande | Action | Résultat |
|---|---|---|
php artisan serve | Lancer le serveur de dév | http://localhost:8000 |
php artisan key:generate | Générer APP_KEY | Clé d'encryption dans .env |
php artisan make:model Restaurant -m | Modèle + migration | app/Models/Restaurant.php + migration |
php artisan make:controller RestaurantController --resource | Contrôleur 7 méthodes CRUD | app/Http/Controllers/RestaurantController.php |
php artisan make:request StoreRestaurantRequest | Form Request validation | app/Http/Requests/StoreRestaurantRequest.php |
php artisan migrate | Exécuter les migrations | Tables créées en BDD |
php artisan migrate:fresh --seed | Reset BDD + remigrer + seeder | BDD réinitialisée |
php artisan tinker | REPL PHP interactif | Test Eloquent en direct |
php artisan route:list | Lister toutes les routes | Tableau méthode/URI/controller/middleware |
php artisan config:cache | Mettre en cache la config | Démarrage accéléré en production |
php artisan queue:work | Démarrer le worker de queue | Traitement des jobs en arrière-plan |
php artisan test | Lancer tous les tests | Rapport PHPUnit |
# 1 — Créer le projet
composer create-project laravel/laravel darigo-api
cd darigo-api
# 2 — Générer la clé d'application
php artisan key:generate
# 3 — Créer la base de données MySQL
mysql -u root -p -e "CREATE DATABASE darigo_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
# 4 — Configurer .env : DB_DATABASE=darigo_db, DB_USERNAME=root...
# 5 — Exécuter les migrations par défaut
php artisan migrate
# Tables créées : users, password_reset_tokens, sessions, cache, jobs, failed_jobs
# 6 — Lancer le serveur
php artisan serve
# http://localhost:8000 — page d'accueil Laravel
Tinker permet d'exécuter du code PHP Laravel en temps réel dans le terminal, avec accès complet à Eloquent, aux Facades et au Service Container. Idéal pour tester une requête avant de l'écrire dans un Controller.
php artisan tinker
>>> App\Models\User::count()
=> 0
>>> App\Models\User::factory()->create(['name' => 'Karim Alaoui', 'email' => 'karim@darigo.ma'])
=> App\Models\User { id: 1, name: "Karim Alaoui", email: "karim@darigo.ma" }
>>> App\Models\User::where('email', 'karim@darigo.ma')->first()->name
=> "Karim Alaoui"
>>> DB::table('users')->count()
=> 1
>>> cache()->put('darigo_test', 'fonctionne !', 60)
>>> cache()->get('darigo_test')
=> "fonctionne !"
>>> exit
php artisan config:cache, que se passe-t-il si on appelle env('APP_NAME') dans un Controller ?public/ dans un projet Laravel ?php artisan migrate:fresh --seed est utilisée en production pour réinitialiser la BDD. Vrai ou faux ?Routage Laravel complet
Routes basiques, paramètres requis et facultatifs, contraintes regex, routes nommées, groupes de routes, Route Model Binding, routes de repli, spoofing de méthode et CORS.
- Routes basiques GET/POST/PUT/PATCH/DELETE/OPTIONS sur
web.phpetapi.php - Paramètres requis
{id}et facultatifs{name?}avec valeur par défaut - Contraintes regex sur les paramètres avec
->where() - Routes nommées
->name()et génération d'URL avecroute() - Groupes de routes — middleware, préfixe, namespace
- Route Model Binding implicite et explicite
- CORS, route de repli (
Route::fallback), cache des routes
routes/web.php — routes pour le navigateur. Groupe middleware web : session, protection CSRF, cookies chiffrés. routes/api.php — routes API stateless. Groupe middleware api : préfixe /api automatique, throttle. Pas de session, pas de CSRF. DariGo utilise uniquement api.php.
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\RestaurantController;
use App\Http\Controllers\CommandeController;
// Route simple avec closure
Route::get('/ping', function () {
return response()->json(['message' => 'DariGo API fonctionne !']);
});
// Routes vers un controller
Route::get('/restaurants', [RestaurantController::class, 'index']);
Route::post('/restaurants', [RestaurantController::class, 'store']);
Route::get('/restaurants/{id}', [RestaurantController::class, 'show']);
Route::put('/restaurants/{id}', [RestaurantController::class, 'update']);
Route::delete('/restaurants/{id}', [RestaurantController::class, 'destroy']);
// Repondre a plusieurs methodes
Route::match(['get', 'post'], '/recherche', function () {
return 'recherche';
});
// Repondre a TOUTES les methodes HTTP
Route::any('/webhook', [WebhookController::class, 'handle']);
Les formulaires HTML ne supportent que GET et POST. Pour envoyer PUT ou DELETE depuis un formulaire Blade, ajouter @method('PUT') ou @method('DELETE'). Laravel lit le champ caché _method et route correctement. Pour les API avec fetch ou Axios : pas besoin, envoyer la méthode HTTP directement.
// Parametre requis — {id} est obligatoire
Route::get('/restaurants/{id}', function ($id) {
return "Restaurant $id";
});
// Plusieurs parametres
Route::get('/restaurants/{restoId}/plats/{platId}', function ($restoId, $platId) {
return "Restaurant $restoId, plat $platId";
});
// Parametre facultatif — {ville?} avec valeur par defaut
Route::get('/restaurants/ville/{ville?}', function ($ville = 'Casablanca') {
return "Restaurants a $ville";
});
// Contraintes regex — id doit etre numerique
Route::get('/restaurants/{id}', [RestaurantController::class, 'show'])
->where('id', '[0-9]+');
// Methodes de contrainte courtes (Laravel 8+)
Route::get('/restaurants/{id}', [RestaurantController::class, 'show'])
->whereNumber('id'); // Equivalent a [0-9]+
Route::get('/villes/{slug}', [VilleController::class, 'show'])
->whereAlpha('slug'); // Lettres uniquement
Route::get('/restaurants/{slug}', [RestaurantController::class, 'show'])
->whereAlphaNumeric('slug'); // Lettres et chiffres
// Contrainte globale dans RouteServiceProvider (appliquee partout)
// Route::pattern('id', '[0-9]+');
Si l'URL /restaurants change en /api/v2/restaurants, sans routes nommées il faut chercher-remplacer dans tout le code. Avec route('restaurants.index'), changer l'URI dans routes/api.php suffit — tous les route() se mettent à jour automatiquement.
// Nommer une route
Route::get('/restaurants', [RestaurantController::class, 'index'])->name('restaurants.index');
Route::get('/restaurants/{id}', [RestaurantController::class, 'show'])->name('restaurants.show');
Route::post('/commandes', [CommandeController::class, 'store'])->name('commandes.store');
// Utiliser le nom pour generer une URL
$url = route('restaurants.index');
// http://localhost:8000/api/restaurants
$url = route('restaurants.show', ['id' => 5]);
// http://localhost:8000/api/restaurants/5
$url = route('restaurants.show', ['id' => 5, 'avis' => 'true']);
// http://localhost:8000/api/restaurants/5?avis=true
// Redirection vers une route nommee
return redirect()->route('restaurants.index');
// Verifier si on est sur la route courante
if (request()->routeIs('restaurants.*')) {
// Toutes les routes qui commencent par restaurants.
}
// Obtenir le nom de la route courante
$routeName = request()->route()->getName();
// Routes publiques (sans authentification)
Route::prefix('auth')->name('auth.')->group(function () {
Route::post('/register', [AuthController::class, 'register'])->name('register');
Route::post('/login', [AuthController::class, 'login'])->name('login');
});
// URLs generees : /api/auth/register, /api/auth/login
// Noms generes : auth.register, auth.login
// Routes protegees par Sanctum
Route::middleware('auth:sanctum')->group(function () {
Route::post('/auth/logout', [AuthController::class, 'logout'])->name('auth.logout');
// Routes restaurants
Route::prefix('restaurants')->name('restaurants.')->group(function () {
Route::get('/', [RestaurantController::class, 'index'])->name('index');
Route::post('/', [RestaurantController::class, 'store'])->name('store');
Route::get('/{restaurant}', [RestaurantController::class, 'show'])->name('show');
Route::put('/{restaurant}', [RestaurantController::class, 'update'])->name('update');
Route::delete('/{restaurant}', [RestaurantController::class, 'destroy'])->name('destroy');
// Routes imbriquees — plats d'un restaurant
Route::get('/{restaurant}/plats', [PlatController::class, 'index']);
Route::post('/{restaurant}/plats', [PlatController::class, 'store']);
Route::put('/{restaurant}/plats/{plat}', [PlatController::class, 'update']);
});
// Routes commandes
Route::apiResource('commandes', CommandeController::class);
// Genere automatiquement : index, store, show, update, destroy
// Routes livraisons — livreurs uniquement
Route::middleware('role:livreur')->prefix('livraisons')->group(function () {
Route::get('/', [LivraisonController::class, 'index']);
Route::patch('/{livraison}/accepter', [LivraisonController::class, 'accepter']);
Route::patch('/{livraison}/statut', [LivraisonController::class, 'updateStatut']);
});
});
Sans Route Model Binding : le contrôleur reçoit un $id et doit faire Restaurant::findOrFail($id). Avec : Laravel injecte directement l'objet $restaurant, lève automatiquement une 404 si non trouvé. Moins de code, comportement standardisé.
// AVANT — sans Route Model Binding
Route::get('/restaurants/{id}', function ($id) {
$restaurant = Restaurant::findOrFail($id); // Manuel + risque d'oubli
return response()->json($restaurant);
});
// APRES — binding implicite
// Le parametre {restaurant} correspond au modele Restaurant par convention
Route::get('/restaurants/{restaurant}', function (Restaurant $restaurant) {
return response()->json($restaurant);
// Si restaurant non trouve : 404 automatique
});
// Dans un controller — meme principe
Route::get('/restaurants/{restaurant}', [RestaurantController::class, 'show']);
// RestaurantController.php
public function show(Restaurant $restaurant): JsonResponse
{
return response()->json([
'restaurant' => $restaurant->load('plats'), // eager loading
]);
}
// Binding par un champ autre que l'id (slug par exemple)
Route::get('/restaurants/{restaurant:slug}', [RestaurantController::class, 'show']);
// Laravel cherche Restaurant::where('slug', $valeur)->firstOrFail()
// config/cors.php — Laravel gere CORS via le middleware HandleCors (automatique)
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => [
'http://localhost:3000', // Front React en dev
'https://app.darigo.ma', // Frontend production
],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true, // Requis pour Sanctum (cookies)
];
// Route::fallback — executee si aucune route ne correspond
Route::fallback(function () {
return response()->json([
'message' => 'Route introuvable',
'docs' => 'https://api.darigo.ma/docs',
], 404);
});
// Cache des routes — PRODUCTION UNIQUEMENT
// php artisan route:cache → compile toutes les routes
// php artisan route:clear → vider le cache
// ATTENTION : route:cache ne fonctionne pas avec les closures dans les routes
// Toujours pointer vers des controllers en production
php artisan route:list # Toutes les routes
php artisan route:list --path=api # Filtrer par prefixe
php artisan route:list -v # Avec middleware
php artisan route:list --name=restaurants # Filtrer par nom
Route::get('/restaurants/{restaurant}', [RestaurantController::class, 'show']) — un client envoie GET /api/restaurants/999 et l'id 999 n'existe pas en BDD. Que retourne Laravel ?php artisan route:cache est exécuté en production. Que se passe-t-il si routes/api.php contient des closures (fonctions anonymes) ?Middleware & Protection CSRF
Créer des middleware personnalisés, les enregistrer (global, groupe, route), passer des paramètres, utiliser le middleware terminate, et comprendre la protection CSRF avec les tokens.
- Définition — un middleware comme couche de filtrage avant/après le Controller
- Créer un middleware avec
php artisan make:middleware - Enregistrement global, de groupe et de route dans
bootstrap/app.php - Passer des paramètres à un middleware
- Middleware
terminate— exécuté après l'envoi de la réponse - Protection CSRF —
@csrf, X-CSRF-Token, X-XSRF-Token pour Axios
Imaginez l'entrée d'un restaurant DariGo. Avant que le client (requête) atteigne le serveur (Controller), il passe par un agent de sécurité (middleware) qui vérifie sa réservation (token), l'hébergement (CORS), le débit carbone (rate limiting). Après le service, le middleware peut aussi faire quelque chose lors du départ (logger, analytics).
| Middleware Laravel built-in | Rôle | Où appliqué |
|---|---|---|
auth | Vérifier que l'utilisateur est authentifié | Routes protégées |
auth:sanctum | Authentification via token Sanctum | Routes API DariGo |
verified | Email vérifié | Routes nécessitant un compte validé |
throttle:60,1 | Rate limiting — 60 req/min | Routes API |
web | Session, CSRF, cookies — groupe complet | Groupe routes/web.php |
api | Throttle + stateless — groupe complet | Groupe routes/api.php |
php artisan make:middleware CheckRole # Vérifier le rôle de l'utilisateur
php artisan make:middleware LogApiRequest # Logger toutes les requetes API
php artisan make:middleware EnsureRestaurantOwner # Proprietaire du restaurant
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckRole
{
/**
* Handle an incoming request.
* $next = la prochaine couche (middleware suivant ou controller)
*/
public function handle(Request $request, Closure $next, string ...$roles): Response
{
// --- CODE AVANT le Controller (avant $next) ---
// Verifier que l'utilisateur est authentifie
if (! $request->user()) {
return response()->json(['erreur' => 'Non authentifie'], 401);
}
// Verifier le role
if (! in_array($request->user()->role, $roles)) {
return response()->json([
'erreur' => 'Acces refuse',
'votre_role' => $request->user()->role,
'roles_requis' => $roles,
], 403);
}
// Passer a la suite (prochain middleware ou Controller)
$response = $next($request);
// --- CODE APRES le Controller (apres $next) ---
// Ajouter un header de reponse par exemple
$response->headers->set('X-DariGo-Role', $request->user()->role);
return $response;
}
}
public function handle(Request $request, Closure $next): Response
{
// AVANT : logger la requete entrante
\Log::info('DariGo API Request', [
'method' => $request->method(),
'url' => $request->fullUrl(),
'user_id' => $request->user()?->id,
'ip' => $request->ip(),
]);
return $next($request);
}
Dans Laravel 10 et antérieures, les middleware étaient enregistrés dans app/Http/Kernel.php. Laravel 11 a supprimé ce fichier. Tout se fait dans bootstrap/app.php.
use App\Http\Middleware\CheckRole;
use App\Http\Middleware\LogApiRequest;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
)
->withMiddleware(function (Middleware $middleware) {
// Middleware global — execute sur TOUTES les requetes
$middleware->append(LogApiRequest::class);
// Alias — utiliser un alias court dans les routes
$middleware->alias([
'role' => CheckRole::class,
'owner' => EnsureRestaurantOwner::class,
]);
// Exclure une route du CSRF (webhooks par exemple)
$middleware->validateCsrfTokens(except: [
'/stripe/webhook',
'/api/*', // Toutes les routes API sont exclues du CSRF
]);
})
->create();
// Middleware role avec parametre
Route::middleware(['auth:sanctum', 'role:admin'])->group(function () {
Route::delete('/restaurants/{restaurant}', [RestaurantController::class, 'destroy']);
});
// Plusieurs roles acceptes
Route::middleware(['auth:sanctum', 'role:restaurateur,admin'])->group(function () {
Route::post('/restaurants', [RestaurantController::class, 'store']);
Route::put('/restaurants/{restaurant}', [RestaurantController::class, 'update']);
});
// Middleware directement sur une route
Route::patch('/commandes/{commande}/statut', [CommandeController::class, 'updateStatut'])
->middleware(['auth:sanctum', 'role:livreur,admin']);
class LogApiRequest
{
private float $startTime;
public function handle(Request $request, Closure $next): Response
{
$this->startTime = microtime(true); // Demarrer le chrono
return $next($request);
}
// terminate() est appele APRES que la reponse a ete envoyee au client
// Ideal pour les operations lentes (BDD, API tierces) sans ralentir la reponse
public function terminate(Request $request, Response $response): void
{
$duration = round((microtime(true) - $this->startTime) * 1000, 2); // ms
\Log::info('DariGo API Response', [
'url' => $request->path(),
'method' => $request->method(),
'status' => $response->getStatusCode(),
'duration_ms' => $duration,
'user_id' => $request->user()?->id,
]);
// Alerter si la reponse depasse 500ms
if ($duration > 500) {
\Log::warning('Reponse lente DariGo', ['url' => $request->path(), 'ms' => $duration]);
}
}
}
Un site malveillant faux-darigo.ma fait soumettre un formulaire à darigo.ma/commande depuis le navigateur d'un utilisateur connecté. Sans protection CSRF, la requête aboutit car le navigateur envoie automatiquement les cookies de session. Le token CSRF — généré par Laravel et valide uniquement pour le vrai site — empêche cette attaque.
<!-- Formulaire Blade : @csrf genere un champ cache _token -->
<form method="POST" action="/commandes">
@csrf
<!-- Genere : <input type="hidden" name="_token" value="aBcXyz..."> -->
<input type="text" name="adresse">
<button type="submit">Commander</button>
</form>
<!-- Spoofing de methode pour PUT/DELETE -->
<form method="POST" action="/restaurants/{{ $restaurant->id }}">
@csrf
@method('PUT')
<!-- Laravel lit le champ _method=PUT et route correctement -->
</form>
// Etape 1 : recuperer le cookie XSRF-TOKEN via la route sanctum
await axios.get('/sanctum/csrf-cookie'); // Sanctum cree le cookie XSRF-TOKEN
// Etape 2 : Axios lit automatiquement XSRF-TOKEN et l'envoie en header X-XSRF-TOKEN
// Pas besoin de configuration manuelle avec Axios
const response = await axios.post('/api/commandes', {
restaurant_id: 5,
lignes: [{ plat_id: 12, quantite: 2 }]
});
// Pour les routes API (routes/api.php) : CSRF n'est PAS requis
// Les API utilisent Bearer tokens (Sanctum), pas les sessions/cookies
routes/api.php avec Sanctum Bearer tokens. Faut-il ajouter @csrf dans les requêtes Postman ?CheckRestaurantHoraires doit bloquer les commandes après 23h. Où placer le code de vérification dans la méthode handle() ?Contrôleurs & Injection de dépendances
Contrôleur basique vs contrôleur ressource (7 méthodes CRUD), apiResource pour les API pures, injection de dépendances, Form Request, contrôleur avec middleware intégré.
- Contrôleur basique vs contrôleur ressource — quand choisir quoi
- Les 7 méthodes d'un Resource Controller : index, create, store, show, edit, update, destroy
--api— 5 méthodes pour API (sans create et edit)- Injection de dépendances dans le constructeur et dans les méthodes
- Form Request — déléguer la validation hors du Controller
- Single Action Controller —
__invoke()pour une action unique
# Controller resource complet (web — avec create et edit pour formulaires Blade)
php artisan make:controller RestaurantController --resource
# Controller API (sans create et edit — pas de formulaires HTML)
php artisan make:controller RestaurantController --api
# Controller + model + migration + factory + seeder en une commande
php artisan make:controller RestaurantController --api --model=Restaurant
# Single action controller (une seule action)
php artisan make:controller CalculerTarif --invokable
| Méthode | Verbe HTTP | URI | Nom de route | Description | API ? |
|---|---|---|---|---|---|
index() | GET | /restaurants | restaurants.index | Liste tous les restaurants | Oui |
create() | GET | /restaurants/create | restaurants.create | Formulaire de création | Non |
store() | POST | /restaurants | restaurants.store | Enregistrer un nouveau restaurant | Oui |
show() | GET | /restaurants/{id} | restaurants.show | Afficher un restaurant | Oui |
edit() | GET | /restaurants/{id}/edit | restaurants.edit | Formulaire de modification | Non |
update() | PUT/PATCH | /restaurants/{id} | restaurants.update | Mettre à jour un restaurant | Oui |
destroy() | DELETE | /restaurants/{id} | restaurants.destroy | Supprimer un restaurant | Oui |
// Route::apiResource — enregistre les 5 routes API (sans create et edit)
Route::apiResource('restaurants', RestaurantController::class);
// Limiter les routes enregistrees
Route::apiResource('commandes', CommandeController::class)->only(['index', 'store', 'show']);
Route::apiResource('plats', PlatController::class)->except(['destroy']);
// Verifier les routes generees
// php artisan route:list --name=restaurants
<?php
namespace App\Http\Controllers;
use App\Models\Restaurant;
use App\Http\Requests\StoreRestaurantRequest;
use App\Http\Requests\UpdateRestaurantRequest;
use Illuminate\Http\JsonResponse;
class RestaurantController extends Controller
{
// Proteger toutes les methodes sauf index et show
public function __construct()
{
$this->middleware('auth:sanctum')->except(['index', 'show']);
$this->middleware('role:restaurateur,admin')->only(['store', 'update', 'destroy']);
}
// GET /api/restaurants — liste avec pagination et filtres
public function index(): JsonResponse
{
$query = Restaurant::with('plats') // Eager loading — eviter N+1
->where('actif', true);
// Filtres via query string : /api/restaurants?ville=Casablanca&categorie=tajine
if (request('ville')) {
$query->where('ville', 'like', '%' . request('ville') . '%');
}
if (request('categorie')) {
$query->where('categorie', request('categorie'));
}
$restaurants = $query->orderBy('note', 'desc')->paginate(10);
return response()->json($restaurants);
}
// POST /api/restaurants — creer un restaurant
public function store(StoreRestaurantRequest $request): JsonResponse
{
// $request->validated() retourne uniquement les champs valides
$restaurant = Restaurant::create([
...$request->validated(),
'proprietaire_id' => auth()->id(),
]);
return response()->json([
'message' => 'Restaurant cree avec succes',
'restaurant' => $restaurant,
], 201);
}
// GET /api/restaurants/{restaurant} — Route Model Binding
public function show(Restaurant $restaurant): JsonResponse
{
return response()->json([
'restaurant' => $restaurant->load(['plats', 'avis']),
]);
}
// PUT /api/restaurants/{restaurant}
public function update(UpdateRestaurantRequest $request, Restaurant $restaurant): JsonResponse
{
// Verifier que l'utilisateur est bien le proprietaire
$this->authorize('update', $restaurant); // Policy
$restaurant->update($request->validated());
return response()->json([
'message' => 'Restaurant mis a jour',
'restaurant' => $restaurant->fresh(), // Recharger depuis la BDD
]);
}
// DELETE /api/restaurants/{restaurant}
public function destroy(Restaurant $restaurant): JsonResponse
{
$this->authorize('delete', $restaurant);
$restaurant->delete();
return response()->json(['message' => 'Restaurant supprime'], 200);
}
}
$request->validate() dans le Controller ?Valider dans le Controller crée un Controller gras et difficile à tester. La Form Request encapsule la logique de validation dans une classe dédiée, réutilisable et testable indépendamment. Le Controller reste "mince" — il reçoit des données déjà validées.
php artisan make:request StoreRestaurantRequest
php artisan make:request UpdateRestaurantRequest
php artisan make:request StoreCommandeRequest
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreRestaurantRequest extends FormRequest
{
// Autoriser ou non cette requete (avant les regles)
public function authorize(): bool
{
// Seuls les restaurateurs et admins peuvent creer un restaurant
return in_array(auth()->user()?->role, ['restaurateur', 'admin']);
}
// Regles de validation
public function rules(): array
{
return [
'nom' => ['required', 'string', 'min:3', 'max:100'],
'ville' => ['required', 'string'],
'adresse' => ['required', 'string', 'max:255'],
'telephone' => ['nullable', 'string', 'regex:/^(0[5-7][0-9]{8})$/'],
'categorie' => ['required', 'in:tajine,couscous,pizza,burger,sushi,fruits_de_mer,marocain'],
'horaires' => ['nullable', 'array'],
'horaires.*' => ['string'],
];
}
// Messages d'erreur personnalises
public function messages(): array
{
return [
'nom.required' => 'Le nom du restaurant est obligatoire',
'nom.min' => 'Le nom doit contenir au moins :min caracteres',
'categorie.in' => 'Categorie invalide. Valeurs : tajine, couscous, pizza, burger...',
'telephone.regex' => 'Telephone invalide. Format marocain : 06XXXXXXXX ou 07XXXXXXXX',
];
}
// Noms conviviaux des champs dans les messages d'erreur
public function attributes(): array
{
return [
'nom' => 'nom du restaurant',
'adresse' => 'adresse du restaurant',
'categorie' => 'categorie culinaire',
];
}
}
Route::resource() et Route::apiResource() ?StoreRestaurantRequest, la méthode authorize() retourne false. Que se passe-t-il ?Vues & Templates Blade
Créer des vues Blade, transmettre des données, utiliser @extends/@section/@yield pour les layouts, les directives (@if, @foreach, @auth), les composants et les View Composers.
- Créer et retourner une vue avec
view()— transmettre des données - Layout principal avec
@extends,@section,@yield - Directives de contrôle :
@if,@foreach,@forelse,@for,@while - Directives d'authentification :
@auth,@guest,@can,@role {{ }}vs{!! !!}— échappement XSS- Composants Blade
@componentet Anonymous Components - View Composers — injecter des données automatiquement dans des vues
Blade est le moteur de templates de Laravel. Les fichiers Blade ont l'extension .blade.php et sont compilés en PHP pur puis mis en cache. Performance identique à du PHP pur, avec la lisibilité des directives. Pour DariGo : les vues Blade servent pour le panneau d'administration (non API).
// Methode 1 — tableau associatif
return view('restaurants.index', [
'restaurants' => Restaurant::paginate(12),
'villes' => ['Casablanca', 'Rabat', 'Marrakech', 'Fes'],
'titre' => 'Tous les restaurants DariGo',
]);
// Methode 2 — compact() (variables PHP → cles du tableau)
$restaurants = Restaurant::paginate(12);
$villes = ['Casablanca', 'Rabat', 'Marrakech'];
return view('restaurants.index', compact('restaurants', 'villes'));
// Methode 3 — with() chainable
return view('restaurants.index')
->with('restaurants', Restaurant::paginate(12))
->with('titre', 'DariGo Restaurants');
{{-- Commentaire Blade — non visible dans le HTML generé --}}
{{-- {{ }} : echappe automatiquement le HTML — protection XSS --}}
<h1>{{ $titre }}</h1>
{{-- Si $titre = "<script>alert('XSS')</script>" → affiche le texte sans executer --}}
{{-- {!! !!} : affiche le HTML brut — UNIQUEMENT pour du HTML de confiance --}}
<div class="description">{!! $restaurant->description_html !!}</div>
{{-- Valeur par defaut si null --}}
<p>{{ $restaurant->telephone ?? 'Non renseigne' }}</p>
{{-- Variable globale $errors (toujours disponible) --}}
@if ($errors->any())
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
@endif
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>@yield('title', 'DariGo — Livraison de repas')</title>
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
@stack('styles') {{-- Les vues enfants peuvent ajouter du CSS --}}
</head>
<body>
@include('partials.navbar') {{-- Inclure la navbar --}}
<main class="container">
@yield('content') {{-- Zone principale — remplie par les vues enfants --}}
</main>
@include('partials.footer')
<script src="{{ asset('js/app.js') }}"></script>
@stack('scripts') {{-- Scripts specifiques aux pages --}}
</body>
</html>
@extends('layouts.app') {{-- Heriter du layout principal --}}
@section('title', 'Restaurants — DariGo') {{-- Remplir le titre --}}
@section('content')
<h1>Restaurants disponibles à {{ $ville ?? 'Casablanca' }}</h1>
@if($restaurants->isEmpty())
<p>Aucun restaurant disponible pour le moment.</p>
@else
<div class="grid">
@foreach($restaurants as $restaurant)
@include('partials.restaurant-card', ['restaurant' => $restaurant])
@endforeach
</div>
{{-- Pagination --}}
{{ $restaurants->links() }}
@endif
@endsection
@push('scripts')
{{-- Script specifique a cette page --}}
<script src="{{ asset('js/restaurants-filter.js') }}"></script>
@endpush
{{-- Conditions --}}
@if($restaurant->actif)
<span class="badge-green">Ouvert</span>
@elseif($restaurant->horaire_fermeture > now())
<span class="badge-orange">Ferme bientot</span>
@else
<span class="badge-red">Ferme</span>
@endif
{{-- @unless — l'inverse de @if --}}
@unless($restaurant->actif)
<p>Ce restaurant est temporairement ferme.</p>
@endunless
{{-- @forelse — foreach avec gestion du cas vide --}}
@forelse($restaurants as $restaurant)
<div>{{ $restaurant->nom }}</div>
@empty
<p>Aucun restaurant dans votre ville.</p>
@endforelse
{{-- Variable $loop dans @foreach --}}
@foreach($plats as $plat)
<tr class="{{ $loop->even ? 'bg-gray' : '' }}">
<td>{{ $loop->iteration }}</td> {{-- 1, 2, 3... --}}
<td>{{ $plat->nom }}</td>
@if($loop->last) {{-- Dernier element --}}
<td class="last">{{ $plat->prix }} MAD</td>
@endif
</tr>
@endforeach
{{-- Authentification --}}
@auth
<p>Bonjour, {{ auth()->user()->prenom }} !</p>
<a href="/commandes">Mes commandes</a>
@endauth
@guest
<a href="/login">Se connecter pour commander</a>
@endguest
{{-- Autorisation (Gates/Policies) --}}
@can('update', $restaurant)
<a href="/restaurants/{{ $restaurant->id }}/edit">Modifier</a>
@endcan
@cannot('delete', $restaurant)
{{-- Masquer le bouton supprimer si non autorise --}}
@endcannot
{{-- Environnement --}}
@env('local')
<div class="debug-bar">Mode développement</div>
@endenv
php artisan make:component RestaurantCard # Composant avec classe PHP
php artisan make:component Alert --view # Composant anonyme (sans classe)
{{-- Composant anonyme — reusable partout --}}
@props([
'restaurant', // Obligatoire
'showRating' => true, // Facultatif avec valeur par defaut
])
<div class="restaurant-card {{ $restaurant->actif ? 'active' : 'closed' }}">
<h3>{{ $restaurant->nom }}</h3>
<p>{{ $restaurant->ville }} — {{ $restaurant->categorie }}</p>
@if($showRating)
<span>★ {{ number_format($restaurant->note, 1) }}</span>
@endif
{{ $slot }} {{-- Contenu injecte entre les balises --}}
</div>
@foreach($restaurants as $restaurant)
<x-restaurant-card :restaurant="$restaurant" :show-rating="true">
<!-- Contenu dans $slot -->
<a href="/restaurants/{{ $restaurant->id }}">Voir le menu</a>
</x-restaurant-card>
@endforeach
{{ $restaurant->description }} vs {!! $restaurant->description !!}. Un utilisateur a stocké <script>alert('hack')</script> comme description. Que se passe-t-il avec chaque syntaxe ?Requêtes, Réponses & Sessions
L'objet Request (input, fichiers, headers, IP), les types de réponses (response()->json(), redirect(), fichiers), sessions HTTP (get/put/flash/forget), Response Macro.
- L'objet
Request— accès à l'input, aux fichiers, aux headers, à l'IP - Méthodes
input(),only(),except(),has(),filled() - Upload de fichiers —
store(), validation MIME, taille max - Types de réponses — JSON, redirect, fichier, stream
- Sessions —
put(),get(),flash(),forget(),regenerate()
public function store(Request $request): JsonResponse
{
// ---- LIRE LES DONNEES ----
// Tous les champs (POST body + query string)
$donnees = $request->all();
// Un seul champ
$nom = $request->input('nom'); // null si absent
$nom = $request->input('nom', 'Sans nom'); // Valeur par defaut
$nom = $request->nom; // Propriete magique — equivalent
// Plusieurs champs specifiques
$champs = $request->only(['nom', 'ville', 'categorie']);
// ['nom' => 'DariGo', 'ville' => 'Casablanca', 'categorie' => 'tajine']
// Tous sauf certains champs
$sansPassword = $request->except(['password', 'password_confirmation']);
// ---- VERIFIER LA PRESENCE ----
if ($request->has('telephone')) { // Existe dans la requete (meme vide)
$tel = $request->input('telephone');
}
if ($request->filled('adresse')) { // Existe ET non vide
$adresse = $request->input('adresse');
}
if ($request->missing('categorie')) { // Manquant
return response()->json(['erreur' => 'Categorie manquante'], 400);
}
// ---- INFORMATIONS SUR LA REQUETE ----
$method = $request->method(); // 'POST', 'GET', 'PUT'...
$isPost = $request->isMethod('post'); // true/false
$url = $request->url(); // http://api.darigo.ma/api/restaurants
$fullUrl = $request->fullUrl(); // Avec query string
$path = $request->path(); // api/restaurants
$ip = $request->ip(); // IP du client
$isApi = $request->expectsJson(); // Attend une reponse JSON (Accept: application/json)
$isAjax = $request->ajax(); // Requete AJAX (X-Requested-With: XMLHttpRequest)
// ---- HEADERS ----
$token = $request->header('Authorization');
$accept = $request->header('Accept', 'application/json'); // Valeur par defaut
$allHeaders = $request->headers->all();
}
public function uploadPhoto(Request $request, Restaurant $restaurant): JsonResponse
{
$request->validate([
'photo' => ['required', 'file', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
// Types MIME autorises Max 2 Mo
]);
$file = $request->file('photo');
// Verifications
if (! $file->isValid()) {
return response()->json(['erreur' => 'Fichier corrompu'], 422);
}
$extension = $file->extension(); // 'jpg', 'png'
$taille = $file->getSize(); // Taille en octets
$mimetype = $file->getMimeType(); // 'image/jpeg'
// Stocker le fichier
// store() genere un nom unique automatiquement
$path = $file->store('restaurants/photos', 'public');
// Stocke dans storage/app/public/restaurants/photos/nomUnique.jpg
// Accessible via /storage/restaurants/photos/nomUnique.jpg
// storeAs() pour garder le nom original
$path = $file->storeAs(
'restaurants/photos',
'restaurant-' . $restaurant->id . '.' . $extension,
'public'
);
// Mettre a jour le restaurant
$restaurant->update(['photo' => $path]);
return response()->json([
'message' => 'Photo mise a jour',
'photo_url' => Storage::url($path),
]);
}
// ---- REPONSES JSON (API DariGo) ----
return response()->json($data); // 200 OK
return response()->json($data, 201); // 201 Created
return response()->json(['erreur' => 'Not found'], 404); // 404
return response()->json($data)->header('X-Version', '1.0');
// ---- REDIRECTIONS (vues Blade) ----
return redirect('/restaurants'); // Vers un URI
return redirect()->route('restaurants.index'); // Vers une route nommee
return redirect()->back(); // Page precedente
return redirect()->back()->withErrors($validator); // Avec erreurs de validation
return redirect()->back()->with('success', 'Commande passee !'); // Avec message flash
// ---- TELECHARGEMENT DE FICHIER ----
return response()->download(storage_path('app/factures/fact-001.pdf'));
return response()->download($path, 'facture-darigo.pdf'); // Nom personnalise
return response()->file(storage_path('app/public/menu.pdf')); // Afficher dans navigateur
// ---- RESPONSE MACRO — reponse API personnalisee ----
// Dans un ServiceProvider (AppServiceProvider::boot())
Response::macro('darigo', function ($data, $message = 'Succes', $status = 200) {
return Response::json([
'succes' => $status < 400,
'message' => $message,
'data' => $data,
], $status);
});
// Utilisation dans les controllers
return response()->darigo($restaurant, 'Restaurant cree', 201);
// {"succes":true, "message":"Restaurant cree", "data":{...}}
Les sessions n'ont de sens que dans les routes web.php (middleware web avec gestion de session). Les routes API (api.php) sont stateless — pas de sessions. DariGo utilise les sessions uniquement dans le panneau d'administration Blade.
// Stocker en session
session()->put('ville_preference', 'Casablanca');
session(['panier' => ['plat_id' => 5, 'quantite' => 2]]);
// Lire en session
$ville = session('ville_preference'); // null si absent
$ville = session('ville_preference', 'Casablanca'); // Valeur par defaut
$ville = session()->get('ville_preference'); // Equivalent
// Verifier
if (session()->has('panier')) { ... } // Existe (meme null)
if (session()->exists('panier')) { ... } // Existe (meme si null)
// Messages flash — disponibles uniquement pour la PROCHAINE requete
session()->flash('success', 'Restaurant cree avec succes !');
session()->flash('error', 'Telephone invalide');
// Lire en Blade :
// @if(session('success')) {{ session('success') }} @endif
// Supprimer
session()->forget('ville_preference');
session()->flush(); // Vider TOUTE la session
// Regenerer l'ID de session (apres login — prevenir session fixation)
session()->regenerate();
routes/api.php), un Controller appelle session()->put('commande', $data). Que se passe-t-il ?Validation, Gestion des erreurs & Logging
Règles de validation complètes, messages personnalisés, règles custom, gestion globale des exceptions dans bootstrap/app.php, logging avec Monolog (channels, niveaux).
- Validation inline avec
$request->validate()vs Form Request - 30+ règles Laravel : required, unique, exists, regex, between, confirmed...
- Créer une règle de validation custom avec
php artisan make:rule - Réponse automatique 422 pour les API avec le détail des erreurs
- Gestion globale des exceptions —
bootstrap/app.php - Logging — niveaux, canaux (stack, daily, slack), contexte
use Illuminate\Validation\Rule;
// Dans un Controller ou Form Request
$request->validate([
// Chaines
'nom' => ['required', 'string', 'min:3', 'max:100'],
'description' => ['nullable', 'string', 'max:1000'],
// Email
'email' => ['required', 'email:rfc,dns', 'unique:users,email'],
// Validation DNS Unique dans table users
// Numerique
'prix' => ['required', 'numeric', 'between:0,9999.99', 'gt:0'],
'stock' => ['required', 'integer', 'min:0'],
'quantite' => ['required', 'integer', 'min:1', 'max:100'],
// Mot de passe
'password' => ['required', 'string', 'min:8', 'confirmed'],
// password_confirmation requis
// Telephone marocain
'telephone' => ['required', 'regex:/^(0[5-7][0-9]{8})$/'],
// Valeur dans une liste
'categorie' => ['required', Rule::in(['tajine', 'couscous', 'pizza', 'burger'])],
'statut' => ['required', Rule::in(['en_attente', 'confirmee', 'livree', 'annulee'])],
// Reference vers un enregistrement BDD (FK)
'restaurant_id' => ['required', 'exists:restaurants,id'],
'plat_id' => ['required', Rule::exists('plats', 'id')->where('actif', true)],
// Doit exister ET etre actif
// Unique avec exception (lors d'un update — ignorer l'enregistrement courant)
'email' => ['required', Rule::unique('users')->ignore($user->id)],
// Tableau et sous-elements
'lignes' => ['required', 'array', 'min:1'],
'lignes.*.plat_id' => ['required', 'exists:plats,id'],
'lignes.*.quantite' => ['required', 'integer', 'min:1'],
// Fichier
'photo' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
// Conditions
'adresse_livraison' => ['required_if:type_livraison,domicile'],
// Requis si type_livraison = domicile
]);
// Reponse automatique si validation echoue :
// - Routes web.php : redirect()->back()->withErrors()
// - Routes api.php : 422 JSON avec {'message': 'The given data was invalid.', 'errors': {...}}
php artisan make:rule HorairesOuverture # Valider les horaires d'ouverture DariGo
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class HorairesOuverture implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
// $value doit etre un tableau de type ['lundi' => '09:00-22:00', ...]
if (! is_array($value)) {
$fail("Les :attribute doivent etre un tableau.");
return;
}
$joursValides = ['lundi','mardi','mercredi','jeudi','vendredi','samedi','dimanche'];
foreach ($value as $jour => $horaire) {
if (! in_array($jour, $joursValides)) {
$fail("Jour invalide dans :attribute : $jour");
return;
}
// Format heure : 09:00-22:00
if (! preg_match('/^\d{2}:\d{2}-\d{2}:\d{2}$/', $horaire)) {
$fail("Format invalide pour $jour. Attendu : HH:MM-HH:MM (ex: 09:00-22:00)");
}
}
}
}
// Utilisation :
// 'horaires' => ['nullable', new HorairesOuverture]
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Auth\Access\AuthorizationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
->withExceptions(function (Exceptions $exceptions) {
// Quand un modele n'est pas trouve (Route Model Binding)
$exceptions->render(function (ModelNotFoundException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json([
'erreur' => 'Ressource introuvable',
'type' => class_basename($e->getModel()),
], 404);
}
});
// Route non trouvee
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json([
'erreur' => 'Route introuvable',
'docs' => 'https://api.darigo.ma/docs',
], 404);
}
});
// Non authentifie
$exceptions->render(function (AuthenticationException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json(['erreur' => 'Non authentifie'], 401);
}
});
// Non autorise (Gate/Policy)
$exceptions->render(function (AuthorizationException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json(['erreur' => 'Action non autorisee'], 403);
}
});
})
| Niveau | Utilisation | Exemple DariGo |
|---|---|---|
Log::debug() | Débogage détaillé — dev uniquement | Valeurs de variables pendant le dev |
Log::info() | Information normale | Nouvelle commande créée, livreur affecté |
Log::notice() | Événement notable mais normal | Restaurant fermé manuellement par admin |
Log::warning() | Situation à surveiller | RabbitMQ indisponible, tentative rate limit |
Log::error() | Erreur récupérable | Paiement échoué, SMS non envoyé |
Log::critical() | Erreur critique — intervention requise | Base de données inaccessible |
use Illuminate\Support\Facades\Log;
// Log simple
Log::info('Nouvelle commande DariGo');
// Log avec contexte (tableau de donnees supplementaires)
Log::info('Commande creee', [
'commande_id' => $commande->id,
'client_id' => auth()->id(),
'restaurant' => $commande->restaurant->nom,
'total' => $commande->total . ' MAD',
]);
// Log conditionnel — seulement en production
Log::channel('slack')->critical('BDD inaccessible !', [
'error' => $e->getMessage(),
]);
// config/logging.php — canal 'daily' cree un fichier par jour
// storage/logs/laravel-2024-11-15.log
// Exemple de log genere :
// [2024-11-15 14:23:01] local.INFO: Commande creee {"commande_id":123,"client_id":7,...}
POST /api/restaurants avec { "email": "invalid-email" }. L'API attend un email valide avec la règle 'email' => 'required|email'. Quel code HTTP est retourné ?'email' => 'unique:users,email' est utilisée lors d'un update de profil. L'utilisateur soumet le même email que le sien (sans changement). Que se passe-t-il ?Artisan avancé & Task Scheduling
Créer des commandes Artisan personnalisées avec arguments et options, planifier des tâches récurrentes avec le scheduler Laravel (cron unique), et maîtriser les commandes Artisan avancées du quotidien.
- Créer une commande custom avec
php artisan make:command - Définir des arguments obligatoires et des options facultatives
- Afficher de la progression avec
info(),warn(),error(),progressBar() - Planifier des tâches avec
schedule:run— un seul cron pour toute l'application - Fréquences :
everyMinute(),hourly(),daily(),cron() - Conditions et callbacks :
when(),skip(),then()
php artisan make:command EnvoyerRappelsLivraison
php artisan make:command SynchroniserMenus
php artisan make:command NettoyerCommandesExpirees
<?php
namespace App\Console\Commands;
use App\Models\Commande;
use App\Notifications\RappelLivraisonNotification;
use Illuminate\Console\Command;
class EnvoyerRappelsLivraison extends Command
{
// Signature : nom + arguments + options
// {ville} = argument obligatoire
// {--dry-run} = option sans valeur (flag boolean)
// {--delai=30} = option avec valeur par defaut
protected $signature = 'darigo:rappels-livraison
{ville : Ville cible (ex: Casablanca)}
{--dry-run : Simuler sans envoyer}
{--delai=30 : Delai en minutes avant rappel}';
protected $description = 'Envoyer des rappels aux clients dont la livraison est en retard';
public function handle(): int
{
$ville = $this->argument('ville');
$dryRun = $this->option('dry-run');
$delai = (int) $this->option('delai');
$this->info("DariGo — Rappels livraison pour : $ville");
$this->info("Delai : {$delai} minutes | Simulation : " . ($dryRun ? 'Oui' : 'Non'));
// Recuperer les commandes en retard
$commandes = Commande::where('ville', $ville)
->where('statut', 'en_livraison')
->where('updated_at', '<', now()->subMinutes($delai))
->get();
if ($commandes->isEmpty()) {
$this->warn("Aucune commande en retard trouvee pour $ville.");
return Command::SUCCESS;
}
$this->info("Commandes en retard : {$commandes->count()}");
// Barre de progression
$bar = $this->output->createProgressBar($commandes->count());
$bar->start();
$envoyes = 0;
foreach ($commandes as $commande) {
if (! $dryRun) {
$commande->client->notify(new RappelLivraisonNotification($commande));
$envoyes++;
}
$bar->advance();
}
$bar->finish();
$this->newLine();
if ($dryRun) {
$this->warn("Mode simulation — aucun rappel envoye.");
} else {
$this->info("$envoyes rappel(s) envoye(s) avec succes.");
}
return Command::SUCCESS;
}
}
# Simulation — sans envoyer
php artisan darigo:rappels-livraison Casablanca --dry-run
# Production — avec delai de 45 minutes
php artisan darigo:rappels-livraison Casablanca --delai=45
# Appeler depuis un Controller ou un Job
Artisan::call('darigo:rappels-livraison', [
'ville' => 'Rabat',
'--delai' => 20,
]);
| Méthode de sortie | Couleur | Usage |
|---|---|---|
$this->info() | Vert | Messages normaux, succès |
$this->warn() | Jaune | Avertissements, simulations |
$this->error() | Rouge | Erreurs, échecs |
$this->line() | Blanc | Texte neutre |
$this->comment() | Jaune clair | Commentaires secondaires |
$this->ask() | — | Demander une saisie interactive |
$this->confirm() | — | Confirmation oui/non |
$this->table() | — | Afficher un tableau formété |
Sans Laravel Scheduler : chaque tâche récurrente nécessite sa propre ligne cron sur le serveur. Avec le Scheduler : un seul cron * * * * * php artisan schedule:run qui s'exécute chaque minute et décide lui-même quelles tâches lancer selon leurs fréquences. Avantage : toute la planification est dans le code versionné, pas sur le serveur.
use Illuminate\Support\Facades\Schedule;
// T1 — Envoyer les rappels de livraison toutes les 30 minutes
Schedule::command('darigo:rappels-livraison Casablanca --delai=30')
->everyThirtyMinutes()
->withoutOverlapping() // Ne pas lancer si l'execution precedente est encore en cours
->onSuccess(function () {
\Log::info('Rappels livraison Casablanca envoyes');
});
// T2 — Nettoyer les commandes expirees chaque nuit a 2h
Schedule::command('darigo:nettoyer-commandes --statut=annulee --jours=30')
->dailyAt('02:00')
->environments(['production']) // Uniquement en production
->emailOutputOnFailure('ops@darigo.ma');
// T3 — Synchroniser les menus des restaurants toutes les heures
Schedule::command('darigo:synchroniser-menus')
->hourly()
->when(function () {
// Ne lancer que si RabbitMQ est disponible
try {
return app('amqp')->isConnected();
} catch (\Exception $e) {
return false;
}
});
// T4 — Rapport journalier par email a 8h du matin
Schedule::command('darigo:rapport-journalier')
->dailyAt('08:00')
->weekdays() // Uniquement en semaine
// T5 — Job en arriere-plan (Queue)
Schedule::job(new \App\Jobs\AnalyserPerformancesLivreurs)
->weekly()
->sundays()
->at('23:00');
// T6 — Closure simple
Schedule::call(function () {
\App\Models\Commande::where('statut', 'en_attente')
->where('created_at', '<', now()->subHours(2))
->update(['statut' => 'expiree']);
})
->everyFifteenMinutes()
->name('expirer-commandes-anciennes')
->onOneServer(); // Sur un seul serveur si cluster
# Editer le crontab : crontab -e
* * * * * cd /var/www/darigo-api && php artisan schedule:run >> /dev/null 2>&1
# Tester localement sans attendre le cron
php artisan schedule:run # Executer les taches dues maintenant
php artisan schedule:list # Lister toutes les taches planifiees
php artisan schedule:test # Tester une tache specifique interactivement
php artisan schedule:work # Mode daemon (dev) — execute toutes les 60s
| Méthode | Fréquence |
|---|---|
->everyMinute() | Chaque minute |
->everyFiveMinutes() | Toutes les 5 min |
->everyThirtyMinutes() | Toutes les 30 min |
->hourly() | Chaque heure |
->daily() | Chaque jour à minuit |
->dailyAt('08:00') | Chaque jour à 8h |
->weekly() | Chaque dimanche à minuit |
->monthly() | 1er du mois à minuit |
->cron('0 9 * * 1-5') | Cron personnalisé |
->withoutOverlapping() est ajoutée à une tâche planifiée. Que se passe-t-il si l'exécution précédente n'est pas terminée quand la suivante est due ?Command::FAILURE au lieu de Command::SUCCESS. Quelle est la différence ?Collections, Cache & Helpers
Les Collections Laravel (collect()) — méthodes chaînées puissantes pour manipuler des données, le système de Cache (Cache::remember()) pour optimiser les performances, et les Helpers globaux (Str::, Arr::, now()).
- Créer une Collection avec
collect()et depuis Eloquent - Méthodes essentielles :
map(),filter(),groupBy(),pluck(),reduce(),each() - Méthodes d'agrégation :
sum(),avg(),max(),count(),sortBy() - Cache drivers : file, database, Redis —
Cache::remember() - Tags de cache, invalidation,
Cache::tags()->flush() - Helpers
Str::etArr::les plus utiles
Une Collection est un tableau PHP amélioré avec 100+ méthodes chaînables. Eloquent retourne toujours des Collections (pas des tableaux simples). collect([...]) transforme n'importe quel tableau en Collection. Les méthodes sont immutables — elles retournent une nouvelle Collection sans modifier l'originale.
$commandes = Commande::with('plats')->where('client_id', 5)->get();
// $commandes est une Collection d'objets Commande
// ---- TRANSFORMATION ----
// map() : transformer chaque element
$totaux = $commandes->map(function ($commande) {
return [
'id' => $commande->id,
'total' => $commande->total,
'statut' => $commande->statut,
];
});
// Syntaxe courte PHP 8 (arrow function)
$totaux = $commandes->map(fn($c) => ['id' => $c->id, 'total' => $c->total]);
// ---- FILTRAGE ----
// filter() : garder les elements qui passent le test
$commandesLivrees = $commandes->filter(fn($c) => $c->statut === 'livree');
$commandesPlus100 = $commandes->filter(fn($c) => $c->total > 100);
// reject() : inverse de filter
$commandesNonAnnulees = $commandes->reject(fn($c) => $c->statut === 'annulee');
// ---- RECHERCHE ----
// first() / last()
$premierLivree = $commandes->first(fn($c) => $c->statut === 'livree');
// contains()
$aDesCommandesEnAttente = $commandes->contains('statut', 'en_attente');
// ---- GROUPEMENT ET TRI ----
// groupBy() : grouper par valeur d'un champ
$parStatut = $commandes->groupBy('statut');
// ['livree' => Collection, 'en_attente' => Collection, ...]
// groupBy avec callback
$parJour = $commandes->groupBy(fn($c) => $c->created_at->format('Y-m-d'));
// sortBy() / sortByDesc()
$parTotal = $commandes->sortByDesc('total');
// ---- EXTRACTION ----
// pluck() : extraire un seul champ
$ids = $commandes->pluck('id'); // Collection [1, 2, 3, ...]
$totaux = $commandes->pluck('total', 'id'); // Collection [1 => 150.00, 2 => 75.50, ...]
// ---- AGREGATION ----
$total = $commandes->sum('total'); // Somme des totaux
$moyenne = $commandes->avg('total'); // Moyenne
$maximum = $commandes->max('total'); // Maximum
$count = $commandes->count(); // Nombre d'elements
$countLivrees = $commandes->where('statut', 'livree')->count();
// ---- REDUCE — calculer un chiffre d'affaires ----
$caParRestaurant = $commandes->reduce(function ($carry, $commande) {
$carry[$commande->restaurant_id] = ($carry[$commande->restaurant_id] ?? 0) + $commande->total;
return $carry;
}, []);
// ['1' => 1250.00, '2' => 890.50, ...]
// ---- DIVERS ----
$commandes->each(fn($c) => $this->info("Commande {$c->id} — {$c->total} MAD"));
$tableau = $commandes->toArray(); // Convertir en tableau PHP
$json = $commandes->toJson(); // Convertir en JSON
$unique = $commandes->unique('restaurant_id'); // Supprimer les doublons
La liste des restaurants est demandée 10 000 fois par heure. Sans cache : 10 000 requêtes MySQL. Avec Cache::remember('restaurants', 300, ...) : 1 requête MySQL toutes les 5 minutes, 9 999 lectures depuis la mémoire Redis. Généralement 100× plus rapide et libère la BDD pour les écritures (commandes).
use Illuminate\Support\Facades\Cache;
// ---- PATTERN remember() : lire ou calculer ----
// Si la cle existe en cache : retourner la valeur
// Sinon : executer la closure, stocker le resultat, retourner
$restaurants = Cache::remember('restaurants.casablanca', 300, function () {
return Restaurant::where('ville', 'Casablanca')
->where('actif', true)
->with('plats')
->orderBy('note', 'desc')
->get();
});
// 300 secondes = 5 minutes | Cle unique par ville
// rememberForever() : sans expiration
$villes = Cache::rememberForever('villes.liste', function () {
return ['Casablanca', 'Rabat', 'Marrakech', 'Fes', 'Agadir'];
});
// ---- OPERATIONS MANUELLES ----
Cache::put('commande.123.statut', 'en_livraison', now()->addHours(2));
$statut = Cache::get('commande.123.statut', 'inconnu'); // Valeur par defaut
$exists = Cache::has('commande.123.statut');
Cache::forget('commande.123.statut'); // Supprimer une cle
Cache::flush(); // Vider tout le cache — DANGER en production !
// ---- CACHE TAGS (Redis ou Memcached uniquement) ----
// Tags permettent d'invalider un groupe de cles en une commande
Cache::tags(['restaurants', 'casablanca'])->put(
'restaurants.casablanca.page.1', $data, 300
);
// Invalider tout ce qui concerne les restaurants de Casablanca
Cache::tags(['restaurants', 'casablanca'])->flush();
// Pattern : invalider quand un restaurant est modifie
// Dans RestaurantObserver::updated()
Cache::tags(['restaurants', strtolower($restaurant->ville)])->flush();
// ---- CACHE ATOMIQUE — eviter les race conditions ----
$valeur = Cache::lock('commande.paiement.123')->get(function () {
// Section critique — un seul processus a la fois
return $this->traiterPaiement(123);
});
// ---- CONFIGURATION .env ----
// CACHE_STORE=redis → Redis (production recommandee)
// CACHE_STORE=file → Fichiers (developpement)
// CACHE_STORE=database → Table cache en BDD
use Illuminate\Support\Str;
// Slug pour les URLs
Str::slug('Tajine Poulet Citron Confit'); // 'tajine-poulet-citron-confit'
Str::slug('Restaurant Al Baraka', '_'); // 'restaurant_al_baraka'
// UUID pour les references de commande
$ref = Str::uuid(); // '550e8400-e29b-41d4-a716-446655440000'
$ref = (string) Str::orderedUuid(); // UUID ordonne par temps — meilleur pour BDD
// Manipulation de chaines
Str::upper('casablanca'); // 'CASABLANCA'
Str::title('livraison rapide darigo'); // 'Livraison Rapide Darigo'
Str::camel('restaurant_name'); // 'restaurantName'
Str::snake('RestaurantName'); // 'restaurant_name'
Str::limit('Description tres longue...', 50, '...'); // Tronquer a 50 chars
Str::startsWith('DariGo-CMD-001', 'DariGo'); // true
Str::endsWith('photo.jpg', ['.jpg', '.png']); // true
Str::contains('commande en livraison', 'livraison'); // true
Str::replace('ancien', 'nouveau', $texte);
// Generer des chaines aleatoires
Str::random(32); // Cle API aleatoire
Str::password(16); // Mot de passe securise
// Fluent strings (Laravel 8+)
$nomFichier = Str::of('Restaurant Al Baraka')
->lower()
->slug()
->append('.jpg'); // 'restaurant-al-baraka.jpg'
use Illuminate\Support\Arr;
$commande = [
'client' => ['nom' => 'Karim', 'email' => 'karim@darigo.ma'],
'total' => 150.00,
];
// Arr::get — acces a un champ imbrique avec notation pointee
$email = Arr::get($commande, 'client.email', 'N/A'); // 'karim@darigo.ma'
$tel = Arr::get($commande, 'client.tel', 'Absent'); // 'Absent' (defaut)
// Arr::set — modifier un champ imbrique
Arr::set($commande, 'client.ville', 'Casablanca');
// Arr::has — verifier l'existence
Arr::has($commande, 'client.email'); // true
Arr::has($commande, 'client.password'); // false
// Arr::only / Arr::except
$public = Arr::only($commande, ['total', 'client.nom']);
$safe = Arr::except($commande, ['client.password']);
// Carbon / now() — dates Laravel
$maintenant = now(); // Carbon instance
$demain = now()->addDay();
$hier = now()->subDay();
$lundi = now()->startOfWeek();
$finMois = now()->endOfMonth();
// Formatage
now()->format('d/m/Y H:i'); // '15/11/2024 14:23'
now()->isoFormat('dddd D MMMM YYYY'); // 'Vendredi 15 Novembre 2024'
now()->diffForHumans(); // 'il y a quelques secondes'
now()->addMinutes(30)->diffForHumans(); // 'dans 30 minutes'
// Comparaisons
now()->isAfter('2024-01-01'); // true
$commande->created_at->lt(now()->subHours(2)); // Commande de plus de 2h
$restaurants = Cache::remember('restaurants', 300, fn() => Restaurant::all()). Un nouveau restaurant est créé. Combien de temps faut-il attendre pour qu'il apparaisse dans la liste ?$collection->map() et $collection->each() ?Événements, Files d'attente & Broadcasting
Système Events/Listeners pour découpler la logique, Jobs et Queue drivers (database/Redis) pour le traitement asynchrone, Broadcasting temps-réel avec Pusher/Laravel Echo pour les notifications instantanées DariGo.
- Créer des Events et Listeners avec
php artisan make:event/make:listener - Enregistrer les listeners dans
EventServiceProvider - Déclencher un événement avec
event() - Jobs et Queues —
php artisan make:job,dispatch(),queue:work - Queue drivers :
sync(dev),database,redis(prod) - Broadcasting — Pusher + Laravel Echo pour les notifications temps-réel
Sans Events, le CommandeController appelle directement : $sms->envoyer(), $email->envoyer(), $livreur->notifier(), $analytics->enregistrer(). Le Controller connait trop de choses — difficile à tester et à maintenir. Avec Events : le Controller déclenche event(new CommandeCreee($commande)) et les Listeners s'abonnent indépendamment. Ajouter une nouvelle action (badge fidélité) = créer un nouveau Listener sans toucher au Controller.
php artisan make:event CommandeCreee
php artisan make:event StatutCommandeModifie
php artisan make:listener EnvoyerEmailConfirmation --event=CommandeCreee
php artisan make:listener NotifierLivreur --event=CommandeCreee
php artisan make:listener MettreAJourAnalytics --event=CommandeCreee
<?php
namespace App\Events;
use App\Models\Commande;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CommandeCreee
{
use Dispatchable, SerializesModels;
// SerializesModels : serialize/deserialize correctement les modeles Eloquent en queue
public function __construct(public readonly Commande $commande)
{
// PHP 8 : constructor property promotion — public $commande est cree automatiquement
}
}
<?php
namespace App\Listeners;
use App\Events\CommandeCreee;
use App\Mail\ConfirmationCommandeMail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;
// ShouldQueue : ce listener s'execute en arriere-plan (queue)
// Sans ShouldQueue : execution synchrone (ralentit la reponse HTTP)
class EnvoyerEmailConfirmation implements ShouldQueue
{
public $queue = 'emails'; // File d'attente specifique
public $tries = 3; // Nombre de tentatives si echec
public $backoff = [10, 30, 60]; // Delais entre tentatives (secondes)
public function handle(CommandeCreee $event): void
{
$commande = $event->commande;
Mail::to($commande->client->email)
->send(new ConfirmationCommandeMail($commande));
}
// Que faire si toutes les tentatives echouent ?
public function failed(CommandeCreee $event, \Throwable $exception): void
{
\Log::error("Email confirmation echoue pour commande {$event->commande->id}", [
'error' => $exception->getMessage(),
]);
}
}
// app/Providers/EventServiceProvider.php (ou AppServiceProvider::boot())
protected $listen = [
CommandeCreee::class => [
EnvoyerEmailConfirmation::class, // Queue emails
NotifierLivreur::class, // Queue livraisons
MettreAJourAnalytics::class, // Queue analytics
],
StatutCommandeModifie::class => [
NotifierClientStatut::class,
BroadcastStatutChange::class, // Temps reel
],
];
// Declencher l'evenement dans le Controller
public function store(StoreCommandeRequest $request): JsonResponse
{
$commande = Commande::create([...]);
// Une ligne — les 3 listeners se declenchent automatiquement
event(new CommandeCreee($commande));
// ou : CommandeCreee::dispatch($commande);
return response()->json(['message' => 'Commande creee'], 201);
}
php artisan make:job TraiterPaiementLivraison
php artisan make:job EnvoyerRapportLivreur
# Creer la table de queue (si QUEUE_CONNECTION=database)
php artisan queue:table
php artisan migrate
# Lancer le worker
php artisan queue:work # Traiter tous les jobs
php artisan queue:work --queue=emails # Traiter uniquement la file emails
php artisan queue:work --tries=3 # Max 3 tentatives par job
php artisan queue:listen # Ecouter et recharger (dev)
# Gestion des jobs echoues
php artisan queue:failed # Lister les jobs echoues
php artisan queue:retry all # Relancer tous les jobs echoues
php artisan queue:flush # Supprimer les jobs echoues
<?php
namespace App\Jobs;
use App\Models\Commande;
use App\Services\PaiementService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class TraiterPaiementLivraison implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3; // 3 tentatives max
public int $timeout = 60; // Timeout par tentative (secondes)
public array $backoff = [5, 15, 30]; // Delais entre tentatives
public function __construct(private readonly Commande $commande)
{}
public function handle(PaiementService $paiement): void
{
// PaiementService est injecte automatiquement par le container
$resultat = $paiement->traiter($this->commande);
$this->commande->update([
'paiement_statut' => $resultat->statut,
'paiement_ref' => $resultat->reference,
]);
}
public function failed(\Throwable $e): void
{
\Log::error("Paiement echoue pour commande {$this->commande->id}: " . $e->getMessage());
$this->commande->update(['paiement_statut' => 'echec']);
}
}
// Dispatcher le job depuis n'importe ou
TraiterPaiementLivraison::dispatch($commande);
// Avec delai
TraiterPaiementLivraison::dispatch($commande)->delay(now()->addMinutes(5));
// Avec file specifique
TraiterPaiementLivraison::dispatch($commande)->onQueue('paiements');
// Job en batch
Bus::batch([
new TraiterPaiementLivraison($commande1),
new TraiterPaiementLivraison($commande2),
])->dispatch();
Le client commande via l'app mobile DariGo. Le livreur accepte la mission. L'app mobile doit afficher "Livreur en route" instantanément — sans polling toutes les 5 secondes. Broadcasting + Pusher + Laravel Echo = WebSocket push du serveur vers le client.
composer require pusher/pusher-php-server
npm install --save-dev laravel-echo pusher-js
# .env
BROADCAST_DRIVER=pusher
PUSHER_APP_ID=xxxxxxxx
PUSHER_APP_KEY=xxxxxxxx
PUSHER_APP_SECRET=xxxxxxxx
PUSHER_HOST=
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=eu
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
class StatutLivraisonChange implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public readonly Commande $commande,
public readonly string $statut,
public readonly ?string $nomLivreur = null,
) {}
// Canal prive par client — securise
public function broadcastOn(): PrivateChannel
{
return new PrivateChannel('commandes.' . $this->commande->client_id);
}
// Nom de l'evenement cote JavaScript
public function broadcastAs(): string
{
return 'statut.change';
}
// Donnees envoyees au client
public function broadcastWith(): array
{
return [
'commande_id' => $this->commande->id,
'statut' => $this->statut,
'nom_livreur' => $this->nomLivreur,
'timestamp' => now()->toIsoString(),
];
}
}
ShouldQueue. QUEUE_CONNECTION=sync dans le .env de développement. Comment ce Listener se comporte-t-il ?Fichiers, Mail & Notifications
La facade Storage:: pour gérer les fichiers (local/public/S3), les classes Mailable pour envoyer des emails HTML avec Blade, et le système de Notifications multi-canaux (email, SMS, base de données).
- Facade
Storage::— disques (local, public, s3), put/get/delete/url - Lien symbolique
storage:linkpour rendre les fichiers publics - Mailable —
php artisan make:mail, template Blade,Mail::to()->send() - Mailtrap pour tester les emails en développement
- Notifications —
php artisan make:notification, canaux mail/database/SMS - Notifications via
$user->notify()etNotification::send()
use Illuminate\Support\Facades\Storage;
// ---- ECRITURE ----
// Disque 'local' : storage/app/
Storage::put('fichiers/rapport.csv', $contenu);
// Disque 'public' : storage/app/public/ → accessible via /storage/
Storage::disk('public')->put('restaurants/menus/menu-5.pdf', $contenu);
Storage::disk('public')->putFile('photos/plats', $request->file('photo'));
// Generer un nom unique
$path = Storage::disk('public')->putFile('photos/restaurants', $file);
// Retourne : 'photos/restaurants/abc123def456.jpg'
// ---- LECTURE ----
$contenu = Storage::get('fichiers/rapport.csv');
$url = Storage::disk('public')->url('photos/restaurants/abc123.jpg');
// http://api.darigo.ma/storage/photos/restaurants/abc123.jpg
// URL temporaire (S3 — lien expirable)
$urlTemp = Storage::disk('s3')->temporaryUrl('factures/fact-001.pdf', now()->addHours(1));
// ---- INFORMATIONS ----
$exists = Storage::exists('fichiers/rapport.csv'); // true/false
$taille = Storage::size('fichiers/rapport.csv'); // octets
$modifie = Storage::lastModified('fichiers/rapport.csv'); // timestamp Unix
$fichiers = Storage::files('photos/restaurants'); // Lister les fichiers
$dossiers = Storage::directories('photos'); // Lister les dossiers
// ---- SUPPRESSION ----
Storage::delete('fichiers/rapport.csv');
Storage::delete(['photo1.jpg', 'photo2.jpg']);
Storage::deleteDirectory('temp'); // Supprimer un dossier entier
// ---- COPIE ET DEPLACEMENT ----
Storage::copy('photos/orig.jpg', 'photos/copie.jpg');
Storage::move('temp/upload.jpg', 'photos/restaurants/restaurant-5.jpg');
# Creer le lien symbolique : public/storage → storage/app/public
php artisan storage:link
# Resultat : les fichiers dans storage/app/public/ sont accessibles via :
# https://api.darigo.ma/storage/photos/restaurants/abc123.jpg
# Pour plusieurs liens (config/filesystems.php)
'links' => [
public_path('storage') => storage_path('app/public'),
public_path('uploads') => storage_path('app/uploads'), // Lien suppl.
],
php artisan make:mail ConfirmationCommandeMail
php artisan make:mail NotificationStatutLivraison
php artisan make:mail BienvenueDariGo
<?php
namespace App\Mail;
use App\Models\Commande;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ConfirmationCommandeMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(public readonly Commande $commande)
{}
// Objet et expediteur
public function envelope(): Envelope
{
return new Envelope(
from: new Address('noreply@darigo.ma', 'DariGo — Livraison'),
subject: "Confirmation de votre commande #{$this->commande->id}",
);
}
// Template Blade utilise
public function content(): Content
{
return new Content(
view: 'emails.commandes.confirmation',
// Variables disponibles dans le template
with: [
'commande' => $this->commande,
'client' => $this->commande->client,
'lignes' => $this->commande->lignes,
'urlSuivi' => route('commandes.show', $this->commande->id),
]
);
}
// Pieces jointes
public function attachments(): array
{
return [
// Attachment::fromPath(storage_path('app/factures/fact-' . $this->commande->id . '.pdf'))
// ->as('Facture-DariGo.pdf')
// ->withMime('application/pdf'),
];
}
}
// Envoyer l'email
Mail::to($commande->client->email)
->cc('support@darigo.ma')
->send(new ConfirmationCommandeMail($commande));
// Tester sans envoyer (preview dans le navigateur)
// Route::get('/mail-preview', fn() => new ConfirmationCommandeMail(Commande::first()));
<!-- Template email HTML -->
<x-mail::message>
# Votre commande est confirmée !
Bonjour **{{ $client->prenom }}**,
Votre commande **#{{ $commande->id }}** a bien été reçue.
<x-mail::table>
| Plat | Prix |
|:-----|-----:|
@foreach ($lignes as $ligne)
| {{ $ligne->nom_plat }} × {{ $ligne->quantite }} | {{ number_format($ligne->sous_total, 2) }} MAD |
@endforeach
| **Total** | **{{ number_format($commande->total, 2) }} MAD** |
</x-mail::table>
<x-mail::button :url="$urlSuivi" color="green">
Suivre ma commande
</x-mail::button>
Merci de faire confiance à **DariGo** !
</x-mail::message>
Un Mailable = un email. Une Notification = un message qui peut être envoyé sur plusieurs canaux simultanémément (email + SMS + notification push + BDD). Préférer les Notifications pour les alertes utilisateur (livraison en retard, commande confirmée).
<?php
namespace App\Notifications;
use App\Models\Commande;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class CommandeLivreeNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(private readonly Commande $commande) {}
// Canaux utilises : mail ET base de donnees
public function via(object $notifiable): array
{
return ['mail', 'database'];
// Autres canaux : 'vonage' (SMS), 'slack', 'broadcast'
}
// Canal email
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject("Votre commande #{$this->commande->id} a été livrée !")
->greeting("Bonjour {$notifiable->prenom} !")
->line("Votre commande de {$this->commande->total} MAD a bien été livrée.")
->action('Voir mes commandes', route('commandes.index'))
->line('Merci d\'avoir choisi DariGo !')
->salutation('L\'équipe DariGo');
}
// Canal base de donnees (table notifications)
public function toDatabase(object $notifiable): array
{
return [
'commande_id' => $this->commande->id,
'message' => "Commande #{$this->commande->id} livrée",
'total' => $this->commande->total,
];
}
}
// Creer la table notifications
// php artisan notifications:table && php artisan migrate
// Envoyer la notification
$commande->client->notify(new CommandeLivreeNotification($commande));
// Envoyer a plusieurs utilisateurs
Notification::send($livreurs, new NouvelleCommandeDisponible($commande));
// Lire les notifications non lues depuis le modele User
$user->unreadNotifications; // Collection
$user->notifications; // Toutes
$user->unreadNotifications->markAsRead();
php artisan storage:link doit être exécuté sur le serveur de production après chaque git pull. Pourquoi cette commande ne suffit-elle pas en production si le disque de la config est s3 ?Packages, Contrats & Localisation
Service Providers et Facades pour créer des packages réutilisables, Contracts (interfaces) pour le couplage faible, et localisation multi-langue (français/arabe) adaptée au marché marocain.
- Service Container — binding, singleton,
app()->make() - Service Providers —
register()vsboot() - Facades — créer une Facade pour son propre service
- Contracts (interfaces) —
implements SomeContract - Localisation —
lang/fr/,lang/ar/,__('messages.bienvenue') - Switcher de langue dans DariGo — URL paramètre ou session
Le Service Container est un conteneur d'injection de dépendances. Il résout automatiquement les dépendances des classes : quand Laravel instancie un Controller qui dépend d'un PaiementService, il crée automatiquement PaiementService et l'injecte. Le Container sait comment créer chaque service grâce aux bindings enregistrés dans les Service Providers.
<?php
namespace App\Providers;
use App\Contracts\PaiementContract;
use App\Contracts\SMSContract;
use App\Services\CMIPaiementService;
use App\Services\OrangeSMSService;
use Illuminate\Support\ServiceProvider;
class DariGoServiceProvider extends ServiceProvider
{
/**
* register() : UNIQUEMENT des bindings dans le container
* Ne jamais utiliser d'autres services ici — ils peuvent ne pas etre charges
*/
public function register(): void
{
// bind() : nouvelle instance a chaque appel
$this->app->bind(PaiementContract::class, CMIPaiementService::class);
// singleton() : meme instance pour toute la requete
$this->app->singleton(SMSContract::class, function ($app) {
return new OrangeSMSService(
apiKey: config('services.orange_sms.key'),
sender: 'DARIGO'
);
});
// Alias court
$this->app->alias(SMSContract::class, 'darigo.sms');
}
/**
* boot() : executer apres TOUS les providers registers
* Ici on peut utiliser les autres services
*/
public function boot(): void
{
// Enregistrer une commande Artisan
if ($this->app->runningInConsole()) {
$this->commands([
\App\Console\Commands\EnvoyerRappelsLivraison::class,
\App\Console\Commands\NettoyerCommandesExpirees::class,
]);
}
// Enregistrer des macros globales
\Illuminate\Http\Response::macro('darigo', function ($data, $msg = 'OK', $status = 200) {
return response()->json(['succes' => true, 'message' => $msg, 'data' => $data], $status);
});
}
}
// Injection automatique dans un Controller (le plus courant)
class CommandeController extends Controller
{
public function __construct(
private readonly PaiementContract $paiement, // Container injecte automatiquement
private readonly SMSContract $sms
) {}
public function store(StoreCommandeRequest $request): JsonResponse
{
$commande = Commande::create([...]);
$this->paiement->traiter($commande); // CMIPaiementService
$this->sms->envoyer('+212600000000', 'Commande confirmee !');
return response()->darigo($commande, 'Commande creee', 201);
}
}
// Resoudre manuellement (dans un script Artisan par exemple)
$sms = app(SMSContract::class);
$sms = app('darigo.sms'); // Via alias
<?php
// app/Contracts/PaiementContract.php
namespace App\Contracts;
use App\Models\Commande;
interface PaiementContract
{
public function traiter(Commande $commande): PaiementResultat;
public function rembourser(string $reference): bool;
public function verifier(string $reference): string; // statut
}
// app/Services/CMIPaiementService.php — implémentation réelle
class CMIPaiementService implements PaiementContract
{
public function traiter(Commande $commande): PaiementResultat
{
// Integration API CMI (Centre Monétique Interbancaire Maroc)
$response = Http::post('https://api.cmi.ma/paiements', [...]);
return new PaiementResultat($response->json());
}
// ...
}
// app/Services/FakePaiementService.php — pour les tests
class FakePaiementService implements PaiementContract
{
public function traiter(Commande $commande): PaiementResultat
{
// Toujours reussir en tests
return new PaiementResultat(['statut' => 'succes', 'ref' => 'TEST-' . $commande->id]);
}
// ...
}
// Dans les tests : substituer l'implementation
$this->app->bind(PaiementContract::class, FakePaiementService::class);
lang/
├── fr/
│ ├── messages.php # Traductions generales
│ ├── validation.php # Messages de validation
│ └── auth.php # Messages d'authentification
└── ar/
├── messages.php
└── validation.php
<?php
return [
'bienvenue' => 'Bienvenue sur DariGo !',
'commande_ok' => 'Votre commande a été passée avec succès.',
'livraison' => 'Livraison en cours vers :adresse', // :adresse = param
// Pluralisation
'plat' => '{0} Aucun plat|{1} :count plat|[2,*] :count plats',
'commande' => '{0} Aucune commande|{1} :count commande|[2,*] :count commandes',
];
<?php
return [
'bienvenue' => 'مرحباً بكم في DariGo !',
'commande_ok' => 'تم' . ' تسجيل طلبك بنجاح.',
'livraison' => 'التوصيل جاري إلى :adresse',
];
// Recuperer une traduction
__('messages.bienvenue'); // 'Bienvenue sur DariGo !'
__('messages.livraison', ['adresse' => '14 rue Hassan II, Casablanca']);
// Pluralisation
trans_choice('messages.plat', 0); // 'Aucun plat'
trans_choice('messages.plat', 1); // '1 plat'
trans_choice('messages.plat', 5); // '5 plats'
// Changer la langue pour la requete courante
app()->setLocale('ar');
app()->getLocale(); // 'ar'
// Dans un middleware LocaleMiddleware
class LocaleMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$locale = $request->header('Accept-Language', 'fr');
$supported = ['fr', 'ar', 'en'];
app()->setLocale(in_array($locale, $supported) ? $locale : 'fr');
return $next($request);
}
}
// Dans Blade
<html lang="{{ app()->getLocale() }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
DariGo cible des utilisateurs marocains : francophone (Casablanca, Rabat) et arabophone (reste du Maroc). Approche recommandée pour l'API : header Accept-Language: ar ou Accept-Language: fr. Laravel lit ce header et sélectionne la bonne langue. L'application mobile envoie la préférence de l'utilisateur dans chaque requête.
register() et boot() dans un Service Provider ?PaiementContract (interface) au lieu de CMIPaiementService (classe concrète) dans les Controllers. Si l'équipe décide de changer de prestataire de paiement (de CMI à HPS), combien de fichiers faut-il modifier ?JWT, Passport & Sanctum — Authentification API
Comparatif des trois systèmes d'authentification API de Laravel, installation et configuration complète de Sanctum pour DariGo, tokens personnels, createToken(), middleware auth:sanctum et auth SPA avec cookies.
- Comparatif JWT / Passport / Sanctum — lequel choisir pour DariGo
- Installer et configurer Sanctum en 4 étapes
HasApiTokenssur le modèle User —createToken()- Routes
POST /auth/register,POST /auth/login,POST /auth/logout - Middleware
auth:sanctumpour protéger les routes API - Abilities (scopes) — tokens limités par permission
L'auth Laravel par défaut (session + cookie) est conçue pour les navigateurs. DariGo est une API consommée par une app mobile React Native — elle ne gère pas les cookies de session. Il faut un système de tokens transmis dans le header Authorization: Bearer xxx.
| Critère | JWT (tymon/jwt-auth) | Passport (OAuth2) | Sanctum (recommandé) |
|---|---|---|---|
| Complexité | Moyenne — librairie tierce | Élevée — OAuth2 complet | Faible — intégré Laravel |
| Token stateless | Oui — pas de BDD | Oui (access token) | Non — token en BDD |
| Révocation | Complexe — blacklist nécessaire | Native | Native — supprimer de la BDD |
| SPA + Mobile | Mobile uniquement | Tout | Mobile + SPA (double mode) |
| Abilities (scopes) | Claims JWT | Scopes OAuth2 | Abilities Sanctum |
| Cas d'usage | Microservices stateless | API publique (Twitter, GitHub) | Apps internes, SaaS |
| DariGo | Possible mais sur-engineered | Trop complexe pour usage interne | Recommandé — simple et puissant |
# Etape 1 : Installer Sanctum (inclus par defaut dans Laravel 11)
composer require laravel/sanctum
# Etape 2 : Publier la config et la migration
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
# Etape 3 : Executer la migration
php artisan migrate
# Cree la table personal_access_tokens
# Etape 4 : Verifier la config
# config/sanctum.php — expiration, middleware, stateful domains
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens; // Import Sanctum
class User extends Authenticatable
{
use HasApiTokens, // Ajouter cette trait
Notifiable;
protected $fillable = [
'prenom', 'nom', 'email', 'password',
'telephone',
'role', // 'client' | 'restaurateur' | 'livreur' | 'admin'
'ville',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed', // Hash automatique a l'assignation
];
}
}
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class AuthController extends Controller
{
// ── POST /api/auth/register ──────────────────────────────
public function register(Request $request): JsonResponse
{
$donnees = $request->validate([
'prenom' => ['required', 'string', 'max:50'],
'nom' => ['required', 'string', 'max:50'],
'email' => ['required', 'email:rfc,dns', 'unique:users,email'],
'telephone' => ['required', 'regex:/^(0[5-7][0-9]{8})$/'],
'password' => ['required', 'confirmed',
Password::min(8)->mixedCase()->numbers()],
'role' => ['sometimes', 'in:client,restaurateur,livreur'],
'ville' => ['required', 'string'],
]);
// 'password' => 'hashed' dans le cast User : hashage automatique
$user = User::create([
...$donnees,
'role' => $donnees['role'] ?? 'client',
]);
// Creer le token initial
$token = $user->createToken(
'darigo-' . $user->role, // Nom du token (pour identifier)
$this->abilitiesPourRole($user->role) // Abilities selon le role
);
return response()->json([
'message' => 'Compte cree avec succes',
'user' => $user,
'token' => $token->plainTextToken, // Affiche UNE SEULE FOIS
'token_type' => 'Bearer',
], 201);
}
// ── POST /api/auth/login ─────────────────────────────────
public function login(Request $request): JsonResponse
{
$request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
]);
if (! Auth::attempt($request->only('email', 'password'))) {
return response()->json([
'message' => 'Identifiants incorrects',
], 401);
}
$user = Auth::user();
// Revoquer les anciens tokens du meme appareil (optionnel)
$user->tokens()->where('name', 'darigo-' . $user->role)->delete();
$token = $user->createToken(
'darigo-' . $user->role,
$this->abilitiesPourRole($user->role)
);
return response()->json([
'message' => 'Connexion reussie',
'user' => [
'id' => $user->id,
'prenom' => $user->prenom,
'email' => $user->email,
'role' => $user->role,
'ville' => $user->ville,
],
'token' => $token->plainTextToken,
'token_type' => 'Bearer',
'abilities' => $this->abilitiesPourRole($user->role),
]);
}
// ── POST /api/auth/logout ────────────────────────────────
public function logout(Request $request): JsonResponse
{
// Revoquer uniquement le token courant
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Deconnexion reussie']);
}
// ── POST /api/auth/logout-all ────────────────────────────
public function logoutAll(Request $request): JsonResponse
{
// Revoquer TOUS les tokens (deconnexion de tous les appareils)
$request->user()->tokens()->delete();
return response()->json(['message' => 'Deconnexion de tous les appareils']);
}
// ── GET /api/auth/me ─────────────────────────────────────
public function me(Request $request): JsonResponse
{
return response()->json([
'user' => $request->user(),
'abilities' => $request->user()->currentAccessToken()->abilities,
]);
}
// Abilities par role DariGo
private function abilitiesPourRole(string $role): array
{
return match($role) {
'admin' => ['*'], // Tout
'restaurateur' => ['restaurants:manage', 'plats:manage', 'commandes:view'],
'livreur' => ['livraisons:view', 'livraisons:update'],
default => ['restaurants:view', 'plats:view', 'commandes:create', 'commandes:view'],
};
}
}
// Routes publiques
Route::prefix('auth')->group(function () {
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
});
// Routes protegees par Sanctum
Route::middleware('auth:sanctum')->group(function () {
Route::post('/auth/logout', [AuthController::class, 'logout']);
Route::post('/auth/logout-all', [AuthController::class, 'logoutAll']);
Route::get('/auth/me', [AuthController::class, 'me']);
Route::apiResource('restaurants', RestaurantController::class);
Route::apiResource('commandes', CommandeController::class);
Route::apiResource('livraisons', LivraisonController::class);
});
# 1 — Register
POST /api/auth/register
Content-Type: application/json
{
"prenom":"Karim","nom":"Alaoui","email":"karim@darigo.ma",
"telephone":"0612345678","password":"Karim2024!","password_confirmation":"Karim2024!",
"role":"client","ville":"Casablanca"
}
# → 201 + token
# 2 — Login
POST /api/auth/login
{ "email":"karim@darigo.ma", "password":"Karim2024!" }
# → 200 + token (copier)
# 3 — Route protegee
GET /api/auth/me
Authorization: Bearer 1|xxxxxxxxxxxx
# → 200 + user + abilities
# 4 — Logout
POST /api/auth/logout
Authorization: Bearer 1|xxxxxxxxxxxx
# → 200 "Deconnexion reussie"
# 5 — Verifier que le token est revoque
GET /api/auth/me
Authorization: Bearer 1|xxxxxxxxxxxx
# → 401 Unauthenticated
$token->plainTextToken est affiché uniquement au moment de la création. Pourquoi est-il impossible de le récupérer après ?['livraisons:view', 'livraisons:update'] pour un livreur. Ce livreur appelle POST /api/restaurants (créer un restaurant). Quel code HTTP reçoit-il ?Authentification avancée
Authentification manuelle avec la façade Auth, middlewares auth et guest, gardes Laravel (config/auth.php), vérification d'email (MustVerifyEmail), réinitialisation de mot de passe, et kit Breeze.
- Façade
Auth—attempt(),login(),logout(),user(),check() - Middlewares
auth(utilisateurs connectés) etguest(non connectés) - Gardes :
web(session+cookie) vsapi(token) - Vérification d'email —
MustVerifyEmail, routeemail/verify - Réinitialisation de mot de passe —
Password::sendResetLink() - Laravel Breeze — kit de démarrage tout-en-un
use Illuminate\Support\Facades\Auth;
// ---- CONNEXION ----
// attempt() — verifier identifiants et connecter
$connecte = Auth::attempt(['email' => $email, 'password' => $password]);
// Retourne true si les identifiants sont corrects et connecte l'utilisateur
// attempt() avec "remember me"
$connecte = Auth::attempt(['email' => $email, 'password' => $password], $remember = true);
// Si true : cookie remember_token — reste connecte meme apres fermeture navigateur
// login() — connecter un utilisateur deja recupere
$user = User::where('email', $email)->first();
Auth::login($user); // Session
Auth::login($user, true); // Avec remember me
// loginUsingId() — connecter par ID (admin qui usurpe un utilisateur)
Auth::loginUsingId(5);
// ---- VERIFICATION ----
Auth::check(); // true si connecte, false sinon
Auth::guest(); // true si NON connecte (inverse de check)
Auth::user(); // Retourne l'objet User ou null
Auth::id(); // Retourne l'ID de l'utilisateur ou null
// Dans Blade
// @auth → Auth::check()
// @guest → Auth::guest()
// auth()->user() → raccourci global
// ---- DECONNEXION ----
Auth::logout();
// Recommande : invalider la session et regenerer le token CSRF apres logout
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
// ---- GARDES ----
// La garde par defaut est 'web' (session)
Auth::guard('web')->user(); // Utilisateur de la garde web
Auth::guard('api')->user(); // Utilisateur de la garde api (tokens)
Auth::guard('sanctum')->user(); // Utilisateur Sanctum
// Dans les routes API : auth():sanctum est equivalent a Auth::guard('sanctum')
| Méthode | Retourne | Usage |
|---|---|---|
Auth::check() | bool | Vérifier si connecté |
Auth::guest() | bool | Vérifier si non connecté |
Auth::user() | User|null | Récupérer l'utilisateur connecté |
Auth::id() | int|null | Récupérer l'ID |
Auth::attempt($creds) | bool | Tenter une connexion |
Auth::login($user) | void | Connecter un objet User |
Auth::logout() | void | Déconnecter |
Auth::viaRemember() | bool | Vérifier si connecté via remember me |
// Routes accessibles uniquement aux utilisateurs NON connectes (formulaires connexion)
Route::middleware('guest')->group(function () {
Route::get('/login', [ConnexionController::class, 'show'])->name('login');
Route::post('/login', [ConnexionController::class, 'login']);
Route::get('/register', [InscriptionController::class, 'show'])->name('register');
Route::post('/register', [InscriptionController::class, 'register']);
});
// Routes accessibles uniquement aux utilisateurs CONNECTES
Route::middleware('auth')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::get('/profil', [ProfilController::class, 'show'])->name('profil');
Route::post('/auth/logout', [DeconnexionController::class, 'logout'])->name('logout');
});
// Si un utilisateur non connecte tente d'acceder a /dashboard :
// → redirige vers la route nommee 'login' (definie dans bootstrap/app.php)
return [
// Garde par defaut
'defaults' => [
'guard' => 'web', // Session + cookie
'passwords' => 'users',
],
'guards' => [
// Garde web : navigateur, session, cookies
'web' => [
'driver' => 'session',
'provider' => 'users',
],
// Garde api : tokens, stateless (routes api.php)
'api' => [
'driver' => 'sanctum', // Sanctum pour les APIs DariGo
'provider' => 'users',
],
],
// Providers : comment recuperer les utilisateurs
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => App\Models\User::class,
],
// Exemple : garde separee pour les restaurateurs
// 'restaurateurs' => [
// 'driver' => 'eloquent',
// 'model' => App\Models\Restaurateur::class,
// ],
],
// Configuration de reinitialisation de mot de passe
'passwords' => [
'users' => [
'provider' => 'users',
'table' => 'password_reset_tokens',
'expire' => 60, // Token expire apres 60 minutes
'throttle' => 60, // Attendre 60s entre deux demandes
],
],
];
// 1 — Ajouter MustVerifyEmail au modele User
use Illuminate\Contracts\Auth\MustVerifyEmail;
class User extends Authenticatable implements MustVerifyEmail
{
// Laravel envoie automatiquement un email de verification apres register
}
// 2 — Routes de verification (Laravel Sanctum — API)
Route::middleware('auth:sanctum')->group(function () {
// Renvoyer l'email de verification
Route::post('/email/verification-notification', function (Request $request) {
if ($request->user()->hasVerifiedEmail()) {
return response()->json(['message' => 'Email deja verifie']);
}
$request->user()->sendEmailVerificationNotification();
return response()->json(['message' => 'Email de verification envoye']);
})->middleware('throttle:6,1');
// Verifier l'email (lien dans l'email)
Route::get('/email/verify/{id}/{hash}', function (EmailVerificationRequest $request) {
$request->fulfill(); // Marque l'email comme verifie
return response()->json(['message' => 'Email verifie avec succes']);
})->middleware('signed')->name('verification.verify');
// Routes qui necessitent l'email verifie
Route::middleware('verified')->group(function () {
Route::post('/commandes', [CommandeController::class, 'store']);
// Un client doit avoir un email verifie pour commander
});
});
use Illuminate\Support\Facades\Password;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Support\Str;
// ── Etape 1 : Demander un lien de reinitialisation ────────
public function envoyerLienReset(Request $request): JsonResponse
{
$request->validate(['email' => ['required', 'email']]);
$statut = Password::sendResetLink($request->only('email'));
// Envoie un email avec un lien signe (token expire en 60 min)
return $statut === Password::RESET_LINK_SENT
? response()->json(['message' => 'Lien de reinitialisation envoye'])
: response()->json(['message' => 'Email introuvable'], 422);
}
// ── Etape 2 : Valider le token et changer le mot de passe ─
public function reinitialiserPassword(Request $request): JsonResponse
{
$request->validate([
'token' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', 'min:8'],
]);
$statut = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user, string $password) {
$user->forceFill(['password' => Hash::make($password)])
->setRememberToken(Str::random(60));
$user->save();
event(new PasswordReset($user));
// Revoquer tous les tokens Sanctum existants
$user->tokens()->delete();
}
);
return $statut === Password::PASSWORD_RESET
? response()->json(['message' => 'Mot de passe reinitialise avec succes'])
: response()->json(['message' => 'Token invalide ou expire'], 422);
}
Laravel Breeze génère automatiquement tout le système d'auth : register, login, logout, vérification email, réinitialisation mot de passe, vues Blade Bootstrap, routes, controllers, migrations. Idéal pour un prototype rapide. Commandes : composer require laravel/breeze --dev puis php artisan breeze:install. Pour DariGo (API pure) : Breeze n'est pas utilisé — on gère manuellement avec Sanctum.
GET /dashboard protégé par le middleware auth. Il envoie une requête JSON (Accept: application/json). Que retourne Laravel ?auth et le middleware guest ?Autorisation — Gates & Policies
Les Gates (closures d'autorisation fine) et les Policies (classes d'autorisation par modèle) — définir, enregistrer, appliquer et tester les autorisations DariGo (restaurateur propriétaire, admin universel).
- Gates —
Gate::define(),Gate::allows(),Gate::authorize(),Response::deny() - Policies —
php artisan make:policy --model, 7 méthodes CRUD - Filtre
before()— admin bypass automatique - 5 façons d'appliquer une Policy : Gate, $user->can(), $this->authorize(), middleware can, authorizeResource()
- Directives Blade :
@can,@cannot,@canany - Différence Gates vs Policies — quand choisir quoi
Gates : pour des autorisations qui ne sont pas liées à un seul modèle Eloquent. Ex : "peut accéder au tableau de bord admin", "peut exporter les statistiques". Policies : pour des autorisations liées à un modèle spécifique. Ex : "peut modifier CE restaurant", "peut voir CETTE commande". Dans DariGo : Gates pour les permissions générales de rôle, Policies pour les ressources.
use Illuminate\Support\Facades\Gate;
use Illuminate\Auth\Access\Response;
public function boot(): void
{
// Gate simple — acces au tableau de bord admin
Gate::define('acceder-dashboard', function (User $user) {
return $user->role === 'admin';
});
// Gate avec modele — voir les statistiques d'un restaurant
Gate::define('voir-stats-restaurant', function (User $user, Restaurant $restaurant) {
return $user->role === 'admin'
|| $user->id === $restaurant->proprietaire_id;
});
// Gate avec reponse detaillee (message d'erreur personnalise)
Gate::define('gerer-livreurs', function (User $user) {
return $user->role === 'admin'
? Response::allow()
: Response::deny('Seuls les administrateurs peuvent gerer les livreurs.');
});
// Gate avec denyWithStatus (404 au lieu de 403 — masquer l'existence)
Gate::define('voir-commande-privee', function (User $user, Commande $commande) {
return $user->id === $commande->client_id || $user->role === 'admin'
? Response::allow()
: Response::denyWithStatus(404); // Le client ne sait pas que la commande existe
});
}
// Dans un Controller
Gate::allows('acceder-dashboard'); // true/false
Gate::denies('acceder-dashboard'); // true/false
Gate::authorize('acceder-dashboard'); // Lance 403 si refuse
Gate::authorize('voir-stats-restaurant', $restaurant); // Avec modele
// Via le modele User
auth()->user()->can('acceder-dashboard'); // true/false
auth()->user()->cannot('acceder-dashboard'); // true/false
// Via la methode authorize() du Controller (herite de Controller)
$this->authorize('voir-stats-restaurant', $restaurant);
// Dans Blade
@can('acceder-dashboard')
<a href="/admin">Tableau de bord</a>
@endcan
@cannot('acceder-dashboard')
<p>Acces restreint aux administrateurs.</p>
@endcannot
// @canany — si au moins une permission est accordee
@canany(['voir-stats-restaurant', 'acceder-dashboard'], $restaurant)
<div class="stats">...</div>
@endcanany
php artisan make:policy RestaurantPolicy --model=Restaurant
php artisan make:policy CommandePolicy --model=Commande
php artisan make:policy PlatPolicy --model=Plat
<?php
namespace App\Policies;
use App\Models\Restaurant;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class RestaurantPolicy
{
/**
* before() : s'execute AVANT toutes les methodes de la Policy
* Utilise pour autoriser automatiquement l'admin
*/
public function before(User $user, string $ability): bool|null
{
if ($user->role === 'admin') {
return true; // Admin peut tout faire — court-circuite toutes les methodes
}
return null; // null = continuer vers la methode specifique
}
// Voir la liste des restaurants (GET /restaurants)
public function viewAny(User $user): bool
{
return true; // Tous les utilisateurs connectes peuvent voir la liste
}
// Voir un restaurant specifique (GET /restaurants/{id})
public function view(User $user, Restaurant $restaurant): bool
{
return true; // Tout le monde peut voir un restaurant
}
// Creer un restaurant (POST /restaurants)
public function create(User $user): bool
{
return $user->role === 'restaurateur';
}
// Modifier un restaurant (PUT /restaurants/{id})
public function update(User $user, Restaurant $restaurant): bool
{
// Seul le proprietaire peut modifier SON restaurant
return $user->id === $restaurant->proprietaire_id;
}
// Supprimer un restaurant (DELETE /restaurants/{id})
public function delete(User $user, Restaurant $restaurant): bool
{
return $user->id === $restaurant->proprietaire_id;
}
// Activer/desactiver un restaurant (action personnalisee)
public function toggleActif(User $user, Restaurant $restaurant): Response
{
return $user->id === $restaurant->proprietaire_id
? Response::allow()
: Response::deny('Seul le proprietaire peut activer ou desactiver son restaurant.');
}
}
class RestaurantController extends Controller
{
// Methode 1 : authorizeResource() — lie automatiquement toutes les methodes CRUD
public function __construct()
{
$this->middleware('auth:sanctum');
$this->authorizeResource(Restaurant::class, 'restaurant');
// Equivalent a appeler $this->authorize('view/create/update/delete', $restaurant)
// dans chaque methode correspondante
}
// Methode 2 : $this->authorize() dans la methode
public function update(UpdateRestaurantRequest $request, Restaurant $restaurant): JsonResponse
{
$this->authorize('update', $restaurant); // Lance 403 si refuse
$restaurant->update($request->validated());
return response()->json(['message' => 'Restaurant mis a jour']);
}
// Methode 3 : Gate::authorize()
public function destroy(Restaurant $restaurant): JsonResponse
{
Gate::authorize('delete', $restaurant);
$restaurant->delete();
return response()->json(['message' => 'Restaurant supprime']);
}
}
// Methode 4 : Middleware can sur la route
Route::delete('/restaurants/{restaurant}', [RestaurantController::class, 'destroy'])
->middleware('can:delete,restaurant');
// Methode 5 : $user->can() dans le Controller
public function toggleActif(Restaurant $restaurant): JsonResponse
{
if (auth()->user()->cannot('toggleActif', $restaurant)) {
return response()->json(['erreur' => 'Non autorise'], 403);
}
$restaurant->update(['actif' => !$restaurant->actif]);
return response()->json(['actif' => $restaurant->actif]);
}
before() de RestaurantPolicy retourne null pour un utilisateur non-admin. Que se passe-t-il ensuite ?DELETE /api/restaurants/7 (restaurant #7 qui lui appartient PAS). Quel code HTTP reçoit-il ?Chiffrement & Hachage
Différence fondamentale entre chiffrement (réversible) et hachage (irréversible), Crypt::encrypt/decrypt() pour les données sensibles, Hash::make() (bcrypt/argon2) pour les mots de passe et Hash::check() pour la vérification.
- Chiffrement vs Hachage — différence fondamentale et cas d'usage
Crypt::encrypt()/Crypt::decrypt()— basé sur APP_KEYHash::make()— bcrypt (défaut) et argon2idHash::check()— comparer un mot de passe et son hash- Cast automatique
'password' => 'hashed'dans Eloquent - Pourquoi ne jamais stocker un mot de passe en clair
| Critère | Chiffrement (Crypt::) | Hachage (Hash::) |
|---|---|---|
| Réversibilité | Réversible — on peut déchiffrer avec la clé | Irréversible — impossible de retrouver l'original |
| Clé requise | Oui — APP_KEY dans .env | Non — algorithme one-way |
| Algorithme | AES-256-CBC (symétrique) | bcrypt ou argon2id |
| Cas d'usage | Stocker données à récupérer (IBAN, numéro CB masqué) | Mots de passe — uniquement vérification |
| DariGo | Numéro de téléphone livreur, coordonnées GPS | Mot de passe de tous les utilisateurs |
Utiliser du chiffrement pour les mots de passe = fuite de données catastrophique si APP_KEY est compromise. Utiliser du hachage pour des données à afficher (numéro de téléphone) = impossible d'afficher le numéro à l'utilisateur. Chaque techno à son usage.
use Illuminate\Support\Facades\Crypt;
use Illuminate\Contracts\Encryption\DecryptException;
// ---- CHIFFRER ----
// Chiffrer n'importe quelle valeur (seriabsle : tableau, objet, chaine)
$chiffre = Crypt::encrypt('0612345678');
// Retourne une chaine Base64 longue : "eyJpdiI6Ij..."
// Chiffrer uniquement une chaine (plus rapide, non serialise)
$chiffre = Crypt::encryptString('0612345678');
// Stocker dans la BDD
$livreur->update(['telephone_chiffre' => Crypt::encryptString($telephone)]);
// ---- DECHIFFRER ----
// Dechiffrer (exception si APP_KEY change ou donnee corrompue)
try {
$telephone = Crypt::decryptString($livreur->telephone_chiffre);
} catch (DecryptException $e) {
// APP_KEY a change ou la donnee est corrompue
\Log::error("Impossible de dechiffrer le telephone du livreur {$livreur->id}");
$telephone = null;
}
// ---- CAST ELOQUENT AUTOMATIQUE ----
// Dans le modele Livreur : chiffrement/dechiffrement automatique
protected function casts(): array
{
return [
'telephone' => 'encrypted', // Crypt::encrypt/decrypt auto
'iban' => 'encrypted',
'coords_domicile' => 'encrypted:array', // Tableau chiffre
];
}
// Utilisation transparente :
$livreur->telephone = '0612345678'; // Stocke chiffre en BDD
echo $livreur->telephone; // Retourne '0612345678' (dechiffre auto)
Toute la sécurité de Crypt:: repose sur APP_KEY dans le .env. Générée par php artisan key:generate, elle est unique à chaque projet. Conséquences : si APP_KEY change, toutes les données chiffrées deviennent indéchiffrables. Jamais de key:generate en production. Stocker APP_KEY dans un gestionnaire de secrets (Vault, Azure Key Vault) — jamais en dur dans le code.
bcrypt est intentionnellement lent. Le paramètre rounds (10 par défaut) détermine le coût. Chaque hash prend ~100ms. Pour une connexion normale : imperceptible. Pour un attaquant qui essaie 1 million de mots de passe : 100ms × 1 000 000 = ~28 heures. C'est l'objectif. Ne jamais réduire rounds en production (les tests utilisent rounds=4).
use Illuminate\Support\Facades\Hash;
// ---- HACHER ----
// bcrypt (driver par defaut) — rounds = 10
$hash = Hash::make('MonMotDePasse2024!');
// '$2y$10$...' — 60 caracteres
// Chaque appel produit un hash DIFFERENT (salt aleatoire inclus)
Hash::make('abc') !== Hash::make('abc') // true — differents !
// Personnaliser le cout (dev = 4, prod = 10-12)
$hash = Hash::make('MonMotDePasse2024!', ['rounds' => 12]);
// argon2id (recommande pour les nouvelles applications)
// Dans config/hashing.php : 'driver' => 'argon2id'
// ---- VERIFIER ----
// Hash::check() — comparer un mot de passe et son hash
$valide = Hash::check('MonMotDePasse2024!', $user->password); // true
$valide = Hash::check('MauvaisMotDePasse', $user->password); // false
// needsRehash() — verifier si le hash doit etre recalcule (apres augmentation rounds)
if (Hash::needsRehash($user->password)) {
$user->update(['password' => Hash::make($nouveauMotDePasse)]);
}
// ---- CAST ELOQUENT (Laravel 11) ----
// Dans le modele User :
// 'password' => 'hashed'
// → Hash::make() automatique a l'assignation
$user->password = 'NouveauMdp2024!'; // Hash automatique via le cast
$user->save();
// ---- DANS UN CONTROLLER ----
public function changerMotDePasse(Request $request): JsonResponse
{
$request->validate([
'ancien_password' => ['required'],
'nouveau_password' => ['required', 'confirmed', 'min:8'],
]);
// Verifier l'ancien mot de passe
if (! Hash::check($request->ancien_password, auth()->user()->password)) {
return response()->json(['message' => 'Ancien mot de passe incorrect'], 422);
}
auth()->user()->update(['password' => Hash::make($request->nouveau_password)]);
// Revoquer tous les tokens (forcer reconnexion sur tous les appareils)
auth()->user()->tokens()->delete();
return response()->json(['message' => 'Mot de passe mis a jour']);
}
| Algo | Config | Longueur hash | Usage recommandé |
|---|---|---|---|
| bcrypt | rounds: 10 (défaut) | 60 caractères | Projets Laravel existants, compatibilité large |
| argon2i | memory: 65536, time: 4 | Variable | GPU-resistant |
| argon2id | Recommandé PHP 7.3+ | Variable | Nouveaux projets — meilleur compromis |
Crypt::encrypt($password) au lieu de Hash::make($password). Quel est le risque principal ?Hash::make('abc') est appelé deux fois de suite. Les deux hashs sont-ils identiques ?Configuration BDD, SQL brut & Transactions
Configurer config/database.php pour MySQL, connexions multiples, exécuter du SQL brut avec DB::select/insert/update/delete, transactions (DB::transaction()), débogage avec DB::getQueryLog() et Tinker.
- Configurer
config/database.php— drivers supportés, charset utf8mb4 - Connexions multiples — BDD principale + BDD archives
- SQL brut :
DB::select(),DB::insert(),DB::update(),DB::delete() - Transactions :
DB::transaction()et gestion manuellebeginTransaction() - Débogage :
DB::enableQueryLog(),DB::getQueryLog() - Tinker — tester des requêtes en temps réel
return [
'default' => env('DB_CONNECTION', 'mysql'), // Connexion par defaut
'connections' => [
// Connexion principale DariGo
'mysql' => [
'driver' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'darigo_db'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8mb4', // Supporte les emojis et l'arabe
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'strict' => true, // Mode strict MySQL — bonnes pratiques
'engine' => 'InnoDB', // InnoDB : transactions + FK
],
// Connexion secondaire — BDD archives (lecture seule)
'archives' => [
'driver' => 'mysql',
'host' => env('DB_ARCHIVES_HOST', '127.0.0.1'),
'database' => env('DB_ARCHIVES_DATABASE', 'darigo_archives'),
'username' => env('DB_ARCHIVES_USERNAME', 'root'),
'password' => env('DB_ARCHIVES_PASSWORD', ''),
'charset' => 'utf8mb4',
'collation'=> 'utf8mb4_unicode_ci',
],
// SQLite — tests unitaires (rapide, sans serveur)
'sqlite' => [
'driver' => 'sqlite',
'database' => ':memory:', // BDD en memoire — detruite apres les tests
],
],
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
];
// Connexion par defaut (mysql)
$commandes = DB::table('commandes')->get();
// Connexion specifique
$archives = DB::connection('archives')->table('commandes_archivees')->get();
// Dans un modele Eloquent
class CommandeArchive extends Model
{
protected $connection = 'archives';
protected $table = 'commandes_archivees';
}
Jamais : DB::select("SELECT * FROM commandes WHERE id = $id") — injection SQL possible. Toujours : DB::select("SELECT * FROM commandes WHERE id = ?", [$id]). Les bindings (?) ou les bindings nommés (:id) remplacent les valeurs après échappement automatique par PDO.
use Illuminate\Support\Facades\DB;
// ---- SELECT ----
// Retourne un tableau d'objets stdClass (pas de modeles Eloquent)
$commandes = DB::select('SELECT * FROM commandes WHERE statut = ?', ['livree']);
// Bindings nommes
$restaurant = DB::select(
'SELECT * FROM restaurants WHERE ville = :ville AND actif = :actif',
['ville' => 'Casablanca', 'actif' => 1]
);
// selectOne() -- retourne un seul objet
$total = DB::selectOne('SELECT SUM(total) as ca FROM commandes WHERE DATE(created_at) = ?', [today()]);
echo $total->ca; // Chiffre d'affaires du jour
// ---- INSERT ----
DB::insert('INSERT INTO logs_activite (user_id, action, ip) VALUES (?, ?, ?)', [
auth()->id(), 'connexion', request()->ip()
]);
// ---- UPDATE ----
$affectes = DB::update(
'UPDATE commandes SET statut = ? WHERE created_at < ? AND statut = ?',
['expiree', now()->subHours(2), 'en_attente']
);
// Retourne le nombre de lignes affectees
// ---- DELETE ----
$supprimes = DB::delete('DELETE FROM tokens_expires WHERE expires_at < ?', [now()]);
// ---- STATEMENT (CREATE TABLE, ALTER, etc.) ----
DB::statement('ALTER TABLE restaurants ADD COLUMN note_google DECIMAL(3,2) NULL');
// ---- REQUETE BRUTE (sans binding) — uniquement pour du code de confiance ----
DB::unprepared('TRUNCATE TABLE cache');
Un client passe une commande : il faut (1) créer la commande ET (2) décrémenter le stock du plat. Si l'opération 2 échoue, la commande existe mais le stock est faux. Sans transaction : données incohérentes. Avec transaction : si une opération échoue, tout est annulé (rollback). Principe ACID : Atomicité, Cohérence, Isolation, Durabilité.
// Methode 1 : DB::transaction() avec closure — rollback automatique si exception
$commande = DB::transaction(function () use ($request, $user) {
// Operation 1 : creer la commande
$commande = Commande::create([
'client_id' => $user->id,
'restaurant_id' => $request->restaurant_id,
'total' => $request->total,
'statut' => 'en_attente',
]);
// Operation 2 : decrements le stock de chaque plat commande
foreach ($request->lignes as $ligne) {
$plat = Plat::lockForUpdate()->find($ligne['plat_id']); // Verrou pessimiste
if ($plat->stock < $ligne['quantite']) {
throw new \Exception("Stock insuffisant pour : {$plat->nom}");
// L'exception declenche le rollback automatique
}
$plat->decrement('stock', $ligne['quantite']);
LigneCommande::create([
'commande_id' => $commande->id,
'plat_id' => $plat->id,
'quantite' => $ligne['quantite'],
'prix' => $plat->prix,
]);
}
return $commande; // Retour de la closure = commit automatique
});
// Methode 2 : gestion manuelle (plus de controle)
DB::beginTransaction();
try {
$commande = Commande::create([...]);
Plat::where('id', $platId)->decrement('stock', $quantite);
DB::commit(); // Valider
return response()->json(['message' => 'Commande creee', 'id' => $commande->id], 201);
} catch (\Throwable $e) {
DB::rollBack(); // Annuler TOUT
\Log::error('Erreur creation commande', ['error' => $e->getMessage()]);
return response()->json(['erreur' => 'Erreur lors de la creation de la commande'], 500);
}
// Transactions imbriquees (savepoints)
DB::transaction(function () {
// Transaction principale
Restaurant::create([...]);
DB::transaction(function () {
// Savepoint — si celle-ci echoue, seule cette partie est annulee
Plat::create([...]);
});
});
// Activer le query log puis executer des requetes
DB::enableQueryLog();
$commandes = Commande::with('client', 'plats')
->where('statut', 'en_attente')
->get();
$log = DB::getQueryLog();
// Retourne un tableau avec : sql, bindings, time (ms)
// [ ['sql' => 'select * from...', 'bindings' => [...], 'time' => 12.5] ]
dd($log); // Afficher et stopper
// toSql() — voir la requete sans l'executer
$sql = Commande::where('statut', 'livree')
->whereMonth('created_at', now()->month)
->toSql();
// 'select * from `commandes` where `statut` = ? and month(`created_at`) = ?'
// dump() — afficher sans stopper (continuer l'execution)
Commande::where('statut', 'en_attente')->dump()->get();
// Compter les requetes N+1 (Livewire Telescope ou Bar)
// Laravel Debugbar : composer require barryvdh/laravel-debugbar --dev
DB::transaction(closure) ?DB::select('... WHERE id = ?', [$id]) plutôt que DB::select("... WHERE id = $id") ?Query Builder complet
Le générateur de requêtes fluide Laravel — sélection, conditions WHERE avancées, jointures (inner/left/right), GROUP BY, HAVING, pagination, CRUD, sous-requêtes et la méthode when() pour des requêtes conditionnelles.
- Sélection :
get(),first(),find(),value(),pluck(),chunk() - Conditions :
where(),orWhere(),whereIn(),whereBetween(),whereNull(),whereDate() - Jointures :
join(),leftJoin(),rightJoin() - Agrégation :
groupBy(),having(),orderBy(),limit() - CRUD :
insert(),update(),updateOrInsert(),delete() - Pagination :
paginate(),simplePaginate(),cursorPaginate() - Requêtes conditionnelles :
when()
use Illuminate\Support\Facades\DB;
// ---- METHODES DE RECUPERATION ----
$restaurants = DB::table('restaurants')->get(); // Collection de stdClass
$restaurants = DB::table('restaurants')->get(['id', 'nom']); // Colonnes specifiques
$restaurant = DB::table('restaurants')->first(); // Premier ou null
$restaurant = DB::table('restaurants')->find(5); // Par id ou null
$nom = DB::table('restaurants')->value('nom'); // Valeur d'un seul champ
$noms = DB::table('restaurants')->pluck('nom'); // Collection de noms
$noms = DB::table('restaurants')->pluck('nom', 'id'); // [id => nom]
$count = DB::table('restaurants')->count();
$max = DB::table('commandes')->max('total');
$avg = DB::table('commandes')->avg('total');
$sum = DB::table('commandes')->sum('total');
// ---- CONDITIONS WHERE ----
// Operateurs : =, !=, <, <=, >, >=, like, not like, in, not in
DB::table('restaurants')->where('ville', 'Casablanca')->get();
DB::table('restaurants')->where('note', '>=', 4.0)->get();
DB::table('restaurants')->where('nom', 'like', '%darigo%')->get();
// WHERE multiples (AND)
DB::table('restaurants')
->where('ville', 'Casablanca')
->where('actif', true)
->where('note', '>=', 3.5)
->get();
// WHERE OR
DB::table('restaurants')
->where('ville', 'Casablanca')
->orWhere('ville', 'Rabat')
->get();
// WHERE groupes (parentheses)
DB::table('restaurants')
->where(function ($query) {
$query->where('ville', 'Casablanca')
->orWhere('ville', 'Marrakech');
})
->where('note', '>=', 4.0)
->get();
// SQL : WHERE (ville = 'Casablanca' OR ville = 'Marrakech') AND note >= 4.0
// WHERE IN / NOT IN
DB::table('commandes')->whereIn('statut', ['livree', 'confirmee'])->get();
DB::table('commandes')->whereNotIn('statut', ['annulee'])->get();
// WHERE NULL / NOT NULL
DB::table('commandes')->whereNull('livreur_id')->get(); // Sans livreur affecte
DB::table('livraisons')->whereNotNull('accepte_le')->get();
// WHERE BETWEEN
DB::table('commandes')->whereBetween('total', [50, 200])->get();
DB::table('plats')->whereBetween('prix', [20, 100])->get();
// WHERE DATE (Carbon/MySQL)
DB::table('commandes')->whereDate('created_at', today())->get(); // Aujourd'hui
DB::table('commandes')->whereMonth('created_at', now()->month)->get(); // Ce mois
DB::table('commandes')->whereYear('created_at', now()->year)->get(); // Cette annee
DB::table('commandes')->whereTime('created_at', '>=', '12:00:00')->get(); // Apres midi
// ---- JOINTURES ----
// INNER JOIN — commandes avec le nom du client
$commandes = DB::table('commandes')
->join('users', 'commandes.client_id', '=', 'users.id')
->select('commandes.*', 'users.prenom', 'users.email')
->get();
// LEFT JOIN — tous les restaurants, avec ou sans commande
$stats = DB::table('restaurants')
->leftJoin('commandes', 'restaurants.id', '=', 'commandes.restaurant_id')
->select('restaurants.nom', DB::raw('COUNT(commandes.id) as nb_commandes'))
->groupBy('restaurants.id', 'restaurants.nom')
->orderBy('nb_commandes', 'DESC')
->get();
// Jointure multiple
$livraisons = DB::table('livraisons')
->join('commandes', 'livraisons.commande_id', '=', 'commandes.id')
->join('users as clients', 'commandes.client_id', '=', 'clients.id')
->join('users as livreurs', 'livraisons.livreur_id', '=', 'livreurs.id')
->select(
'livraisons.statut',
'clients.prenom as client',
'livreurs.prenom as livreur',
'commandes.total'
)
->get();
// ---- GROUP BY + HAVING ----
// Chiffre d'affaires par restaurant (mois en cours)
$ca = DB::table('commandes')
->select('restaurant_id', DB::raw('SUM(total) as ca_mensuel'), DB::raw('COUNT(*) as nb_commandes'))
->whereMonth('created_at', now()->month)
->where('statut', 'livree')
->groupBy('restaurant_id')
->having('ca_mensuel', '>=', 1000) // Restaurants avec CA > 1000 MAD
->orderBy('ca_mensuel', 'DESC')
->get();
// ---- TRI ET LIMITE ----
DB::table('restaurants')->orderBy('note', 'DESC')->limit(10)->get(); // Top 10
DB::table('restaurants')->latest('created_at')->first(); // Plus recent
DB::table('restaurants')->inRandomOrder()->first(); // Aleatoire
DB::table('commandes')->skip(20)->take(10)->get(); // Pagination manuelle
when()// ---- INSERT ----
DB::table('logs')->insert([
'user_id' => auth()->id(),
'action' => 'connexion',
'ip' => request()->ip(),
]);
// insertGetId() — recuperer l'id insere
$id = DB::table('livraisons')->insertGetId([
'commande_id' => 15,
'statut' => 'disponible',
'created_at' => now(),
]);
// ---- UPDATE ----
$affected = DB::table('commandes')
->where('statut', 'en_attente')
->where('created_at', '<', now()->subHours(2))
->update(['statut' => 'expiree', 'updated_at' => now()]);
// increment() / decrement()
DB::table('plats')->where('id', 5)->decrement('stock', 2);
DB::table('restaurants')->where('id', 3)->increment('nb_commandes');
// updateOrInsert() — chercher et mettre a jour OU creer
DB::table('statistiques_journalieres')->updateOrInsert(
['restaurant_id' => 5, 'date' => today()], // Conditions de recherche
['nb_commandes' => DB::raw('nb_commandes + 1'), 'updated_at' => now()] // Donnees
);
// ---- DELETE ----
DB::table('tokens')->where('expires_at', '<', now())->delete();
// ---- when() — requeues conditionnelles ----
// PROBLEME : construire une requete selon les filtres de l'utilisateur
$ville = request('ville');
$categorie = request('categorie');
$note_min = request('note_min');
$restaurants = DB::table('restaurants')
->when($ville, function ($query, $ville) {
// Ce bloc ne s'execute QUE si $ville est non-null/non-vide
$query->where('ville', $ville);
})
->when($categorie, function ($query, $categorie) {
$query->where('categorie', $categorie);
})
->when($note_min, function ($query, $note) {
$query->where('note', '>=', $note);
})
->when(request('tri') === 'note', function ($query) {
$query->orderBy('note', 'DESC');
}, function ($query) {
$query->orderBy('created_at', 'DESC'); // Tri par defaut
})
->paginate(12);
// ---- chunk() — traiter les grandes tables par morceaux ----
DB::table('commandes')->where('statut', 'livree')->chunk(100, function ($commandes) {
foreach ($commandes as $commande) {
// Traiter 100 commandes a la fois — evite les problemes de memoire
$this->genererFacture($commande);
}
});
// ---- Pagination ----
$restaurants = DB::table('restaurants')->paginate(12); // Avec total (count)
$restaurants = DB::table('restaurants')->simplePaginate(12); // Sans total (plus rapide)
// Dans le Controller API : paginate() retourne toutes les metadata
// {"current_page":1,"data":[...],"last_page":5,"total":60,"per_page":12}
DB::table('restaurants')->get() retourne des objets stdClass. Restaurant::all() retourne des instances du modèle Restaurant. Quelle est la différence pratique ?ville, categorie, note_min. Sans when(), comment ce code serait-il écrit ?Migrations & Schema Builder
Créer, modifier et versionner le schéma de la base de données avec les migrations Laravel — up()/down(), tous les types de colonnes, modificateurs (nullable, default, unique), clés étrangères, indexes, et les 6 commandes migrate essentielles.
- Définition et utilité des migrations — versionner le schéma BDD comme du code
- Structure
up()etdown()— créer et annuler - Tous les types de colonnes :
string,integer,decimal,enum,json,timestamp... - Modificateurs :
nullable(),default(),unique(),unsigned() - Clés étrangères :
foreignId()->constrained()etcascadeOnDelete() - Modifier une table existante avec
Schema::table()
Sans migrations : chaque développeur modifie sa BDD locale manuellement et le serveur de production a une structure différente. Avec migrations : la structure de la BDD est décrite en PHP versionné sur Git. php artisan migrate applique exactement les mêmes tables partout (local, CI/CD, production). Rollback possible : php artisan migrate:rollback.
# Creer une migration (nom prefixe automatiquement par la date+heure)
php artisan make:migration create_restaurants_table
php artisan make:migration create_plats_table
php artisan make:migration create_commandes_table
php artisan make:migration ajouter_note_google_to_restaurants_table
# Avec table specifiee explicitement
php artisan make:migration ajouter_colonne_photo --table=restaurants
# Avec le modele (creer modele + migration + seeder + factory + controller)
php artisan make:model Restaurant -mscf --resource
Schema::create('restaurants', function (Blueprint $table) {
$table->id(); // BIGINT UNSIGNED AUTO_INCREMENT PK
$table->string('nom', 100); // VARCHAR(100) NOT NULL
$table->string('slug', 120)->unique(); // VARCHAR + index UNIQUE
$table->text('description')->nullable(); // TEXT NULL
$table->string('ville', 50);
$table->string('adresse');
$table->string('telephone', 15)->nullable();
$table->enum('categorie', ['tajine','couscous','pizza','burger','sushi','marocain']);
$table->json('horaires')->nullable(); // JSON — tableau des horaires
$table->decimal('note', 3, 2)->default(0.00); // DECIMAL(3,2) — ex: 4.75
$table->unsignedSmallInteger('nb_avis')->default(0);
$table->boolean('actif')->default(true);
$table->string('photo')->nullable();
// Cle etrangere — proprietaire du restaurant
$table->foreignId('proprietaire_id') // BIGINT UNSIGNED
->constrained('users') // FK vers users.id
->restrictOnDelete(); // Interdit supprimer un user avec restaurants
$table->timestamps(); // created_at + updated_at TIMESTAMP
// Index pour les recherches frequentes
$table->index(['ville', 'actif']); // Index composite
$table->index('note'); // Index simple
});
Schema::create('commandes', function (Blueprint $table) {
$table->id();
$table->foreignId('client_id')->constrained('users')->cascadeOnDelete();
// cascadeOnDelete() : supprimer le client supprime ses commandes
$table->foreignId('restaurant_id')->constrained()->restrictOnDelete();
$table->foreignId('livreur_id')->nullable()->constrained('users')->nullOnDelete();
// nullOnDelete() : si livreur supprime, livreur_id devient NULL
$table->decimal('total', 8, 2);
$table->enum('statut', ['en_attente','confirmee','en_preparation','prete','en_livraison','livree','annulee'])
->default('en_attente');
$table->string('adresse_livraison');
$table->text('notes')->nullable();
$table->timestamp('acceptee_le')->nullable();
$table->timestamp('livree_le')->nullable();
$table->timestamps();
$table->index(['client_id', 'statut']);
$table->index('created_at');
});
// Table pivot commande_plat (many-to-many)
Schema::create('commande_plat', function (Blueprint $table) {
$table->foreignId('commande_id')->constrained()->cascadeOnDelete();
$table->foreignId('plat_id')->constrained()->restrictOnDelete();
$table->unsignedTinyInteger('quantite')->default(1);
$table->decimal('prix_unitaire', 8, 2); // Prix au moment de la commande
$table->primary(['commande_id', 'plat_id']); // Cle primaire composite
});
// Fichier : ajouter_note_google_to_restaurants_table
return new class extends Migration
{
public function up(): void
{
Schema::table('restaurants', function (Blueprint $table) {
$table->decimal('note_google', 3, 2)->nullable()->after('note');
$table->string('place_id_google', 50)->nullable()->after('note_google');
});
}
public function down(): void
{
Schema::table('restaurants', function (Blueprint $table) {
$table->dropColumn(['note_google', 'place_id_google']);
});
}
};
| Commande | Action | Usage |
|---|---|---|
migrate | Exécuter les migrations non exécutées | Standard — tous environnements |
migrate:status | Afficher l'état de chaque migration | Débogage — voir ce qui est appliqué |
migrate:rollback | Annuler le dernier batch de migrations | Corriger une migration en développement |
migrate:rollback --step=3 | Annuler les 3 derniers batchs | Revenir plusieurs étapes en arrière |
migrate:fresh | Supprimer toutes les tables + remigrer | Développement local uniquement — DANGER prod |
migrate:fresh --seed | Fresh + seeders | Reset complet de la BDD de test |
migrate --pretend | Afficher le SQL sans exécuter | Vérifier avant d'appliquer en production |
create_plats_table utilise foreignId('restaurant_id')->constrained(). Un restaurateur tente de supprimer son restaurant qui a encore 5 plats. Que se passe-t-il si la contrainte est restrictOnDelete() ?note_google à la table restaurants déjà en production. Quelle est la bonne approche ?Seeders & Factories
Peupler la base de données avec des données initiales (Seeders) et des données de test réalistes (Factories + Faker) — données marocaines pour DariGo, DatabaseSeeder, factory()->count()->create().
- Différence données initiales (livrées avec l'app) vs données de test
- Créer un Seeder avec
php artisan make:seeder - Créer une Factory avec
php artisan make:factoryet Faker - Faker localisé pour le Maroc — noms, villes, téléphones marocains
DatabaseSeeder— orchestrer l'ordre des seedersfactory()->count(50)->create(),->for(),->has()
php artisan make:seeder VillesSeeder # Donnees initiales — villes marocaines
php artisan make:seeder CategoriesSeeder # Categories de restaurants
php artisan make:seeder AdminUserSeeder # Compte admin initial
php artisan make:seeder RestaurantSeeder # Restaurants de test
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class VillesSeeder extends Seeder
{
public function run(): void
{
// truncate() vide la table avant d'inserer (evite les doublons)
DB::table('villes')->truncate();
DB::table('villes')->insert([
['nom' => 'Casablanca', 'region' => 'Casablanca-Settat', 'code_postal' => '20000'],
['nom' => 'Rabat', 'region' => 'Rabat-Salé-Kénitra', 'code_postal' => '10000'],
['nom' => 'Marrakech', 'region' => 'Marrakech-Safi', 'code_postal' => '40000'],
['nom' => 'Fès', 'region' => 'Fès-Meknès', 'code_postal' => '30000'],
['nom' => 'Tanger', 'region' => 'Tanger-Tétouan-Al Hoceima', 'code_postal' => '90000'],
['nom' => 'Agadir', 'region' => 'Souss-Massa', 'code_postal' => '80000'],
['nom' => 'Meknès', 'region' => 'Fès-Meknès', 'code_postal' => '50000'],
['nom' => 'Oujda', 'region' => 'Oriental', 'code_postal' => '60000'],
['nom' => 'Kénitra', 'region' => 'Rabat-Salé-Kénitra', 'code_postal' => '14000'],
['nom' => 'El Jadida', 'region' => 'Casablanca-Settat', 'code_postal' => '24000'],
]);
$this->command->info('Villes marocaines insérées : ' . DB::table('villes')->count());
}
}
use App\Models\User;
use Illuminate\Support\Facades\Hash;
class AdminUserSeeder extends Seeder
{
public function run(): void
{
// firstOrCreate() : creer uniquement si n'existe pas (idempotent)
User::firstOrCreate(
['email' => 'admin@darigo.ma'],
[
'prenom' => 'Admin',
'nom' => 'DariGo',
'password' => Hash::make('Admin2024!'),
'role' => 'admin',
'ville' => 'Casablanca',
'telephone' => '0522000000',
'email_verified_at' => now(),
]
);
$this->command->info('Compte admin crée : admin@darigo.ma / Admin2024!');
}
}
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
class UserFactory extends Factory
{
// Utiliser le Faker fr_MA (francais marocain) pour des donnees realistes
protected $model = \App\Models\User::class;
// Donnees marocaines statiques pour les noms et villes
private static array $prenomsMarocains = [
'Karim', 'Nadia', 'Youssef', 'Fatima', 'Mohamed', 'Leila',
'Amine', 'Sara', 'Hassan', 'Kenza', 'Rachid', 'Imane',
];
private static array $nomsMarocains = [
'Alaoui', 'Benali', 'Chraibi', 'Idrissi', 'Mansouri', 'Tazi',
'Bennani', 'El Fassi', 'Berrada', 'Filali', 'Guessous', 'Hajji',
];
private static array $villesMarocaines = [
'Casablanca', 'Rabat', 'Marrakech', 'Fès', 'Tanger', 'Agadir',
];
public function definition(): array
{
$prenom = $this->faker->randomElement(self::$prenomsMarocains);
$nom = $this->faker->randomElement(self::$nomsMarocains);
$ville = $this->faker->randomElement(self::$villesMarocaines);
return [
'prenom' => $prenom,
'nom' => $nom,
'email' => strtolower($prenom . '.' . $nom . $this->faker->unique()->randomNumber(3)) . '@gmail.com',
'telephone' => '06' . $this->faker->numerify('########'), // 06XXXXXXXX
'password' => Hash::make('password'),
'role' => 'client',
'ville' => $ville,
'email_verified_at' => now(),
];
}
// States — variantes de la factory
public function restaurateur(): static
{
return $this->state(['role' => 'restaurateur']);
}
public function livreur(): static
{
return $this->state(['role' => 'livreur']);
}
public function admin(): static
{
return $this->state([
'role' => 'admin',
'email' => 'admin@darigo.ma',
]);
}
}
class RestaurantFactory extends Factory
{
protected $model = Restaurant::class;
private static array $nomsRestaurants = [
'Riad Al Baraka', 'Le Tajine d\'Or', 'Casa Blanca Grill',
'Dar Zitoun', 'La Maison Berbere', 'Oasis Culinaire',
'Le Couscous Royal', 'Marrakech Saveurs', 'Atlantic Grill',
];
private static array $categories = [
'tajine', 'couscous', 'pizza', 'burger', 'sushi', 'marocain',
];
public function definition(): array
{
$nom = $this->faker->randomElement(self::$nomsRestaurants);
$slug = \Str::slug($nom) . '-' . $this->faker->unique()->randomNumber(4);
return [
'nom' => $nom,
'slug' => $slug,
'description' => $this->faker->paragraph(2),
'ville' => $this->faker->randomElement(['Casablanca','Rabat','Marrakech','Fès']),
'adresse' => $this->faker->numberBetween(1, 200) . ' Rue ' . $this->faker->word(),
'telephone' => '05' . $this->faker->numerify('########'),
'categorie' => $this->faker->randomElement(self::$categories),
'note' => $this->faker->randomFloat(2, 2.0, 5.0),
'nb_avis' => $this->faker->numberBetween(0, 500),
'actif' => true,
'proprietaire_id'=> User::factory()->restaurateur(), // Creer un proprietaire
];
}
public function inactif(): static
{
return $this->state(['actif' => false]);
}
}
<?php
namespace Database\Seeders;
use App\Models\{Restaurant, User, Plat, Commande};
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
// ---- DONNEES INITIALES (livrees avec l'application) ----
$this->call([
VillesSeeder::class, // Villes marocaines
CategoriesSeeder::class, // Categories de plats
AdminUserSeeder::class, // Compte admin
]);
// ---- DONNEES DE TEST (developpement uniquement) ----
if (app()->environment('local', 'testing')) {
// 1. Creer des utilisateurs par role
$clients = User::factory(50)->create(); // 50 clients
$restaurateurs = User::factory(10)->restaurateur()->create(); // 10 restaurateurs
$livreurs = User::factory(20)->livreur()->create(); // 20 livreurs
// 2. Creer des restaurants pour chaque restaurateur
$restaurants = Restaurant::factory(30)
->recycle($restaurateurs) // Reutiliser les restaurateurs existants
->create();
// 3. Creer des plats pour chaque restaurant (5 a 15 plats)
$restaurants->each(function ($restaurant) {
Plat::factory(rand(5, 15))
->for($restaurant) // Lier au restaurant
->create();
});
// 4. Creer des commandes pour les clients
$clients->each(function ($client) use ($restaurants) {
Commande::factory(rand(0, 5))
->for($client, 'client')
->for($restaurants->random(), 'restaurant')
->create();
});
$this->command->info("Base de donnees peuplee :");
$this->command->table(
['Table', 'Count'],
[
['Utilisateurs', User::count()],
['Restaurants', Restaurant::count()],
['Commandes', Commande::count()],
]
);
}
}
}
php artisan db:seed # Executer DatabaseSeeder
php artisan db:seed --class=VillesSeeder # Seeder specifique
php artisan migrate:fresh --seed # Reset BDD + tous les seeders
php artisan migrate:fresh --seed --seeder=AdminUserSeeder # Reset + seeder specifique
php artisan db:seed est exécuté deux fois de suite sans truncate() dans le seeder. Que se passe-t-il ?Redis & Optimisation
Redis comme BDD clé-valeur ultra-rapide pour le cache, les sessions et les queues Laravel — installation, commandes Redis::set/get/hset/incr, configuration Laravel, et stratégies d'optimisation pour l'API DariGo.
- Redis — qu'est-ce que c'est, pourquoi c'est 100x plus rapide que MySQL
- Installer Redis +
predis/predisouphpredis - Commandes Redis :
SET/GET,HSET/HGET,INCR,EXPIRE,TTL - Redis pour le cache Laravel —
CACHE_STORE=redis - Redis pour les sessions —
SESSION_DRIVER=redis - Redis pour les queues —
QUEUE_CONNECTION=redis
MySQL : optimisé pour les données persistantes (transactions, relations). Mais pour lire la liste des restaurants 10 000 fois par heure, chaque lecture MySQL prend 5-50ms. Redis stocke les données en RAM : lecture en 0.1ms — 100x plus rapide. Redis n'est pas un remplacement de MySQL, mais un complément pour les données fréquemment lues et peu modifiées.
| Structure Redis | Commandes clés | Usage DariGo |
|---|---|---|
| String | SET key val EX 300, GET key | Cache d'un restaurant, compteur de vues |
| Hash | HSET key field val, HGET, HGETALL | Profil utilisateur (plusieurs champs) |
| List | LPUSH key val, LRANGE key 0 -1 | File de jobs, historique de connexions |
| Set | SADD key val, SMEMBERS key | Restaurants favoris d'un utilisateur |
| Sorted Set | ZADD key score val, ZRANGE | Top restaurants par note |
# Installer Redis (Ubuntu/Debian)
sudo apt install redis-server -y
redis-server --version # Verifier
# macOS
brew install redis && brew services start redis
# Tester Redis en CLI
redis-cli ping # PONG
redis-cli set test "DariGo fonctionne"
redis-cli get test # "DariGo fonctionne"
# Installer le client PHP Redis
composer require predis/predis # Pure PHP (toujours compatible)
# OU : installer l'extension phpredis (plus rapide, necessite compilation)
# Configurer .env
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
use Illuminate\Support\Facades\Redis;
// ---- STRINGS ----
Redis::set('darigo:restaurant:5', json_encode($restaurant)); // Stocker un objet
Redis::expire('darigo:restaurant:5', 300); // Expiration 5 minutes
// ou en une commande :
Redis::setex('darigo:restaurant:5', 300, json_encode($restaurant));
$data = Redis::get('darigo:restaurant:5'); // Retourne la string JSON
$restaurant = json_decode($data, true); // Decoder
Redis::exists('darigo:restaurant:5'); // true/false
Redis::ttl('darigo:restaurant:5'); // Temps restant en secondes
Redis::del('darigo:restaurant:5'); // Supprimer
// ---- HASHES — stocker un objet champ par champ ----
Redis::hset('darigo:user:42', 'prenom', 'Karim');
Redis::hset('darigo:user:42', 'ville', 'Casablanca');
Redis::hset('darigo:user:42', 'nb_commandes', 15);
Redis::expire('darigo:user:42', 3600); // 1 heure
$prenom = Redis::hget('darigo:user:42', 'prenom'); // 'Karim'
$profil = Redis::hgetall('darigo:user:42'); // ['prenom'=>'Karim',...]
// ---- COMPTEURS — incrementer/decrementer ----
Redis::incr('darigo:stats:vues:restaurant:5'); // +1
Redis::incrby('darigo:stats:vues:restaurant:5', 5); // +5
Redis::decr('darigo:stock:plat:12'); // -1
// ---- LISTES ----
Redis::lpush('darigo:notifications:user:42', json_encode(['type' => 'livraison', 'msg' => 'Commande livree']));
$notifs = Redis::lrange('darigo:notifications:user:42', 0, 9); // 10 dernieres
// ---- PIPELINE — executer plusieurs commandes en une requete reseau ----
$results = Redis::pipeline(function ($pipe) {
$pipe->set('darigo:test:1', 'valeur1');
$pipe->set('darigo:test:2', 'valeur2');
$pipe->incr('darigo:compteur');
}); // 3 commandes en 1 aller-retour reseau (vs 3 allers-retours)
# Cache — stocker les resultats de requetes (restaurants, menus...)
CACHE_STORE=redis
# Sessions — stocker les sessions utilisateur (plus scalable que fichiers)
SESSION_DRIVER=redis
# Queues — jobs en arriere-plan (emails, notifications SMS...)
QUEUE_CONNECTION=redis
// ---- CACHE RESTAURANTS (Cache::remember) ----
public function index(Request $request): JsonResponse
{
$ville = $request->get('ville', 'Casablanca');
$cacheKey = "restaurants:{$ville}:" . md5(json_encode($request->all()));
$restaurants = Cache::remember($cacheKey, 300, function () use ($request, $ville) {
return Restaurant::with('plats')
->where('ville', $ville)
->where('actif', true)
->when($request->categorie, fn($q, $cat) => $q->where('categorie', $cat))
->orderBy('note', 'desc')
->paginate(12);
});
return response()->json($restaurants);
}
// ---- INVALIDATION CACHE quand un restaurant est modifie ----
// Dans RestaurantObserver::updated() ou dans le Controller update()
Cache::tags(['restaurants'])->flush(); // Redis uniquement (tags non supporte file)
// Ou : Cache::forget("restaurants:Casablanca:...");
// ---- SESSIONS REDIS ----
// Aucun changement de code — Session facade fonctionne identiquement
session()->put('panier', $panier);
session()->get('panier');
// ---- QUEUE REDIS ----
// Demarrer le worker Redis
// php artisan queue:work redis --queue=emails,default
// Supervision avec Supervisor en production (process manager)
| Indicateur | Sans Redis (MySQL uniquement) | Avec Redis |
|---|---|---|
| Temps de réponse GET /restaurants | ~80ms (MySQL) | ~2ms (Redis hit) |
| Charge MySQL à 1000 req/min | 1000 requêtes SQL/min | ~2 requêtes SQL/min (expirations) |
| Scalabilité sessions (plusieurs serveurs) | Impossible (fichiers locaux) | Native (Redis partagé) |
| Délai des queues | DB: poll toutes les 3s | Redis: push instantané |
Modèles Eloquent & CRUD
Créer et configurer les modèles Eloquent, conventions de nommage, mass assignment ($fillable/$guarded), opérations CRUD (all, find, create, update, delete), requêtes de récupération avancées et pagination.
- Créer un modèle avec
php artisan make:model -m— conventions de nommage - Configuration :
$fillable,$guarded,$table,$primaryKey,$casts - Mass assignment — sécurité et vulnérabilités
- CRUD Eloquent :
all(),find(),findOrFail(),create(),update(),delete() - Requêtes avancées :
where(),selectRaw(),groupBy(),having() - Pagination :
paginate(),simplePaginate(),cursorPaginate()
Chaque table MySQL correspond à un modèle Eloquent. Le modèle Restaurant correspond automatiquement à la table restaurants (nom au pluriel, minuscules). Eloquent encapsule les enregistrements en objets PHP — plus besoin d'écrire de SQL pour les opérations courantes.
# Modele seul
php artisan make:model Restaurant
# Modele + migration + seeder + factory + controller resource (tout en une commande)
php artisan make:model Restaurant -mcsf --resource
# ou : php artisan make:model Restaurant --all
# Raccourcis : -m=migration, -c=controller, -s=seeder, -f=factory, -r=resource
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Restaurant extends Model
{
use HasFactory, SoftDeletes;
// -- Optionnel : si le nom de la table ne respecte pas la convention --
// protected $table = 'restaurants'; // Laravel devine automatiquement
// protected $primaryKey = 'id'; // Par defaut 'id'
// public $timestamps = true; // created_at + updated_at geres auto
// -- Mass Assignment : liste blanche des colonnes autorisees --
protected $fillable = [
'nom',
'slug',
'description',
'ville',
'adresse',
'telephone',
'categorie',
'horaires',
'actif',
'photo',
'proprietaire_id',
];
// -- Casts : conversion automatique des types --
protected function casts(): array
{
return [
'horaires' => 'array', // JSON <-> tableau PHP automatiquement
'actif' => 'boolean', // 0/1 <-> true/false
'note' => 'float',
'created_at' => 'datetime',
];
}
// -- Attributs caches (jamais dans toArray/toJson) --
protected $hidden = [
'proprietaire_id', // Ne pas exposer dans l'API
];
// -- Attributs toujours charges avec le modele --
protected $appends = ['disponible'];
}
Si le modèle User a un champ role non listé dans $fillable, un attaquant ne peut pas l'injecter via User::create($request->all()). Sans $fillable ni $guarded : MassAssignmentException. Avec $guarded = [] : tout est autorisé — à utiliser avec prudence uniquement quand les données sont déjà validées par une Form Request.
// ---- LIRE ----
$restaurants = Restaurant::all(); // Tous les restaurants
$restaurant = Restaurant::find(5); // Par id (null si absent)
$restaurant = Restaurant::findOrFail(5); // Par id (404 si absent)
$restaurant = Restaurant::where('slug', 'dar-zitoun')->first();
$restaurant = Restaurant::where('slug', 'dar-zitoun')->firstOrFail();
// Selectionner des colonnes specifiques
$noms = Restaurant::select('id', 'nom', 'ville')->get();
// Conditions multiples
$restaurants = Restaurant::where('ville', 'Casablanca')
->where('actif', true)
->where('note', '>=', 4.0)
->orderBy('note', 'DESC')
->limit(10)
->get();
// ---- CREER ----
// Methode 1 : create() — mass assignment (fillable requis)
$restaurant = Restaurant::create([
'nom' => 'Dar Zitoun',
'slug' => 'dar-zitoun',
'ville' => 'Marrakech',
'adresse' => '14 Rue Moulay Ali, Medina',
'categorie' => 'marocain',
'proprietaire_id' => auth()->id(),
]);
// Methode 2 : new + save() (utile pour initialiser puis modifier)
$restaurant = new Restaurant();
$restaurant->nom = 'Dar Zitoun';
$restaurant->ville = 'Marrakech';
$restaurant->save();
// $restaurant->id est maintenant disponible
// firstOrCreate() — creer seulement si n'existe pas
$restaurant = Restaurant::firstOrCreate(
['slug' => 'dar-zitoun'], // Conditions de recherche
['nom' => 'Dar Zitoun', 'ville' => 'Marrakech'] // Valeurs si creation
);
// updateOrCreate() — modifier si existe, creer sinon
Restaurant::updateOrCreate(
['slug' => 'dar-zitoun'],
['note' => 4.7, 'nb_avis' => 150]
);
// ---- MODIFIER ----
// Methode 1 : update() sur une instance
$restaurant = Restaurant::findOrFail(5);
$restaurant->update(['note' => 4.8, 'nb_avis' => 200]);
// Methode 2 : update() en masse (tous les restaurants de Casablanca)
Restaurant::where('ville', 'Casablanca')->update(['actif' => false]);
// Methode 3 : assigner puis save
$restaurant->note = 4.9;
$restaurant->save();
// fresh() — recharger depuis la BDD apres modification
$restaurant = $restaurant->fresh();
// ---- SUPPRIMER ----
$restaurant = Restaurant::findOrFail(5);
$restaurant->delete(); // Soft delete si SoftDeletes est activee
// Supprimer en masse
Restaurant::where('actif', false)->where('created_at', '<', now()->subYear())->delete();
// Restaurer un soft delete
Restaurant::withTrashed()->find(5)->restore();
// Supprimer definitivement (bypass soft delete)
Restaurant::withTrashed()->find(5)->forceDelete();
// Colonne calculee avec selectRaw
$plats = Plat::select('nom', 'prix')
->selectRaw('prix * 1.2 as prix_avec_tva')
->where('actif', true)
->get();
// Statistiques par ville
$stats = Restaurant::selectRaw('ville, COUNT(*) as nb_restaurants, AVG(note) as note_moyenne')
->where('actif', true)
->groupBy('ville')
->having('note_moyenne', '>=', 3.5)
->orderBy('nb_restaurants', 'DESC')
->get();
// Resultat : [['ville'=>'Casablanca', 'nb_restaurants'=>45, 'note_moyenne'=>4.2], ...]
// Top restaurants avec nb_commandes (withCount)
$topRestaurants = Restaurant::withCount('commandes')
->withAvg('commandes', 'total')
->where('actif', true)
->orderBy('commandes_count', 'DESC')
->limit(10)
->get();
// Chaque restaurant a : $restaurant->commandes_count, $restaurant->commandes_avg_total
// ---- PAGINATION ----
// paginate() — avec total count (pour afficher "Page 2/15")
$restaurants = Restaurant::where('actif', true)
->orderBy('note', 'DESC')
->paginate(12);
// JSON API : {"data":[...],"current_page":1,"last_page":15,"total":180,"per_page":12}
// simplePaginate() — sans total count (plus rapide sur grandes tables)
$restaurants = Restaurant::paginate(12);
// cursorPaginate() — pour les feeds infinis (pas de sauts de pages)
$restaurants = Restaurant::orderBy('id')->cursorPaginate(20);
// Pagination avec filtres (preserveQuery)
// GET /api/restaurants?ville=Casablanca&page=2
$restaurants = Restaurant::where('ville', request('ville'))
->paginate(12)
->withQueryString(); // Inclut ?ville=Casablanca dans les liens de pagination
Restaurant::findOrFail(999) est appelé et le restaurant #999 n'existe pas. Que se passe-t-il dans une route API avec la gestion globale des exceptions configurée ?SoftDeletes. Restaurant::where('actif', false)->delete() est appelé. Que contient la colonne deleted_at de ces restaurants ?Relations Eloquent
Toutes les relations Eloquent appliquées à DariGo — hasOne, hasMany, belongsTo, belongsToMany (table pivot, withPivot, attach/detach/sync), hasManyThrough, relations polymorphes, eager loading et résolution du problème N+1.
hasOne— un restaurant a un seul paramètre de livraisonhasMany/belongsTo— un restaurant a plusieurs platsbelongsToMany— commandes et plats via table pivotcommande_platwithPivot(),attach(),detach(),sync()hasManyThrough— un client a plusieurs factures via ses commandes- Problème N+1 — détection et solution avec
with() has(),whereHas(),withCount()
class Restaurant extends Model
{
// hasOne : un restaurant a un seul profil de livraison
public function profilLivraison(): HasOne
{
return $this->hasOne(ProfilLivraison::class);
// Laravel cherche profilLivraison.restaurant_id par convention
}
// hasMany : un restaurant a plusieurs plats
public function plats(): HasMany
{
return $this->hasMany(Plat::class);
// Laravel cherche plats.restaurant_id
}
// hasMany : un restaurant a plusieurs commandes
public function commandes(): HasMany
{
return $this->hasMany(Commande::class);
}
// belongsTo : un restaurant appartient a un proprietaire (User)
public function proprietaire(): BelongsTo
{
return $this->belongsTo(User::class, 'proprietaire_id');
// 'proprietaire_id' = nom explicite de la FK (convention = user_id)
}
}
class Plat extends Model
{
// belongsTo : un plat appartient a un restaurant
public function restaurant(): BelongsTo
{
return $this->belongsTo(Restaurant::class);
// Laravel cherche plats.restaurant_id (convention automatique)
}
}
$restaurant = Restaurant::find(5);
// Propriete (acces direct) — lazy loading (1 requete SQL supplementaire)
$plats = $restaurant->plats; // Collection de Plat
$proprio = $restaurant->proprietaire; // Objet User
// Methode (relation comme query builder) — ajouter des contraintes
$platsActifs = $restaurant->plats()->where('actif', true)->orderBy('prix')->get();
$platsDispos = $restaurant->plats()->where('stock', '>', 0)->count();
// Ajouter via la relation (client_id/restaurant_id assigné automatiquement)
$restaurant->plats()->create([
'nom' => 'Pastilla au Poulet',
'prix' => 65.00,
'stock' => 20,
'categorie' => 'plat',
]);
// save() et saveMany()
$restaurant->plats()->save($plat);
$restaurant->plats()->saveMany([$plat1, $plat2]);
// Requetes sur les relations
Restaurant::has('plats')->get(); // Restaurants avec au moins 1 plat
Restaurant::has('plats', '>=', 5)->get(); // Au moins 5 plats
Restaurant::whereHas('plats', function ($q) { // Plats < 50 MAD
$q->where('prix', '<', 50);
})->get();
Restaurant::withCount('plats')->get(); // + champ plats_count
class Commande extends Model
{
// Une commande a plusieurs plats, et un plat est dans plusieurs commandes
public function plats(): BelongsToMany
{
return $this->belongsToMany(Plat::class, 'commande_plat')
->withPivot(['quantite', 'prix_unitaire']) // Colonnes de la table pivot
->withTimestamps(); // created_at/updated_at de la pivot
}
// Client de la commande
public function client(): BelongsTo
{
return $this->belongsTo(User::class, 'client_id');
}
// Restaurant de la commande
public function restaurant(): BelongsTo
{
return $this->belongsTo(Restaurant::class);
}
}
class Plat extends Model
{
public function commandes(): BelongsToMany
{
return $this->belongsToMany(Commande::class, 'commande_plat')
->withPivot(['quantite', 'prix_unitaire'])
->withTimestamps();
}
}
$commande = Commande::find(3);
// attach() — ajouter un plat a la commande avec donnees pivot
$commande->plats()->attach(12, [
'quantite' => 2,
'prix_unitaire' => 65.00,
]);
// Ajouter plusieurs plats
$commande->plats()->attach([
12 => ['quantite' => 2, 'prix_unitaire' => 65.00],
15 => ['quantite' => 1, 'prix_unitaire' => 45.00],
]);
// detach() — retirer un plat
$commande->plats()->detach(12);
$commande->plats()->detach(); // Retirer TOUS les plats
// sync() — synchroniser (supprime les anciens, ajoute les nouveaux)
$commande->plats()->sync([
12 => ['quantite' => 3, 'prix_unitaire' => 65.00],
20 => ['quantite' => 1, 'prix_unitaire' => 55.00],
]);
// Lire les donnees pivot
foreach ($commande->plats as $plat) {
echo $plat->nom . ' x' . $plat->pivot->quantite;
echo ' = ' . ($plat->prix * $plat->pivot->quantite) . ' MAD';
}
// Calculer le total d'une commande
$total = $commande->plats->sum(function ($plat) {
return $plat->prix * $plat->pivot->quantite;
});
// Filtrer sur les donnees pivot
$commande->plats()->wherePivot('quantite', '>', 1)->get();
// Liste des commandes de plus de 3 plats
Commande::has('plats', '>=', 3)->get();
// Liste des commandes avec des plats dont le prix > 100 MAD
Commande::whereHas('plats', function ($q) {
$q->where('prix', '>', 100);
})->get();
class User extends Authenticatable
{
// hasMany direct
public function commandes(): HasMany
{
return $this->hasMany(Commande::class, 'client_id');
}
// hasManyThrough — tous les plats commandes par ce client (via ses commandes)
// Client -> Commandes -> Plats (a travers la table pivot commande_plat)
public function platsCommandes(): BelongsToMany
{
// Pour hasManyThrough sur many-to-many, passer par la relation
return $this->hasManyThrough(Plat::class, Commande::class);
// Ou via une requete personnalisee
}
// Exemple hasManyThrough classique : Client -> Commandes -> Livraisons
public function livraisons(): HasManyThrough
{
return $this->hasManyThrough(Livraison::class, Commande::class);
// User hasMany Commandes, Commandes hasMany Livraisons
// => User hasManyThrough Livraisons via Commandes
}
}
// Utilisation
$client = User::find(7);
$livraisons = $client->livraisons; // Toutes les livraisons de ce client
Si on affiche 50 restaurants avec leurs plats sans eager loading : 1 requête pour les restaurants + 50 requêtes pour les plats de chaque restaurant = 51 requêtes. Sur 100 restaurants : 101 requêtes. Solution : with('plats') = 2 requêtes quelle que soit la taille.
// PROBLEME N+1 (ne pas faire)
$restaurants = Restaurant::all(); // 1 requete
foreach ($restaurants as $restaurant) {
echo $restaurant->plats->count(); // 1 requete par restaurant = N requetes
}
// Total : 1 + N requetes (ex: 1 + 50 = 51)
// SOLUTION : with() — eager loading
$restaurants = Restaurant::with('plats')->get(); // 2 requetes au total
foreach ($restaurants as $restaurant) {
echo $restaurant->plats->count(); // 0 requete supplementaire (en cache)
}
// Eager loading multiple
$restaurants = Restaurant::with(['plats', 'proprietaire', 'commandes'])->get();
// Eager loading avec contraintes
$restaurants = Restaurant::with([
'plats' => function ($query) {
$query->where('actif', true)->orderBy('prix');
},
'commandes' => function ($query) {
$query->where('statut', 'livree')->latest()->limit(5);
},
])->get();
// Eager loading imbrique (relations de relations)
$commandes = Commande::with('plats.restaurant')->get();
// Charge plats ET le restaurant de chaque plat
// Lazy eager loading (charger apres coup)
$restaurants = Restaurant::all();
$restaurants->load('plats'); // Charger apres
$restaurants->loadCount('commandes'); // Charger le count apres
// Detect N+1 en developpement
Model::preventLazyLoading(! app()->isProduction());
Restaurant::with('plats') génère combien de requêtes SQL pour 100 restaurants ?Eloquent Avancé
Accessors et Mutators (attributs calculés et transformés), Casting ($casts), Scopes locaux et globaux, SoftDeletes, sérialisation (toArray()/toJson()) et API Resources pour formater les réponses JSON de l'API DariGo.
- Accessors — attributs calculés en lecture (
get{Attr}Attribute) - Mutators — transformer avant l'enregistrement (
set{Attr}Attribute) - Casting
$casts— JSON, boolean, encrypted, hashed - Scopes locaux — réutiliser des conditions (
scopeActif()) - Scopes globaux — conditions appliquées automatiquement
- API Resources — formater les réponses JSON sans exposer la BDD
Accessor : attribut calculé en lecture. $restaurant->nom_complet combine nom + ville sans colonne en BDD. Mutator : transformer avant l'écriture. $restaurant->nom = "dar zitoun" est automatiquement mis en majuscule avant save().
use Illuminate\Database\Eloquent\Casts\Attribute;
class Restaurant extends Model
{
// Accessor : attribut calcule (pas de colonne en BDD)
// $restaurant->nom_complet retourne "Dar Zitoun — Marrakech"
protected function nomComplet(): Attribute
{
return Attribute::make(
get: fn () => $this->nom . ' — ' . $this->ville,
);
}
// Accessor + Mutator combines sur la meme propriete
// $restaurant->nom = "dar zitoun" → stocke "Dar Zitoun" (ucwords)
protected function nom(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucwords(strtolower($value)), // Lecture : ucwords
set: fn (string $value) => ucwords(strtolower($value)), // Ecriture : ucwords
);
}
// Accessor disponibilite (basee sur le champ actif + horaires)
protected function estOuvert(): Attribute
{
return Attribute::make(
get: function () {
if (! $this->actif) return false;
if (! $this->horaires) return true;
$maintenant = now()->format('H:i');
$jour = strtolower(now()->locale('fr')->isoFormat('dddd'));
$horaire = $this->horaires[$jour] ?? null;
if (! $horaire) return false;
[$ouv, $ferm] = explode('-', $horaire);
return $maintenant >= $ouv && $maintenant <= $ferm;
}
);
}
}
// Utilisation
$restaurant = Restaurant::find(5);
echo $restaurant->nom_complet; // "Dar Zitoun — Marrakech"
echo $restaurant->est_ouvert; // true/false selon l'heure
$restaurant->nom = 'dar zitoun'; // Stocke "Dar Zitoun"
class Plat extends Model
{
protected $appends = ['disponible', 'prix_formate'];
// Accessible via $plat->disponible
protected function disponible(): Attribute
{
return Attribute::make(
get: fn () => $this->stock > 0 && $this->actif,
);
}
// Formater le prix avec le symbole MAD
protected function prixFormate(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->prix, 2) . ' MAD',
);
}
}
Sans Scope : ->where('actif', true) est répété dans chaque Controller. Avec un Scope local scopeActif() : Restaurant::actif()->get() partout. Le code de la condition n'est écrit qu'une fois, dans le modèle.
class Restaurant extends Model
{
// ---- SCOPES LOCAUX ----
// Restaurant::actif()->get()
public function scopeActif(Builder $query): Builder
{
return $query->where('actif', true);
}
// Restaurant::dansVille('Casablanca')->get()
public function scopeDansVille(Builder $query, string $ville): Builder
{
return $query->where('ville', $ville);
}
// Restaurant::bienNote(4.0)->get()
public function scopeBienNote(Builder $query, float $seuil = 3.5): Builder
{
return $query->where('note', '>=', $seuil);
}
// Restaurant::deCategorie('tajine')->get()
public function scopeDeCategorie(Builder $query, string $categorie): Builder
{
return $query->where('categorie', $categorie);
}
}
class Plat extends Model
{
// Plat::disponible()->get()
public function scopeDisponible(Builder $query): Builder
{
return $query->where('actif', true)->where('stock', '>', 0);
}
// Plat::abordable(50)->get() — prix <= 50 MAD
public function scopeAbordable(Builder $query, float $max = 100): Builder
{
return $query->where('prix', '<=', $max);
}
}
// Utilisation enchainees — tres lisibles
$restaurants = Restaurant::actif()
->dansVille('Casablanca')
->bienNote(4.0)
->deCategorie('tajine')
->withCount('plats')
->paginate(12);
$plats = Plat::disponible()->abordable(80)->orderBy('prix')->get();
Sans Resource : return response()->json($restaurant) expose toutes les colonnes de la BDD (y compris proprietaire_id, deleted_at...). Avec Resource : on contrôle exactement quels champs sont exposés, on peut ajouter des champs calculés, renommer des clés, conditionner les champs selon le rôle.
php artisan make:resource RestaurantResource
php artisan make:resource PlatResource
php artisan make:resource CommandeResource
php artisan make:resource RestaurantCollection # Collection avec metadata
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class RestaurantResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'nom' => $this->nom,
'slug' => $this->slug,
'description' => $this->description,
'ville' => $this->ville,
'adresse' => $this->adresse,
'telephone' => $this->telephone,
'categorie' => $this->categorie,
'note' => round($this->note, 1),
'nb_avis' => $this->nb_avis,
'actif' => $this->actif,
'est_ouvert' => $this->est_ouvert, // Accessor
// Inclure les plats seulement si charges (when loaded)
'plats' => PlatResource::collection($this->whenLoaded('plats')),
'nb_plats' => $this->when(
$this->relationLoaded('plats'),
fn() => $this->plats->count()
),
// Champs visibles uniquement par l'admin
'proprietaire' => $this->when(
auth()->user()?->role === 'admin',
fn() => new UserResource($this->whenLoaded('proprietaire'))
),
'created_at' => $this->created_at?->toDateString(),
];
}
}
// Utilisation dans un Controller
public function show(Restaurant $restaurant): RestaurantResource
{
$restaurant->load('plats');
return new RestaurantResource($restaurant);
// JSON : {"data": {"id":5, "nom":"Dar Zitoun", "plats":[...], ...}}
}
public function index(): AnonymousResourceCollection
{
$restaurants = Restaurant::actif()->with('plats')->paginate(12);
return RestaurantResource::collection($restaurants);
// JSON avec pagination : {"data":[...], "links":{...}, "meta":{...}}
}
$this->whenLoaded('plats') retourne quoi si les plats n'ont pas été chargés avec with('plats') ?scopeActif($query). Comment l'appeler dans un Controller ?PHPUnit — Setup & Premiers tests
Configurer l'environnement de test Laravel — phpunit.xml, .env.testing, SQLite en mémoire, RefreshDatabase, setUp() et tearDown(), types de tests (Unit vs Feature), assertions de base et exécution.
- Pourquoi tester — confiance, régression, documentation vivante
- Unit vs Feature tests — quelle différence, quand choisir
- Configurer
phpunit.xmlet.env.testing(BDD SQLite en mémoire) - Trait
RefreshDatabase— BDD fraîche à chaque test - Méthodes
setUp()ettearDown() - Assertions de base :
assertTrue,assertEquals,assertNull - Exécuter les tests :
php artisan test, filtres, couverture
Sans tests : modifier le code de création de commande DariGo casse silencieusement le calcul du total — découvert en production par un client. Avec tests : php artisan test détecte la régression en 3 secondes avant le déploiement. Les tests sont une assurance, pas un luxe.
| Bénéfice | Sans tests | Avec tests |
|---|---|---|
| Régression | Un bug cassé réapparaît sans qu'on le sache | Détecté immédiatement à chaque php artisan test |
| Confiance | Déployer en production = stress | Si les tests passent, on peut déployer sereinement |
| Documentation | Comportement attendu à deviner depuis le code | Chaque test décrit un comportement attendu lisible |
| Refactoring | Dangereux — on ne sait pas ce qu'on casse | Sécurisé — les tests valident après chaque changement |
| Type | Dossier | Ce qu'il teste | Exemple DariGo |
|---|---|---|---|
| Unit Test | tests/Unit/ | Une classe ou méthode isolée — sans BDD, sans HTTP | Calcul du total d'une commande, formatage du prix |
| Feature Test | tests/Feature/ | Une fonctionnalité complète — avec BDD, HTTP, auth | POST /api/commandes retourne 201 avec le bon JSON |
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
stopOnFailure="false">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<!-- BDD SQLite en memoire : rapide, isolee, detruite apres les tests -->
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<!-- Cache tableau : pas de Redis requis pour les tests -->
<env name="CACHE_STORE" value="array"/>
<!-- Queues synchrones : les jobs s'executent immediatement -->
<env name="QUEUE_CONNECTION" value="sync"/>
<!-- Mail : intercepter sans envoyer -->
<env name="MAIL_MAILER" value="array"/>
<!-- Bcrypt rapide pour les tests (4 rounds au lieu de 12) -->
<env name="BCRYPT_ROUNDS" value="4"/>
<!-- Sessions en tableau -->
<env name="SESSION_DRIVER" value="array"/>
</php>
</phpunit>
:memory: ?SQLite en mémoire crée une BDD temporaire dans la RAM, sans fichier sur disque. Avantages : les migrations s'exécutent en quelques millisecondes (vs secondes avec MySQL), chaque suite de tests repart d'une BDD vide, et il n'y a aucun risque de polluer la BDD de développement. Inconvénient : les fonctions MySQL spécifiques (FULLTEXT, JSON_EXTRACT) ne sont pas disponibles.
php artisan make:test RestaurantTest # Feature test
php artisan make:test CommandeTest # Feature test
php artisan make:test CalculTotalTest --unit # Unit test
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class CalculTotalTest extends TestCase
{
// Un Unit test n'etend pas Tests\TestCase mais PHPUnit\Framework\TestCase
// Pas de BDD, pas de HTTP, pas de Laravel — ultra rapide
public function test_calcul_total_commande(): void
{
$lignes = [
['prix' => 65.00, 'quantite' => 2],
['prix' => 45.00, 'quantite' => 1],
];
$total = collect($lignes)->sum(fn($l) => $l['prix'] * $l['quantite']);
$this->assertEquals(175.00, $total);
}
public function test_total_commande_vide(): void
{
$total = collect([])->sum(fn($l) => $l['prix'] * $l['quantite']);
$this->assertEquals(0, $total);
}
}
<?php
namespace Tests\Feature;
use App\Models\Restaurant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class RestaurantTest extends TestCase
{
use RefreshDatabase;
// RefreshDatabase : reexecute toutes les migrations avant chaque test
// La BDD repart de zero a chaque methode de test
// Donnees partagees entre les methodes du test
private User $admin;
private User $client;
private User $restaurateur;
// setUp() : execute AVANT chaque methode de test
protected function setUp(): void
{
parent::setUp(); // Toujours appeler parent::setUp()
$this->admin = User::factory()->create(['role' => 'admin']);
$this->client = User::factory()->create(['role' => 'client']);
$this->restaurateur = User::factory()->create(['role' => 'restaurateur']);
}
// tearDown() : execute APRES chaque methode de test
// (rarement necessaire avec RefreshDatabase)
protected function tearDown(): void
{
// Nettoyer des fichiers, fermer des connexions...
parent::tearDown();
}
/** @test */
public function un_client_peut_voir_la_liste_des_restaurants(): void
{
Restaurant::factory(5)->create(['actif' => true]);
$response = $this->actingAs($this->client, 'sanctum')
->getJson('/api/restaurants');
$response->assertStatus(200)
->assertJsonCount(5, 'data');
}
/** @test */
public function un_restaurateur_peut_creer_un_restaurant(): void
{
$donnees = [
'nom' => 'Dar Zitoun Test',
'ville' => 'Marrakech',
'adresse' => '14 Rue Hassan II',
'categorie' => 'marocain',
];
$response = $this->actingAs($this->restaurateur, 'sanctum')
->postJson('/api/restaurants', $donnees);
$response->assertStatus(201)
->assertJsonFragment(['nom' => 'Dar Zitoun Test']);
}
/** @test */
public function un_client_ne_peut_pas_creer_un_restaurant(): void
{
$response = $this->actingAs($this->client, 'sanctum')
->postJson('/api/restaurants', ['nom' => 'Faux restaurant']);
$response->assertStatus(403);
}
}
# Tous les tests
php artisan test
# Avec affichage detaille
php artisan test --verbose
# Filtrer par nom de methode
php artisan test --filter=un_client_peut_voir_la_liste
# Filtrer par classe
php artisan test --filter=RestaurantTest
# Suite specifique (Unit ou Feature)
php artisan test --testsuite=Feature
php artisan test --testsuite=Unit
# Stopper au premier echec
php artisan test --stop-on-failure
# En parallele (plus rapide sur les grands projets)
php artisan test --parallel
# Couverture de code (necessite Xdebug ou PCOV)
php artisan test --coverage
php artisan test --coverage --min=80 # Echoue si < 80% de couverture
RefreshDatabase. Les données créées dans setUp() sont-elles disponibles dans les deux méthodes de test qui suivent ?BCRYPT_ROUNDS=4 est configuré dans phpunit.xml au lieu de 12 ?Tests HTTP & Feature
Tester les routes API de DariGo — getJson/postJson/putJson/deleteJson, assertions HTTP (assertStatus, assertJson, assertJsonStructure), authentification avec actingAs(), et tester toutes les règles de validation.
- Méthodes HTTP :
getJson(),postJson(),putJson(),patchJson(),deleteJson() - Assertions HTTP :
assertStatus(),assertOk(),assertCreated(),assertUnauthorized() - Assertions JSON :
assertJson(),assertJsonFragment(),assertJsonStructure(),assertJsonCount() - Authentification :
actingAs($user, 'sanctum') - Tester la validation : s'assurer que les erreurs 422 sont bien retournées
- Tester les autorisations : 403 quand le mauvais rôle accède
// Les methodes *Json ajoutent automatiquement :
// Accept: application/json
// Content-Type: application/json
$response = $this->getJson('/api/restaurants');
$response = $this->postJson('/api/restaurants', ['nom' => 'Test']);
$response = $this->putJson('/api/restaurants/5', ['nom' => 'Modifie']);
$response = $this->patchJson('/api/commandes/3/statut', ['statut' => 'confirmee']);
$response = $this->deleteJson('/api/restaurants/5');
// Assertions de statut HTTP
$response->assertStatus(200); // Code exact
$response->assertOk(); // 200
$response->assertCreated(); // 201
$response->assertNoContent(); // 204
$response->assertBadRequest(); // 400
$response->assertUnauthorized(); // 401
$response->assertForbidden(); // 403
$response->assertNotFound(); // 404
$response->assertUnprocessable(); // 422 (validation)
$response->assertServerError(); // 500
$response = $this->actingAs($this->client, 'sanctum')
->postJson('/api/commandes', [
'restaurant_id' => $this->restaurant->id,
'lignes' => [
['plat_id' => $this->plat->id, 'quantite' => 2],
],
]);
// assertJson() : verifie que la reponse CONTIENT exactement ces paires cle/valeur
$response->assertJson([
'message' => 'Commande creee',
'statut' => 'en_attente',
]);
// assertJsonFragment() : verifie qu'un fragment existe QUELQUE PART dans le JSON
// (plus souple — fonctionne dans des tableaux imbriques)
$response->assertJsonFragment(['statut' => 'en_attente']);
// assertJsonStructure() : verifie la STRUCTURE (cles) sans verifier les valeurs
$response->assertJsonStructure([
'message',
'commandeId',
'total',
'statut',
]);
// Structure imbriquee (plats avec sous-cles)
$this->getJson('/api/restaurants/' . $this->restaurant->id)
->assertJsonStructure([
'data' => [
'id', 'nom', 'ville',
'plats' => [
'*' => ['id', 'nom', 'prix', 'disponible']
],
],
]);
// assertJsonCount() : compter les elements d'un tableau JSON
$this->getJson('/api/restaurants')
->assertJsonCount(5, 'data'); // 5 restaurants dans data[]
// assertJsonPath() : verifier une valeur a un chemin precis
$response->assertJsonPath('data.nom', 'Dar Zitoun');
$response->assertJsonPath('data.plats.0.prix', 65.00);
// assertJsonMissing() : verifier qu'une cle N'EXISTE PAS
$response->assertJsonMissing(['proprietaire_id']); // Pas expose dans l'API
<?php
namespace Tests\Feature;
use App\Models\{Commande, Plat, Restaurant, User};
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CommandeTest extends TestCase
{
use RefreshDatabase;
private User $client;
private User $restaurateur;
private User $livreur;
private User $admin;
private Restaurant $restaurant;
private Plat $plat;
protected function setUp(): void
{
parent::setUp();
$this->client = User::factory()->create(['role' => 'client']);
$this->restaurateur = User::factory()->create(['role' => 'restaurateur']);
$this->livreur = User::factory()->create(['role' => 'livreur']);
$this->admin = User::factory()->create(['role' => 'admin']);
$this->restaurant = Restaurant::factory()->create([
'actif' => true,
'proprietaire_id' => $this->restaurateur->id,
]);
$this->plat = Plat::factory()->create([
'restaurant_id' => $this->restaurant->id,
'prix' => 65.00,
'stock' => 10,
'actif' => true,
]);
}
// ── Tests CRUD ────────────────────────────────────────
/** @test */
public function un_client_peut_creer_une_commande(): void
{
$response = $this->actingAs($this->client, 'sanctum')
->postJson('/api/commandes', [
'restaurant_id' => $this->restaurant->id,
'lignes' => [
['plat_id' => $this->plat->id, 'quantite' => 2],
],
]);
$response->assertCreated()
->assertJsonStructure(['commandeId', 'total', 'statut'])
->assertJsonPath('statut', 'en_attente');
// Verifier que le total est correct (2 * 65 MAD)
$this->assertEquals(130.00, $response->json('total'));
}
/** @test */
public function un_utilisateur_non_connecte_ne_peut_pas_commander(): void
{
$this->postJson('/api/commandes', [
'restaurant_id' => $this->restaurant->id,
'lignes' => [['plat_id' => $this->plat->id, 'quantite' => 1]],
])->assertUnauthorized();
}
/** @test */
public function un_livreur_ne_peut_pas_creer_une_commande(): void
{
$this->actingAs($this->livreur, 'sanctum')
->postJson('/api/commandes', [
'restaurant_id' => $this->restaurant->id,
'lignes' => [['plat_id' => $this->plat->id, 'quantite' => 1]],
])->assertForbidden();
}
/** @test */
public function un_client_voit_uniquement_ses_propres_commandes(): void
{
// Creer commandes pour deux clients differents
$autreClient = User::factory()->create(['role' => 'client']);
Commande::factory(3)->create(['client_id' => $this->client->id]);
Commande::factory(2)->create(['client_id' => $autreClient->id]);
$response = $this->actingAs($this->client, 'sanctum')
->getJson('/api/commandes');
$response->assertOk()
->assertJsonCount(3, 'commandes');
}
/** @test */
public function impossible_de_commander_un_plat_hors_stock(): void
{
$this->plat->update(['stock' => 0]); // Vider le stock
$this->actingAs($this->client, 'sanctum')
->postJson('/api/commandes', [
'restaurant_id' => $this->restaurant->id,
'lignes' => [['plat_id' => $this->plat->id, 'quantite' => 1]],
])->assertStatus(409)
->assertJsonFragment(['erreur' => 'Stock insuffisant']);
}
// ── Tests de validation ───────────────────────────────
/** @test */
public function la_validation_echoue_sans_restaurant_id(): void
{
$this->actingAs($this->client, 'sanctum')
->postJson('/api/commandes', [
// restaurant_id manquant
'lignes' => [['plat_id' => $this->plat->id, 'quantite' => 1]],
])->assertUnprocessable()
->assertJsonValidationErrors(['restaurant_id']);
}
/** @test */
public function la_validation_echoue_avec_quantite_invalide(): void
{
$this->actingAs($this->client, 'sanctum')
->postJson('/api/commandes', [
'restaurant_id' => $this->restaurant->id,
'lignes' => [['plat_id' => $this->plat->id, 'quantite' => 0]],
])->assertUnprocessable()
->assertJsonValidationErrors(['lignes.0.quantite']);
}
}
assertJson(['statut' => 'en_attente']) vs assertJsonFragment(['statut' => 'en_attente']) — quelle est la différence ?$this->actingAs($livreur, 'sanctum')->postJson('/api/restaurants', [...]). La route est protégée par middleware('role:restaurateur,admin'). Quel code HTTP est attendu ?Tests BDD & Mocking
Vérifier les états de la BDD après une action (assertDatabaseHas, assertDatabaseMissing), intercepter les mails, jobs et événements sans les envoyer (Mail::fake(), Queue::fake(), Event::fake()) et utiliser les Factories dans les tests.
assertDatabaseHas()— vérifier qu'un enregistrement existe en BDDassertDatabaseMissing()— vérifier qu'un enregistrement n'existe plusassertDatabaseCount()— compter les enregistrementsMail::fake()— intercepter les emails sans les envoyerQueue::fake()— vérifier qu'un job a bien été mis en queueEvent::fake()— vérifier qu'un événement a bien été déclenché- Factories dans les tests — states, sequences, relations
/** @test */
public function register_cree_un_utilisateur_en_base(): void
{
$this->postJson('/api/auth/register', [
'prenom' => 'Karim',
'nom' => 'Alaoui',
'email' => 'karim@darigo.ma',
'telephone' => '0612345678',
'password' => 'Karim2024!',
'password_confirmation' => 'Karim2024!',
'ville' => 'Casablanca',
])->assertCreated();
// Verifier que l'utilisateur est en BDD
$this->assertDatabaseHas('users', [
'email' => 'karim@darigo.ma',
'prenom' => 'Karim',
'role' => 'client',
]);
// Verifier que le mot de passe N'EST PAS stocke en clair
$this->assertDatabaseMissing('users', [
'password' => 'Karim2024!',
]);
}
/** @test */
public function supprimer_un_restaurant_le_soft_delete(): void
{
$restaurant = Restaurant::factory()->create();
$this->actingAs($this->admin, 'sanctum')
->deleteJson('/api/restaurants/' . $restaurant->id)
->assertOk();
// Soft delete : toujours en BDD mais avec deleted_at
$this->assertSoftDeleted('restaurants', ['id' => $restaurant->id]);
// assertDatabaseHas ne retrouve PAS les soft-deleted par defaut
$this->assertDatabaseMissing('restaurants', [
'id' => $restaurant->id,
'deleted_at' => null,
]);
}
/** @test */
public function creer_commande_decremente_le_stock(): void
{
$this->plat->update(['stock' => 5]);
$this->actingAs($this->client, 'sanctum')
->postJson('/api/commandes', [
'restaurant_id' => $this->restaurant->id,
'lignes' => [['plat_id' => $this->plat->id, 'quantite' => 2]],
])->assertCreated();
// Verifier que le stock a diminue
$this->assertDatabaseHas('plats', [
'id' => $this->plat->id,
'stock' => 3, // 5 - 2 = 3
]);
}
/** @test */
public function logout_revoque_le_token_sanctum(): void
{
$token = $this->client->createToken('test')->plainTextToken;
$this->withToken($token)
->postJson('/api/auth/logout')
->assertOk();
// Verifier que le token est supprime de la BDD
$this->assertDatabaseCount('personal_access_tokens', 0);
}
/** @test */
public function la_pagination_retourne_le_bon_nombre(): void
{
Restaurant::factory(15)->create(['actif' => true]);
$response = $this->actingAs($this->client, 'sanctum')
->getJson('/api/restaurants?per_page=10');
$response->assertOk()
->assertJsonCount(10, 'data')
->assertJsonPath('meta.total', 15)
->assertJsonPath('meta.last_page', 2);
}
Les tests ne doivent pas envoyer de vrais emails ni de vrais SMS — coûteux, lents et imprévisibles. Mail::fake() intercepte les emails en mémoire. On peut ensuite vérifier qu'un email a bien été envoyé sans jamais toucher un serveur SMTP. Même principe pour Queue::fake() et Event::fake().
use App\Mail\ConfirmationCommandeMail;
use Illuminate\Support\Facades\Mail;
/** @test */
public function creer_commande_envoie_un_email_de_confirmation(): void
{
Mail::fake(); // Intercepter tous les emails
$response = $this->actingAs($this->client, 'sanctum')
->postJson('/api/commandes', [
'restaurant_id' => $this->restaurant->id,
'lignes' => [['plat_id' => $this->plat->id, 'quantite' => 1]],
])->assertCreated();
// Verifier qu'un email a ete envoye
Mail::assertSent(ConfirmationCommandeMail::class);
// Verifier le destinataire et le contenu
Mail::assertSent(ConfirmationCommandeMail::class, function ($mail) {
return $mail->hasTo($this->client->email);
});
// Verifier qu'UN SEUL email a ete envoye
Mail::assertSentCount(1);
// Verifier qu'aucun autre email n'a ete envoye
Mail::assertNotSent(\App\Mail\AlerteAdminMail::class);
}
use App\Events\CommandeCreee;
use App\Jobs\TraiterPaiementLivraison;
use Illuminate\Support\Facades\{Event, Queue};
/** @test */
public function creer_commande_dispatche_le_bon_job(): void
{
Queue::fake(); // Intercepter les jobs — ne pas les executer
$this->actingAs($this->client, 'sanctum')
->postJson('/api/commandes', [
'restaurant_id' => $this->restaurant->id,
'lignes' => [['plat_id' => $this->plat->id, 'quantite' => 1]],
])->assertCreated();
// Verifier que le job a bien ete mis en queue
Queue::assertPushed(TraiterPaiementLivraison::class);
// Verifier le contenu du job
Queue::assertPushed(TraiterPaiementLivraison::class, function ($job) {
return $job->commande->client_id === $this->client->id;
});
// Verifier la file d'attente specifique
Queue::assertPushedOn('paiements', TraiterPaiementLivraison::class);
}
/** @test */
public function creer_commande_declenche_levenement_commande_creee(): void
{
Event::fake(); // Intercepter les evenements
$this->actingAs($this->client, 'sanctum')
->postJson('/api/commandes', [
'restaurant_id' => $this->restaurant->id,
'lignes' => [['plat_id' => $this->plat->id, 'quantite' => 1]],
])->assertCreated();
// Verifier que l'evenement a ete dispatche
Event::assertDispatched(CommandeCreee::class);
// Verifier les donnees de l'evenement
Event::assertDispatched(CommandeCreee::class, function ($event) {
return $event->commande->client_id === $this->client->id;
});
// Verifier qu'aucun autre evenement n'a ete declenche
Event::assertNotDispatched(\App\Events\CommandeAnnulee::class);
}
/** @test */
public function notification_est_envoyee_quand_statut_livraison_change(): void
{
\Notification::fake();
$commande = Commande::factory()->create(['client_id' => $this->client->id]);
$this->actingAs($this->admin, 'sanctum')
->patchJson('/api/commandes/' . $commande->id . '/statut', ['statut' => 'livree'])
->assertOk();
\Notification::assertSentTo(
$this->client,
\App\Notifications\CommandeLivreeNotification::class
);
}
// States — variantes semantiques
$admin = User::factory()->admin()->create();
$restaurateur = User::factory()->restaurateur()->create();
$livreur = User::factory()->livreur()->create();
// Plusieurs utilisateurs avec des roles differents en sequence
$users = User::factory()->count(3)->sequence(
['role' => 'client'],
['role' => 'restaurateur'],
['role' => 'livreur'],
)->create();
// Relations — creer un restaurant avec 5 plats
$restaurant = Restaurant::factory()
->has(Plat::factory()->count(5), 'plats')
->create();
// has() avec state
$restaurant = Restaurant::factory()
->has(Plat::factory()->count(3)->state(['actif' => false, 'stock' => 0]), 'plats')
->create();
// for() — creer un plat pour un restaurant existant
$plat = Plat::factory()
->for($this->restaurant)
->create(['prix' => 45.00, 'stock' => 20]);
// Creer 10 commandes avec leurs lignes
Commande::factory()
->count(10)
->for($this->client, 'client')
->for($this->restaurant, 'restaurant')
->create()
->each(function ($commande) {
// Attacher des plats a chaque commande
$commande->plats()->attach(
$this->plat->id,
['quantite' => rand(1, 3), 'prix_unitaire' => $this->plat->prix]
);
});
Queue::fake() est appelé dans un test. Un job est dispatched. Est-ce que le job s'exécute réellement ?assertDatabaseHas('users', ['password' => 'Karim2024!']) échoue après un register. Pourquoi ?CMS — Définition & Panorama
Qu'est-ce qu'un CMS, comment il fonctionne (front office / back office), comparaison des CMS PHP majeurs (WordPress, Joomla, Drupal, PrestaShop) et quand choisir un CMS plutôt que Laravel.
- Définition d'un CMS — séparation contenu / présentation
- Architecture CMS — front office, back office, WYSIWYG
- Comparatif WordPress / Joomla / Drupal / PrestaShop
- CMS vs Framework Laravel — critères de choix
- Part de marché et écosystème WordPress au Maroc
Un CMS est un logiciel qui permet de créer et de mettre à jour un site web sans écrire de code. La grande innovation des CMS est la séparation entre le contenu (textes, images, articles) et la forme (mise en page, couleurs, typographie). Le rédacteur se concentre sur le contenu, le designer sur l'apparence — sans que l'un bloque l'autre.
| Composant | Rôle | Acces | Exemple WordPress |
|---|---|---|---|
| Front Office | Ce que voient les visiteurs — le site public | Tout le monde | https://monsite.ma/ |
| Back Office | Interface d'administration — gérer les contenus | Administrateurs et éditeurs | https://monsite.ma/wp-admin |
| WYSIWYG | Éditeur visuel — What You See Is What You Get | Rédacteurs | Gutenberg (WordPress) |
| Base de données | Stockage des contenus, utilisateurs, paramètres | Système | MySQL — tables wp_posts, wp_users... |
Comment fonctionne un CMS ?
- L'administrateur se connecte au back office via le navigateur (pas d'application à installer)
- Il rédige un article via l'éditeur WYSIWYG — sans écrire de HTML
- Le CMS stocke le contenu dans MySQL
- Un visiteur demande la page — le CMS assemble le contenu et le thème graphique
- Le HTML final est envoyé au navigateur
| CMS | Langage | Part de marché | Points forts | Cas d'usage idéal | Au Maroc |
|---|---|---|---|---|---|
| WordPress | PHP | 43% du Web mondial | Communauté énorme, 60 000+ plugins, simple | Blog, site vitrine, magazine, e-commerce simple | Très utilisé — agences, startups, médias |
| Joomla | PHP | ~2% | Plus flexible que WordPress, ACL avancé | Portails, intranets, sites communautaires | En déclin |
| Drupal | PHP | ~1.3% | Très robuste, sécurité entreprise | Gouvernements, grandes organisations | Sites gouvernementaux marocains |
| PrestaShop | PHP | Leader e-commerce francophone | E-commerce complet, multi-devises, multi-langues | Boutiques en ligne | Très populaire — commerce marocain en ligne |
| Shopify | Ruby/SaaS | — | Hébergé, zéro maintenance | E-commerce rapide | Croissance rapide |
WordPress est open source, gratuit et dispose d'une communauté mondiale — plus de 60 000 plugins et 10 000 thèmes. Sa prise en main est rapide : un site professionnel peut être en ligne en quelques heures. Au Maroc, la majorité des agences web livrent leurs clients sur WordPress — sites vitrines pour PME casablancaises, blogs, magazines en ligne (Medias24, Hespress ont utilisé WordPress). Ses mises à jour automatiques intègrent des corrections de sécurité régulières.
| Critère | CMS (WordPress) | Framework (Laravel) |
|---|---|---|
| Time to market | Très rapide — quelques heures | Plus long — tout à construire |
| Personnalisation | Limitée aux thèmes et plugins | Totale — contrôle absolu |
| Compétences requises | Faibles — interface graphique | Élevées — PHP, BDD, API |
| Coût de maintenance | Faible — mises à jour auto | Élevé — développeur requis |
| Performance | Variable selon les plugins | Optimisable précisément |
| Sécurité | Surface d'attaque large (plugins tiers) | Maîtrisée par le développeur |
| API REST | Possible (WP REST API) mais limité | Natif et optimal |
| Logique métier complexe | Difficile — contournements | Idéal — code structuré |
Choisir WordPress : site vitrine d'entreprise, blog, magazine, portfolio, e-commerce standard — contenu géré par un non-développeur, budget réduit, délai court. Choisir Laravel : plateforme DariGo, SaaS, application métier sur mesure, API consommée par mobile, logique complexe (commandes, paiements, rôles).
Installation & Administration WordPress
Installer WordPress en local (XAMPP), créer la base de données MySQL, configurer wp-config.php, découvrir le tableau de bord et les tables BDD (wp_posts, wp_users...), gérer les utilisateurs et leurs rôles.
- Télécharger WordPress et le placer dans le répertoire XAMPP
htdocs/ - Créer une base de données MySQL pour WordPress
- Configurer
wp-config.php— connexion BDD, clés de sécurité - Structure des tables WordPress — wp_posts, wp_users, wp_options...
- Tableau de bord — navigation, rôles des utilisateurs
- Rôles WordPress — Administrateur, Éditeur, Auteur, Contributeur, Abonné
Étapes d'installation
- Télécharger WordPress :
wordpress.org/download— archive ZIP (~20 Mo) - Démarrer XAMPP : Apache + MySQL doivent être actifs (voyant vert)
- Placer WordPress dans htdocs/ : décompresser le ZIP dans
C:\xampp\htdocs\mon-site-ma\ - Créer la BDD MySQL : phpMyAdmin (
localhost/phpmyadmin) → Nouvelle base →monsite_db→ utf8mb4_unicode_ci - Lancer l'assistant :
http://localhost/mon-site-ma→ WordPress détecte l'absence de config et lance le wizard - Renseigner les informations BDD : nom de la base, utilisateur (root), mot de passe (vide par défaut XAMPP), hôte (localhost)
- Configurer le site : titre, identifiant admin (éviter "admin" par sécurité), mot de passe fort, email
- Se connecter au back office :
http://localhost/mon-site-ma/wp-admin
Le nom d'utilisateur "admin" est le premier essaié lors d'une attaque par force brute. Utiliser un identifiant unique (karim_darigo) et un mot de passe généré par WordPress (le bouton "Générer" crée un mot de passe fort). Activer l'authentification à deux facteurs via plugin en production.
// Connexion BDD (genere par l'assistant)
define('DB_NAME', 'monsite_db');
define('DB_USER', 'root');
define('DB_PASSWORD', '');
define('DB_HOST', 'localhost');
define('DB_CHARSET', 'utf8mb4');
// Cles de securite — regenerer sur https://api.wordpress.org/secret-key/1.1/salt/
define('AUTH_KEY', 'votre-cle-unique-1');
define('SECURE_AUTH_KEY', 'votre-cle-unique-2');
// ... (8 cles au total)
// Prefixe des tables — changer pour la securite (eviter wp_ par defaut)
$table_prefix = 'ms_'; // ms_ au lieu de wp_
// Mode debug (false en production)
define('WP_DEBUG', false);
WordPress stocke tout dans MySQL. Comprendre la structure des tables permet de faire des requêtes avancées, de migrer des données et de débugger. Toutes les tables sont préfixées par wp_ (ou le préfixe personnalisé dans wp-config.php).
| Table | Contenu | Données typiques |
|---|---|---|
wp_posts | Articles, pages, médias, menus | post_title, post_content, post_status, post_type |
wp_postmeta | Métadonnées des posts (champs personnalisés) | meta_key, meta_value |
wp_users | Comptes utilisateurs | user_login, user_email, user_pass (haché) |
wp_usermeta | Métadonnées utilisateurs (rôles, préférences) | wp_capabilities (rôle), wp_user_level |
wp_options | Paramètres du site et des plugins | blogname, siteurl, active_plugins |
wp_terms | Catégories et étiquettes | term_id, name, slug |
wp_term_taxonomy | Type de terme (category, post_tag) | taxonomy, count |
wp_comments | Commentaires des articles | comment_author, comment_content, comment_approved |
Navigation dans le tableau de bord
Le tableau de bord est accessible via /wp-admin. La colonne de gauche contient le menu de navigation principal : Articles, Médias, Pages, Commentaires, Apparence, Extensions, Utilisateurs, Réglages.
| Rôle | Capacités principales | Cas d'usage DariGo Blog |
|---|---|---|
| Administrateur | Tout — installer plugins, thèmes, gérer utilisateurs, paramètres | L'équipe technique DariGo |
| Éditeur | Gérer et publier TOUS les articles (y compris ceux des autres) | Chef de rédaction du blog DariGo |
| Auteur | Publier et gérer SES propres articles uniquement | Rédacteurs freelance |
| Contributeur | Rédiger ses articles mais ne peut PAS les publier (validation requise) | Contributeurs occasionnels |
| Abonné | Lire uniquement, accès au profil | Clients DariGo avec espace membres |
wp-config.php, on recommande de changer $table_prefix = 'wp_' en un préfixe personnalisé comme 'ms_'. Pourquoi ?Contenus & Fonctionnalités WordPress
Gérer les articles et les pages (différences, statuts, hiérarchie), catégories et étiquettes, bibliothèque de médias, menus de navigation et widgets — le tout depuis le tableau de bord.
- Articles vs Pages — différences fondamentales et cas d'usage
- Statuts de publication : Brouillon, En attente, Publié, Privé, Planifié
- Catégories (obligatoires) et Étiquettes/Tags (optionnels)
- Bibliothèque de médias — images, PDF, vidéos
- Menus de navigation — créer, ordonner, emboîter
- Widgets et zones de widgets
| Caractéristique | Article (Post) | Page |
|---|---|---|
| Type de contenu | Chronologique — actualités, blog | Statique — contenu permanent |
| Catégories/Tags | Oui — requis pour organiser | Non — pas de taxonomie |
| Hiérarchie | Non — pas de parent/enfant | Oui — page parent > page enfant |
| Affiché dans le flux | Oui — page d'accueil du blog | Non — accès par URL directe |
| Template | template single.php | template page.php ou template custom |
| Exemples DariGo Blog | "Top 10 restaurants Casablanca", "Recette tajine" | "À propos", "Contact", "Conditions d'utilisation" |
| Statut | Visibilité | Usage |
|---|---|---|
| Brouillon | Admins seulement | Article en cours de rédaction |
| En attente de relecture | Admins/Éditeurs | Soumis par un Contributeur |
| Publié | Tout le monde | En ligne — visible par les visiteurs |
| Privé | Admins/Éditeurs connectés | Contenu interne non public |
| Planifié | Publié automatiquement à une date future | Publication planifiée à 8h du matin |
| Corbeille | Personne — supprimé (30 jours avant effaçage) | Soft delete WordPress |
Catégories vs Étiquettes (Tags)
Catégories : classification principale, hiérarchique, obligatoire. Un article doit appartenir à au moins une catégorie. Exemple DariGo Blog : "Gastronomie marocaine", "Recettes", "Bons plans restaurants Casablanca".
Étiquettes (Tags) : mots-clés libres, non hiérarchiques, optionnels. Elles décrivent le contenu en détail. Exemple : "tajine", "couscous", "restaurant halal", "livraison Casablanca". Les tags améliorent le SEO et facilitent la navigation.
Bibliothèque de médias
Médias > Bibliothèque — tous les fichiers uploadés (images, PDF, vidéos). Les images sont stockées dans wp-content/uploads/année/mois/. WordPress génère automatiquement plusieurs tailles (thumbnail, medium, large, full). Le texte alternatif (alt) est indispensable pour l'accessibilité et le SEO — toujours le renseigner.
Créer et configurer un menu
- Apparence > Menus > Créer un nouveau menu (ex : "Menu principal")
- Ajouter des éléments : Pages, Articles, Catégories, Liens personnalisés
- Ordonner par glisser-déposer
- Emboîter : glisser un élément sous un autre pour créer un sous-menu
- Assigner à un emplacement : "Menu principal", "Menu pied de page"
- Enregistrer le menu
Widgets
Les widgets sont des blocs de contenu placés dans les zones définies par le thème (barre latérale, pied de page...). Widgets courants : Recherche, Articles récents, Catégories, Archives, Texte libre, Réseaux sociaux. Apparence > Widgets pour les configurer.
Thèmes & Personnalisation
Choisir et activer un thème WordPress, créer un Child Theme pour personnaliser sans perdre ses modifications lors des mises à jour, utiliser functions.php, comprendre la hiérarchie des templates et utiliser Elementor comme page builder.
- Thème WordPress — définition, structure de fichiers, installer et activer
- Hiérarchie des templates — quel fichier PHP est appelé pour quelle URL
- Child Theme — pourquoi, comment créer,
style.css+functions.php functions.php— hooks, actions, filtres WordPress- Elementor — page builder visuel par glisser-déposer
- Personnaliser l'apparence via le Personnalisateur WordPress
Un thème est un ensemble de fichiers PHP, CSS et JavaScript qui définit l'apparence du site. Il sépare la présentation du contenu. Changer de thème change l'apparence sans toucher aux articles ni aux pages. Les thèmes sont stockés dans wp-content/themes/. WordPress propose des milliers de thèmes gratuits sur wordpress.org/themes et des thèmes premium sur Themeforest, Elegant Themes...
wp-content/themes/mon-theme/
├── style.css # Obligatoire — en-tête du thème (nom, version, auteur)
├── index.php # Obligatoire — template par défaut
├── functions.php # Fonctions, hooks, scripts, styles
├── header.php # En-tête du site (menu, logo)
├── footer.php # Pied de page
├── sidebar.php # Barre latérale
├── single.php # Template d'un article
├── page.php # Template d'une page
├── archive.php # Template des archives (catégorie, date)
└── screenshot.png # Apercu dans l'admin WordPress
Thème Astra — le plus populaire
Astra est le thème WordPress gratuit le plus installé (> 1 million d'installations actives, note 4.9/5). Il est ultra-léger (< 50 Ko), compatible avec Elementor et WooCommerce, et dispose d'une option Responsive intégrée. Pour l'installer : Apparence > Thèmes > Ajouter > Rechercher "Astra" > Installer > Activer.
Si on modifie directement les fichiers du thème Astra et qu'une mise à jour est disponible, toutes les modifications sont écrasées. Solution : créer un Child Theme. Le Child Theme hérite du parent mais ses fichiers ne sont jamais touchés par les mises à jour.
/*
Theme Name: Astra Child DariGo
Template: astra
Description: Child theme d'Astra pour DariGo Blog
Author: Equipe DariGo
Version: 1.0
*/
/* Personnalisations CSS ici — non ecrasees par les mises a jour d'Astra */
:root {
--darigo-rouge: #e63946;
--darigo-vert: #2a9d8f;
}
.site-header {
background-color: var(--darigo-rouge);
}
.entry-title a {
color: var(--darigo-vert);
}
.btn-commander {
background: var(--darigo-rouge);
color: white;
padding: 10px 20px;
border-radius: 4px;
}
<?php
// Charger les styles du theme parent (Astra) ET du child theme
add_action('wp_enqueue_scripts', function () {
// Charger le CSS du parent
wp_enqueue_style(
'astra-parent-style',
get_template_directory_uri() . '/style.css'
);
// Charger le CSS du child (avec le parent comme dependance)
wp_enqueue_style(
'astra-child-style',
get_stylesheet_directory_uri() . '/style.css',
['astra-parent-style'],
wp_get_theme()->get('Version')
);
});
// Ajouter une fonctionnalite : lien "Commander sur DariGo" apres chaque article
add_filter('the_content', function ($contenu) {
if (is_single()) {
$lien = get_post_meta(get_the_ID(), 'darigo_restaurant_url', true);
if ($lien) {
$contenu .= '<a class="btn-commander" href="' . esc_url($lien) . '">
Commander sur DariGo
</a>';
}
}
return $contenu;
});
Elementor est le page builder le plus populaire de WordPress (> 10 millions d'installations). Il permet de créer des mises en page complexes par glisser-déposer, sans écrire de CSS. Compatible avec tous les thèmes et gratuit en version de base.
Concepts Elementor
- Section : conteneur horizontal principal — 1, 2 ou 3 colonnes
- Colonne : division vertical d'une section
- Widget : élément de contenu (Titre, Image, Bouton, Formulaire, Galerie, Map...)
- Style global : couleurs et typographie du site entière
- Responsive : Elementor gère l'affichage mobile/tablette/desktop séparément
Installer et activer Elementor
Extensions > Ajouter > Rechercher "Elementor" > Installer > Activer. Ensuite, éditer une page > "Modifier avec Elementor". L'éditeur plein écran s'ouvre avec le panneau de widgets à gauche et la prévisualisation à droite.
single.php du thème Astra directement. Une mise à jour d'Astra est installée. Que se passe-t-il ?Plugins & Déploiement
Installer et utiliser les plugins essentiels (SEO, sécurité, performance, formulaires), créer un plugin simple avec les hooks WordPress, et déployer un site WordPress sur un hébergeur marocain ou OVH.
- Installer, activer et configurer un plugin depuis le répertoire WordPress
- Plugins essentiels — Yoast SEO, Wordfence, WP Rocket, Contact Form 7
- Structure d'un plugin simple — header,
add_action,add_shortcode - Hébergement — mutualisé vs VPS, coûts au Maroc
- Déploiement — FTP, cPanel, migration local → production
- SSL, sauvegardes automatiques, sécurité de base
| Plugin | Catégorie | Rôle | Gratuit ? |
|---|---|---|---|
| Yoast SEO | SEO | Optimiser les balises meta, sitemap XML, analyse readability | Gratuit (Pro payant) |
| Wordfence Security | Sécurité | Pare-feu, scan malware, blocage IP, authentification 2FA | Gratuit (Premium payant) |
| WP Rocket | Performance | Cache de pages, minification CSS/JS, lazy loading images | Payant (~49$/an) |
| W3 Total Cache | Performance | Alternative gratuite à WP Rocket | Gratuit |
| Contact Form 7 | Formulaires | Créer des formulaires de contact configurable | Gratuit |
| UpdraftPlus | Sauvegarde | Sauvegardes automatiques vers Google Drive, Dropbox | Gratuit (Premium payant) |
| WooCommerce | E-commerce | Transformer WordPress en boutique en ligne | Gratuit |
| Smush | Médias | Compression automatique des images sans perte de qualité | Gratuit |
Chaque plugin ajoute du code PHP exécuté à chaque page. 50 plugins actifs = site lent et vulnérable aux conflits. Règle : installer uniquement les plugins nécessaires, vérifier la dernière mise à jour (< 1 an), le nombre d'installations actives (> 10 000) et les évaluations (> 4/5).
<?php
/**
* Plugin Name: DariGo Widget
* Plugin URI: https://darigo.ma/plugin
* Description: Affiche un widget "Commander sur DariGo" sur le blog
* Version: 1.0.0
* Author: Equipe DariGo
* Author URI: https://darigo.ma
* License: GPL v2 or later
* Text Domain: darigo-widget
*/
// Securite : empecher l'acces direct au fichier PHP
if (! defined('ABSPATH')) {
exit;
}
// HOOK : ajouter un shortcode [darigo_bouton url="https://app.darigo.ma/restaurants/5"]
add_shortcode('darigo_bouton', function ($atts) {
$atts = shortcode_atts([
'url' => 'https://app.darigo.ma',
'texte' => 'Commander sur DariGo',
], $atts);
return sprintf(
'<a href="%s" class="darigo-btn" target="_blank">%s</a>',
esc_url($atts['url']),
esc_html($atts['texte'])
);
});
// HOOK : charger le CSS du plugin
add_action('wp_enqueue_scripts', function () {
wp_enqueue_style(
'darigo-widget-style',
plugin_dir_url(__FILE__) . 'css/darigo-widget.css',
[],
'1.0.0'
);
});
// HOOK : ajouter automatiquement le bouton apres chaque article de categorie "Restaurant"
add_filter('the_content', function ($contenu) {
if (! is_single()) return $contenu;
if (has_category('restaurant')) {
$url = get_post_meta(get_the_ID(), '_darigo_url', true);
if ($url) {
$bouton = do_shortcode('[darigo_bouton url="' . esc_url($url) . '"]');
$contenu .= '<div class="darigo-cta">' . $bouton . '</div>';
}
}
return $contenu;
});
// Utilisation dans un article Blade ou page WordPress :
// [darigo_bouton url="https://app.darigo.ma/restaurants/5" texte="Voir le menu"]
Choisir un hébergement
| Type | Prix/mois | Avantages | Cas d'usage |
|---|---|---|---|
| Mutualisé | 25–80 MAD | Simple, support inclus, cPanel, SSL gratuit (Let's Encrypt) | Blog DariGo, site vitrine PME |
| VPS | 80–400 MAD | Ressources dédiées, contrôle total, performances | Trafic élevé, personnalisation |
| Hébergement WP managé | 200–800 MAD | Optimisé WordPress, backups auto, CDN | Sites à fort trafic |
Hébergeurs marocains : connectme.ma, maroc-host.com, planethoster.com (bureaux à Casablanca). Hébergeurs internationaux avec serveurs Europe : OVHcloud (France Centre), Hostinger (à partir de 2,99€/mois), o2switch (offre illimitée à ~7€/mois). Le certificat SSL (HTTPS) est gratuit via Let's Encrypt — à activer immédiatement.
Avant le deploiement :
□ Exporter la BDD locale (phpMyAdmin > Exporter > SQL)
□ Compresser les fichiers WordPress en ZIP
□ Creer la BDD sur l'hebergeur (cPanel > MySQL Databases)
□ Creer un utilisateur MySQL et lui donner tous les droits sur la BDD
Transfert des fichiers :
□ Uploader le ZIP via FileZilla (FTP) vers public_html/
□ Decompresser le ZIP sur le serveur (via cPanel File Manager)
□ Importer la BDD (cPanel > phpMyAdmin > Importer > .sql)
Configuration :
□ Modifier wp-config.php : DB_NAME, DB_USER, DB_PASSWORD, DB_HOST
□ Mettre a jour les URLs BDD : wp_options > siteurl et home
□ OU utiliser le plugin Better Search Replace pour le remplacement
□ Activer SSL (cPanel > Let's Encrypt) et forcer HTTPS
Verification :
□ Tester toutes les pages
□ Verifier les formulaires
□ Tester sur mobile
□ Activer le plugin de sauvegarde automatique (UpdraftPlus)
wp_options ?esc_url() est utilisée dans le plugin DariGo avant d'afficher une URL fournie par un utilisateur. Pourquoi ?DariGo — Architecture & Setup Laravel
DariGo est une API REST complète de livraison de repas à domicile au Maroc, construite avec Laravel 11, Sanctum, Eloquent ORM et PHPUnit. Ce projet mobilise l'ensemble des compétences du cours — routage, modèles, sécurité, queues, tests et déploiement.
DariGo est une plateforme de livraison de repas à domicile — dari (maison en darija) + Go (livraison rapide). Un client commande chez un restaurant de Casablanca, Rabat ou Marrakech — un livreur reçoit la mission et livre. L'admin supervise tout. Ce projet est la version Laravel du back-end — une API REST pure, sans vues Blade, consommée par une app mobile.
APP MOBILE / POSTMAN
|
| HTTP + Bearer Token (Sanctum)
v
LARAVEL 11 API (darigo-api/)
├─ routes/api.php
├─ app/Http/Controllers/
├─ app/Models/ (Eloquent ORM)
├─ app/Policies/ (Gates & Policies)
├─ app/Jobs/ (Queues)
├─ app/Notifications/ (Email livreur)
└─ tests/Feature/ (PHPUnit)
|
v
MySQL 8 (darigo_db)
├─ users
├─ restaurants
├─ plats
├─ commandes
└─ livraisons
| Table | Modèle | Description | Relations clés |
|---|---|---|---|
users | User | Tous les utilisateurs — 4 rôles | hasMany restaurants, commandes, livraisons |
restaurants | Restaurant | Restaurants avec plats, ville, catégorie | belongsTo user (propriétaire), hasMany plats, commandes |
plats | Plat | Plats avec prix, stock, disponibilité | belongsTo restaurant, belongsToMany commandes |
commandes | Commande | Commandes avec statuts, total, lignes | belongsTo user (client), restaurant; belongsToMany plats |
livraisons | Livraison | Missions livreurs avec statuts | belongsTo commande, user (livreur) |
| Rôle | Permissions principales |
|---|---|
| client | Voir restaurants/plats, créer commandes, voir ses commandes |
| restaurateur | Gérer ses restaurants et ses plats, voir les commandes de ses restaurants |
| livreur | Voir les livraisons disponibles, accepter une mission, mettre à jour le statut |
| admin | Tout — bypass automatique de toutes les policies |
# 1 — Creer le projet
composer create-project laravel/laravel darigo-api
cd darigo-api
# 2 — Installer Sanctum (inclus dans Laravel 11, publier la config)
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
# 3 — Configurer .env
# DB_DATABASE=darigo_db
# DB_USERNAME=root
# DB_PASSWORD=
# 4 — Creer la BDD
mysql -u root -p -e "CREATE DATABASE darigo_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
# 5 — Generer la cle
php artisan key:generate
# 6 — Lancer les migrations par defaut
php artisan migrate
# 7 — Verifier
php artisan route:list # Routes par defaut
php artisan serve # http://localhost:8000
# Modeles + migrations + factories + seeders + controllers
php artisan make:model User --migration # Deja present
php artisan make:model Restaurant -mcsf --api
php artisan make:model Plat -mcsf --api
php artisan make:model Commande -mcsf --api
php artisan make:model Livraison -mcsf --api
# Policies
php artisan make:policy RestaurantPolicy --model=Restaurant
php artisan make:policy CommandePolicy --model=Commande
php artisan make:policy LivraisonPolicy --model=Livraison
# Job
php artisan make:job NotifierLivreurDisponible
# Notification
php artisan make:notification NouvelleCommandeDisponible
# Tests
php artisan make:test AuthTest
php artisan make:test RestaurantTest
php artisan make:test CommandeTest
php artisan make:test LivraisonTest
Étape 1 — Migrations, Modèles & Factories
Créer les 5 migrations DariGo, configurer les 5 modèles Eloquent avec toutes leurs relations (hasOne, hasMany, belongsTo, belongsToMany, hasManyThrough) et générer les Factories avec des données marocaines réalistes.
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('prenom', 50);
$table->string('nom', 50);
$table->string('email')->unique();
$table->string('telephone', 15)->nullable();
$table->string('password');
$table->enum('role', ['client','restaurateur','livreur','admin'])->default('client');
$table->string('ville', 50)->nullable();
$table->string('photo')->nullable();
$table->timestamp('email_verified_at')->nullable();
$table->rememberToken();
$table->timestamps();
});
// restaurants
Schema::create('restaurants', function (Blueprint $table) {
$table->id();
$table->string('nom', 100);
$table->string('slug', 120)->unique();
$table->text('description')->nullable();
$table->string('ville', 50);
$table->string('adresse');
$table->string('telephone', 15)->nullable();
$table->enum('categorie', ['tajine','couscous','pizza','burger','sushi','marocain']);
$table->json('horaires')->nullable();
$table->decimal('note', 3, 2)->default(0.00);
$table->unsignedSmallInteger('nb_avis')->default(0);
$table->boolean('actif')->default(true);
$table->string('photo')->nullable();
$table->foreignId('proprietaire_id')->constrained('users')->restrictOnDelete();
$table->timestamps();
$table->index(['ville', 'actif', 'categorie']);
});
// plats
Schema::create('plats', function (Blueprint $table) {
$table->id();
$table->string('nom', 100);
$table->text('description')->nullable();
$table->decimal('prix', 8, 2);
$table->enum('categorie', ['entree','plat','dessert','boisson'])->default('plat');
$table->unsignedSmallInteger('stock')->default(999);
$table->boolean('actif')->default(true);
$table->string('photo')->nullable();
$table->foreignId('restaurant_id')->constrained()->cascadeOnDelete();
$table->timestamps();
});
// commandes
Schema::create('commandes', function (Blueprint $table) {
$table->id();
$table->foreignId('client_id')->constrained('users')->restrictOnDelete();
$table->foreignId('restaurant_id')->constrained()->restrictOnDelete();
$table->decimal('total', 8, 2);
$table->enum('statut', ['en_attente','confirmee','en_preparation','prete','en_livraison','livree','annulee'])
->default('en_attente');
$table->string('adresse_livraison');
$table->string('ville_livraison', 50);
$table->text('notes')->nullable();
$table->timestamps();
$table->index(['client_id', 'statut']);
});
// Table pivot commande_plat
Schema::create('commande_plat', function (Blueprint $table) {
$table->foreignId('commande_id')->constrained()->cascadeOnDelete();
$table->foreignId('plat_id')->constrained()->restrictOnDelete();
$table->unsignedTinyInteger('quantite')->default(1);
$table->decimal('prix_unitaire', 8, 2); // prix au moment de la commande
$table->string('nom_plat', 100); // snapshot du nom
$table->primary(['commande_id', 'plat_id']);
});
// livraisons
Schema::create('livraisons', function (Blueprint $table) {
$table->id();
$table->foreignId('commande_id')->constrained()->cascadeOnDelete();
$table->foreignId('livreur_id')->nullable()->constrained('users')->nullOnDelete();
$table->enum('statut', ['disponible','acceptee','en_route','livree','annulee'])
->default('disponible');
$table->timestamp('acceptee_le')->nullable();
$table->timestamp('livree_le')->nullable();
$table->timestamps();
});
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
protected $fillable = ['prenom','nom','email','telephone','password','role','ville','photo'];
protected $hidden = ['password','remember_token'];
protected function casts(): array {
return ['email_verified_at' => 'datetime', 'password' => 'hashed'];
}
public function restaurants(): HasMany { return $this->hasMany(Restaurant::class, 'proprietaire_id'); }
public function commandes(): HasMany { return $this->hasMany(Commande::class, 'client_id'); }
public function livraisons(): HasMany { return $this->hasMany(Livraison::class, 'livreur_id'); }
// hasManyThrough : livraisons du client via ses commandes
public function livraisonsClient(): HasManyThrough {
return $this->hasManyThrough(Livraison::class, Commande::class, 'client_id', 'commande_id');
}
public function isAdmin(): bool { return $this->role === 'admin'; }
public function isRestaurateur(): bool { return $this->role === 'restaurateur'; }
}
class Restaurant extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = ['nom','slug','description','ville','adresse','telephone','categorie','horaires','actif','photo','proprietaire_id'];
protected function casts(): array {
return ['horaires' => 'array', 'actif' => 'boolean', 'note' => 'float'];
}
public function proprietaire(): BelongsTo { return $this->belongsTo(User::class, 'proprietaire_id'); }
public function plats(): HasMany { return $this->hasMany(Plat::class); }
public function commandes(): HasMany { return $this->hasMany(Commande::class); }
// Scopes
public function scopeActif($q) { return $q->where('actif', true); }
public function scopeDansVille($q, string $ville) { return $q->where('ville', $ville); }
public function scopeBienNote($q, float $seuil = 3.5) { return $q->where('note', '>=', $seuil); }
// Accessor : est-ce que le restaurant est ouvert maintenant ?
protected function estOuvert(): Attribute {
return Attribute::make(get: function () {
if (!$this->actif || !$this->horaires) return $this->actif;
$heure = now()->format('H:i');
$jour = strtolower(now()->locale('fr')->isoFormat('dddd'));
$plage = $this->horaires[$jour] ?? null;
if (!$plage) return false;
[$ouv, $ferm] = explode('-', $plage);
return $heure >= $ouv && $heure <= $ferm;
});
}
}
class Commande extends Model
{
use HasFactory;
protected $fillable = ['client_id','restaurant_id','total','statut','adresse_livraison','ville_livraison','notes'];
public function client(): BelongsTo { return $this->belongsTo(User::class, 'client_id'); }
public function restaurant(): BelongsTo { return $this->belongsTo(Restaurant::class); }
public function livraison(): HasOne { return $this->hasOne(Livraison::class); }
public function plats(): BelongsToMany {
return $this->belongsToMany(Plat::class, 'commande_plat')
->withPivot(['quantite', 'prix_unitaire', 'nom_plat']);
}
// Scope
public function scopeEnCours($q) {
return $q->whereIn('statut', ['en_attente','confirmee','en_preparation','prete','en_livraison']);
}
}
public function run(): void
{
// Admin
User::firstOrCreate(['email' => 'admin@darigo.ma'], [
'prenom' => 'Admin', 'nom' => 'DariGo',
'password' => Hash::make('Admin2024!'),
'role' => 'admin', 'ville' => 'Casablanca',
'email_verified_at' => now(),
]);
if (app()->environment('local', 'testing')) {
$restaurateurs = User::factory(5)->restaurateur()->create();
$livreurs = User::factory(10)->livreur()->create();
$clients = User::factory(20)->create();
$restaurants = Restaurant::factory(15)
->recycle($restaurateurs)->create();
$restaurants->each(fn($r) =>
Plat::factory(rand(6, 12))->for($r)->create()
);
$clients->each(function ($client) use ($restaurants) {
Commande::factory(rand(1, 4))
->for($client, 'client')
->for($restaurants->random(), 'restaurant')
->create();
});
}
}
php artisan migrate:fresh --seed
# Output attendu :
# INFO Running migrations.
# INFO Seeding database.
# Admin DariGo cree : admin@darigo.ma
# Utilisateurs, restaurants et plats generes
Étape 2 — Authentification Sanctum
AuthController complet — register, login, logout, me. Middleware de vérification du rôle. Routes API avec groupes Sanctum. Tests PHPUnit de toutes les routes auth.
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\{JsonResponse, Request};
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rules\Password;
class AuthController extends Controller
{
public function register(Request $request): JsonResponse
{
$data = $request->validate([
'prenom' => ['required', 'string', 'max:50'],
'nom' => ['required', 'string', 'max:50'],
'email' => ['required', 'email:rfc,dns', 'unique:users,email'],
'telephone' => ['required', 'regex:/^(0[5-7][0-9]{8})$/'],
'password' => ['required', 'confirmed', Password::min(8)->mixedCase()->numbers()],
'role' => ['sometimes', 'in:client,restaurateur,livreur'],
'ville' => ['required', 'string'],
]);
$user = User::create([...$data, 'role' => $data['role'] ?? 'client']);
$token = $user->createToken('darigo-' . $user->role, $this->abilities($user->role));
return response()->json([
'message' => 'Compte cree',
'user' => $user,
'token' => $token->plainTextToken,
], 201);
}
public function login(Request $request): JsonResponse
{
$request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
if (! Auth::attempt($request->only('email', 'password'))) {
return response()->json(['message' => 'Identifiants incorrects'], 401);
}
$user = Auth::user();
$user->tokens()->where('name', 'darigo-' . $user->role)->delete();
$token = $user->createToken('darigo-' . $user->role, $this->abilities($user->role));
return response()->json([
'message' => 'Connexion reussie',
'user' => ['id' => $user->id, 'prenom' => $user->prenom, 'role' => $user->role, 'ville' => $user->ville],
'token' => $token->plainTextToken,
'abilities' => $this->abilities($user->role),
]);
}
public function logout(Request $request): JsonResponse
{
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Deconnecte']);
}
public function me(Request $request): JsonResponse
{
return response()->json(['user' => $request->user()]);
}
private function abilities(string $role): array
{
return match ($role) {
'admin' => ['*'],
'restaurateur' => ['restaurants:manage', 'plats:manage', 'commandes:view'],
'livreur' => ['livraisons:view', 'livraisons:update'],
default => ['restaurants:view', 'plats:view', 'commandes:create', 'commandes:view'],
};
}
}
public function handle(Request $request, Closure $next, string ...$roles): Response
{
if (! $request->user() || ! in_array($request->user()->role, $roles)) {
return response()->json([
'message' => 'Acces refuse',
'roles_requis' => $roles,
], 403);
}
return $next($request);
}
// Auth public
Route::prefix('auth')->group(function () {
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
});
// Protege par Sanctum
Route::middleware('auth:sanctum')->group(function () {
Route::post('/auth/logout', [AuthController::class, 'logout']);
Route::get('/auth/me', [AuthController::class, 'me']);
// Restaurants — public en lecture, protege en ecriture
Route::get('/restaurants', [RestaurantController::class, 'index']);
Route::get('/restaurants/{restaurant}', [RestaurantController::class, 'show']);
Route::middleware('role:restaurateur,admin')->group(function () {
Route::post('/restaurants', [RestaurantController::class, 'store']);
Route::put('/restaurants/{restaurant}', [RestaurantController::class, 'update']);
Route::delete('/restaurants/{restaurant}', [RestaurantController::class, 'destroy']);
Route::apiResource('restaurants.plats', PlatController::class);
});
// Commandes — client cree, admin/resto voir tout
Route::apiResource('commandes', CommandeController::class);
Route::patch('/commandes/{commande}/statut', [CommandeController::class, 'updateStatut']);
// Livraisons — livreurs uniquement
Route::middleware('role:livreur,admin')->group(function () {
Route::get('/livraisons', [LivraisonController::class, 'index']);
Route::patch('/livraisons/{livraison}/accepter', [LivraisonController::class, 'accepter']);
Route::patch('/livraisons/{livraison}/statut', [LivraisonController::class, 'updateStatut']);
});
});
class AuthTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function register_cree_un_compte_et_retourne_un_token(): void
{
$this->postJson('/api/auth/register', [
'prenom' => 'Karim', 'nom' => 'Alaoui',
'email' => 'karim@darigo.ma', 'telephone' => '0612345678',
'password' => 'Karim2024!', 'password_confirmation' => 'Karim2024!',
'ville' => 'Casablanca',
])->assertCreated()
->assertJsonStructure(['token', 'user']);
$this->assertDatabaseHas('users', ['email' => 'karim@darigo.ma', 'role' => 'client']);
$this->assertDatabaseMissing('users', ['password' => 'Karim2024!']);
}
/** @test */
public function login_retourne_token_et_abilities(): void
{
$user = User::factory()->create(['role' => 'livreur']);
$this->postJson('/api/auth/login', [
'email' => $user->email, 'password' => 'password',
])->assertOk()
->assertJsonStructure(['token', 'abilities'])
->assertJsonPath('user.role', 'livreur');
}
/** @test */
public function login_invalide_retourne_401(): void
{
User::factory()->create(['email' => 'test@darigo.ma']);
$this->postJson('/api/auth/login', [
'email' => 'test@darigo.ma', 'password' => 'mauvais',
])->assertUnauthorized();
}
/** @test */
public function logout_revoque_le_token(): void
{
$user = User::factory()->create();
$token = $user->createToken('test')->plainTextToken;
$this->withToken($token)->postJson('/api/auth/logout')->assertOk();
$this->assertDatabaseCount('personal_access_tokens', 0);
// Token revoque : plus d'acces
$this->withToken($token)->getJson('/api/auth/me')->assertUnauthorized();
}
}
Étape 3 — API Restaurants & Plats
RestaurantController et PlatController complets — CRUD avec Policy, pagination, filtres ville/catégorie/note, API Resource pour le formatage JSON, et scope disponible sur les plats.
class RestaurantController extends Controller
{
public function __construct()
{
$this->middleware('auth:sanctum')->except(['index', 'show']);
$this->authorizeResource(Restaurant::class, 'restaurant');
}
public function index(): JsonResponse
{
$restaurants = Restaurant::actif()
->when(request('ville'), fn($q, $v) => $q->dansVille($v))
->when(request('categorie'), fn($q, $c) => $q->where('categorie', $c))
->when(request('note_min'), fn($q, $n) => $q->bienNote((float)$n))
->with(['proprietaire:id,prenom,nom'])
->withCount('plats')
->orderBy('note', 'DESC')
->paginate(request('par_page', 12));
return response()->json($restaurants);
}
public function store(StoreRestaurantRequest $request): JsonResponse
{
$restaurant = Restaurant::create([
...$request->validated(),
'slug' => \Str::slug($request->nom) . '-' . \Str::random(5),
'proprietaire_id' => auth()->id(),
]);
return response()->json(['message' => 'Restaurant cree', 'restaurant' => $restaurant], 201);
}
public function show(Restaurant $restaurant): JsonResponse
{
return response()->json([
'restaurant' => $restaurant->load(['plats' => fn($q) => $q->where('actif', true)]),
]);
}
public function update(UpdateRestaurantRequest $request, Restaurant $restaurant): JsonResponse
{
$restaurant->update($request->validated());
return response()->json(['message' => 'Restaurant mis a jour', 'restaurant' => $restaurant->fresh()]);
}
public function destroy(Restaurant $restaurant): JsonResponse
{
$restaurant->delete();
return response()->json(['message' => 'Restaurant supprime']);
}
}
class RestaurantPolicy
{
public function before(User $user, string $ability): ?bool
{
return $user->role === 'admin' ? true : null;
}
public function viewAny(User $user): bool { return true; }
public function view(User $user, Restaurant $restaurant): bool { return true; }
public function create(User $user): bool { return $user->role === 'restaurateur'; }
public function update(User $user, Restaurant $restaurant): bool {
return $user->id === $restaurant->proprietaire_id;
}
public function delete(User $user, Restaurant $restaurant): bool {
return $user->id === $restaurant->proprietaire_id;
}
}
class PlatController extends Controller
{
// GET /api/restaurants/{restaurant}/plats
public function index(Restaurant $restaurant): JsonResponse
{
$plats = $restaurant->plats()
->when(request('disponible') === 'true', fn($q) => $q->disponible())
->when(request('categorie'), fn($q, $c) => $q->where('categorie', $c))
->orderBy('categorie')->orderBy('prix')
->get()
->map(fn($plat) => [
...$plat->toArray(),
'disponible' => $plat->stock > 0 && $plat->actif,
'prix_formate' => number_format($plat->prix, 2) . ' MAD',
]);
return response()->json(['restaurant_id' => $restaurant->id, 'plats' => $plats]);
}
// POST /api/restaurants/{restaurant}/plats
public function store(Request $request, Restaurant $restaurant): JsonResponse
{
$this->authorize('update', $restaurant); // Meme droit que modifier le restaurant
$data = $request->validate([
'nom' => ['required', 'string', 'max:100'],
'description' => ['nullable', 'string'],
'prix' => ['required', 'numeric', 'min:0.01', 'max:9999'],
'categorie' => ['required', 'in:entree,plat,dessert,boisson'],
'stock' => ['required', 'integer', 'min:0'],
'photo' => ['nullable', 'image', 'max:2048'],
]);
if ($request->hasFile('photo')) {
$data['photo'] = $request->file('photo')->store('plats', 'public');
}
$plat = $restaurant->plats()->create($data);
return response()->json(['message' => 'Plat ajoute', 'plat' => $plat], 201);
}
// PUT /api/restaurants/{restaurant}/plats/{plat}
public function update(Request $request, Restaurant $restaurant, Plat $plat): JsonResponse
{
$this->authorize('update', $restaurant);
$data = $request->validate([
'nom' => ['sometimes', 'string', 'max:100'],
'prix' => ['sometimes', 'numeric', 'min:0.01'],
'stock' => ['sometimes', 'integer', 'min:0'],
'actif' => ['sometimes', 'boolean'],
]);
$plat->update($data);
return response()->json(['message' => 'Plat mis a jour', 'plat' => $plat->fresh()]);
}
public function destroy(Restaurant $restaurant, Plat $plat): JsonResponse
{
$this->authorize('delete', $restaurant);
$plat->delete();
return response()->json(['message' => 'Plat supprime']);
}
}
Étape 4 — API Commandes
Créer une commande avec validation du stock, dénormalisation des prix, machine à états pour les statuts, transaction BDD pour garantir la cohérence, et déclenchement de l'événement CommandeCreee.
class CommandeController extends Controller
{
public function store(StoreCommandeRequest $request): JsonResponse
{
$commande = DB::transaction(function () use ($request) {
$total = 0;
$lignes = [];
foreach ($request->lignes as $ligne) {
$plat = Plat::lockForUpdate()->findOrFail($ligne['plat_id']);
if ($plat->restaurant_id !== $request->restaurant_id) {
throw new \Exception("Plat {$plat->id} n'appartient pas a ce restaurant");
}
if (! $plat->actif || $plat->stock < $ligne['quantite']) {
abort(409, "Plat \"{$plat->nom}\" non disponible ou stock insuffisant");
}
$plat->decrement('stock', $ligne['quantite']);
$sousTotal = $plat->prix * $ligne['quantite'];
$total += $sousTotal;
$lignes[$plat->id] = [
'quantite' => $ligne['quantite'],
'prix_unitaire' => $plat->prix,
'nom_plat' => $plat->nom, // Denormalisation
];
}
$commande = Commande::create([
'client_id' => auth()->id(),
'restaurant_id' => $request->restaurant_id,
'total' => round($total, 2),
'adresse_livraison'=> $request->adresse_livraison,
'ville_livraison' => $request->ville_livraison,
'notes' => $request->notes,
]);
$commande->plats()->attach($lignes);
return $commande;
});
// Declencher l'evenement (hors transaction)
event(new CommandeCreee($commande));
return response()->json([
'message' => 'Commande creee',
'commande' => $commande->load('plats'),
], 201);
}
public function index(): JsonResponse
{
$query = Commande::with(['restaurant:id,nom,ville', 'plats']);
// Un client voit uniquement ses commandes
if (auth()->user()->role === 'client') {
$query->where('client_id', auth()->id());
}
// Un restaurateur voit les commandes de ses restaurants
if (auth()->user()->role === 'restaurateur') {
$restoIds = auth()->user()->restaurants->pluck('id');
$query->whereIn('restaurant_id', $restoIds);
}
$commandes = $query->when(request('statut'), fn($q, $s) => $q->where('statut', $s))
->latest()->paginate(15);
return response()->json($commandes);
}
public function updateStatut(Request $request, Commande $commande): JsonResponse
{
$this->authorize('updateStatut', $commande);
$transitions = [
'en_attente' => ['confirmee', 'annulee'],
'confirmee' => ['en_preparation', 'annulee'],
'en_preparation' => ['prete'],
'prete' => ['en_livraison'],
'en_livraison' => ['livree'],
];
$statutsAutorises = $transitions[$commande->statut] ?? [];
if (! in_array($request->statut, $statutsAutorises)) {
return response()->json([
'message' => 'Transition de statut invalide',
'statut_actuel' => $commande->statut,
'transitions_valides' => $statutsAutorises,
], 422);
}
$commande->update(['statut' => $request->statut]);
return response()->json(['message' => 'Statut mis a jour', 'statut' => $request->statut]);
}
}
class StoreCommandeRequest extends FormRequest
{
public function authorize(): bool
{
return in_array(auth()->user()?->role, ['client', 'admin']);
}
public function rules(): array
{
return [
'restaurant_id' => ['required', 'exists:restaurants,id'],
'adresse_livraison'=> ['required', 'string', 'max:255'],
'ville_livraison' => ['required', 'string'],
'notes' => ['nullable', 'string', 'max:500'],
'lignes' => ['required', 'array', 'min:1'],
'lignes.*.plat_id' => ['required', 'exists:plats,id'],
'lignes.*.quantite'=> ['required', 'integer', 'min:1', 'max:50'],
];
}
}
Étape 5 — API Livraisons & Queues
LivraisonController — lister les missions disponibles, accepter une mission (updateOne atomique), mettre à jour le statut. Evénement CommandeCreee qui crée automatiquement une livraison et dispatche un Job pour notifier les livreurs disponibles.
// app/Events/CommandeCreee.php
class CommandeCreee
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Commande $commande) {}
}
// app/Listeners/CreerLivraison.php
class CreerLivraison implements ShouldQueue
{
public function handle(CommandeCreee $event): void
{
$livraison = Livraison::create([
'commande_id' => $event->commande->id,
'statut' => 'disponible',
]);
// Dispatcher le job de notification aux livreurs
NotifierLivreurDisponible::dispatch($livraison)
->onQueue('notifications')
->delay(now()->addSeconds(10));
}
}
// Enregistrement dans EventServiceProvider
protected $listen = [
CommandeCreee::class => [CreerLivraison::class],
];
class NotifierLivreurDisponible implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 30;
public function __construct(private readonly Livraison $livraison) {}
public function handle(): void
{
$commande = $this->livraison->commande()->with('restaurant')->first();
// Notifier les livreurs de la meme ville que le restaurant
$livreurs = User::where('role', 'livreur')
->where('ville', $commande->restaurant->ville)
->get();
Notification::send($livreurs, new NouvelleCommandeDisponible($this->livraison));
}
public function failed(\Throwable $e): void
{
\Log::error("Notification livreur echouee : " . $e->getMessage(), [
'livraison_id' => $this->livraison->id,
]);
}
}
class LivraisonController extends Controller
{
// GET /api/livraisons — missions disponibles pour le livreur
public function index(): JsonResponse
{
$livraisons = Livraison::with(['commande.restaurant', 'commande.client:id,prenom'])
->when(auth()->user()->role === 'livreur', function ($q) {
$q->where(function ($q) {
$q->where('statut', 'disponible')
->orWhere('livreur_id', auth()->id());
});
})
->latest()->paginate(20);
return response()->json($livraisons);
}
// PATCH /api/livraisons/{livraison}/accepter
public function accepter(Livraison $livraison): JsonResponse
{
// Mise a jour atomique — eviter les race conditions
$updated = Livraison::where('id', $livraison->id)
->where('statut', 'disponible')
->update([
'livreur_id' => auth()->id(),
'statut' => 'acceptee',
'acceptee_le' => now(),
]);
if (! $updated) {
return response()->json([
'message' => 'Mission deja acceptee ou non disponible',
], 409);
}
return response()->json([
'message' => 'Mission acceptee',
'livraison' => $livraison->fresh(),
]);
}
// PATCH /api/livraisons/{livraison}/statut
public function updateStatut(Request $request, Livraison $livraison): JsonResponse
{
$this->authorize('updateStatut', $livraison);
$request->validate([
'statut' => ['required', 'in:en_route,livree,annulee'],
]);
$data = ['statut' => $request->statut];
if ($request->statut === 'livree') {
$data['livree_le'] = now();
// Mettre aussi la commande a jour
$livraison->commande->update(['statut' => 'livree']);
}
$livraison->update($data);
return response()->json(['message' => 'Statut mis a jour', 'statut' => $request->statut]);
}
}
Étape 6 — Tests PHPUnit complets
Suite de tests complète couvrant les routes Restaurants, Commandes et Livraisons — actingAs() par rôle, assertDatabaseHas, Queue::fake(), Event::fake(), couverture > 80%.
class RestaurantTest extends TestCase
{
use RefreshDatabase;
private User $admin, $restaurateur, $client;
private Restaurant $restaurant;
protected function setUp(): void
{
parent::setUp();
$this->admin = User::factory()->admin()->create();
$this->restaurateur = User::factory()->restaurateur()->create();
$this->client = User::factory()->create();
$this->restaurant = Restaurant::factory()->create([
'actif' => true, 'proprietaire_id' => $this->restaurateur->id,
]);
}
/** @test */
public function liste_restaurants_accessible_sans_auth(): void
{
Restaurant::factory(4)->create(['actif' => true]);
$this->getJson('/api/restaurants')->assertOk()->assertJsonCount(5, 'data');
}
/** @test */
public function filtre_par_ville_fonctionne(): void
{
Restaurant::factory(3)->create(['ville' => 'Rabat', 'actif' => true]);
$this->getJson('/api/restaurants?ville=Rabat')
->assertOk()
->assertJsonCount(3, 'data');
}
/** @test */
public function restaurateur_peut_creer_son_restaurant(): void
{
$response = $this->actingAs($this->restaurateur, 'sanctum')
->postJson('/api/restaurants', [
'nom' => 'Dar Zitoun',
'ville' => 'Marrakech',
'adresse' => '14 Rue Hassan II',
'categorie' => 'marocain',
]);
$response->assertCreated();
$this->assertDatabaseHas('restaurants', ['nom' => 'Dar Zitoun', 'actif' => true]);
}
/** @test */
public function client_ne_peut_pas_creer_restaurant(): void
{
$this->actingAs($this->client, 'sanctum')
->postJson('/api/restaurants', ['nom' => 'Test'])
->assertForbidden();
}
/** @test */
public function restaurateur_ne_peut_pas_modifier_restaurant_dautrui(): void
{
$autreResto = Restaurant::factory()->create();
$this->actingAs($this->restaurateur, 'sanctum')
->putJson('/api/restaurants/' . $autreResto->id, ['nom' => 'Pirate'])
->assertForbidden();
}
/** @test */
public function admin_peut_supprimer_nimporte_quel_restaurant(): void
{
$this->actingAs($this->admin, 'sanctum')
->deleteJson('/api/restaurants/' . $this->restaurant->id)
->assertOk();
$this->assertSoftDeleted('restaurants', ['id' => $this->restaurant->id]);
}
}
class CommandeTest extends TestCase
{
use RefreshDatabase;
private User $client, $restaurateur;
private Restaurant $restaurant;
private Plat $plat;
protected function setUp(): void
{
parent::setUp();
$this->client = User::factory()->create(['role' => 'client']);
$this->restaurateur = User::factory()->restaurateur()->create();
$this->restaurant = Restaurant::factory()->create([
'actif' => true, 'proprietaire_id' => $this->restaurateur->id,
]);
$this->plat = Plat::factory()->create([
'restaurant_id' => $this->restaurant->id,
'prix' => 65.00, 'stock' => 10, 'actif' => true,
]);
}
/** @test */
public function client_peut_creer_commande_et_evenement_est_declenche(): void
{
Event::fake();
$response = $this->actingAs($this->client, 'sanctum')
->postJson('/api/commandes', [
'restaurant_id' => $this->restaurant->id,
'adresse_livraison' => '14 Rue Hassan II',
'ville_livraison' => 'Casablanca',
'lignes' => [['plat_id' => $this->plat->id, 'quantite' => 2]],
]);
$response->assertCreated()->assertJsonPath('commande.total', 130.0);
$this->assertDatabaseHas('commandes', ['client_id' => $this->client->id, 'total' => 130.00]);
$this->assertDatabaseHas('plats', ['id' => $this->plat->id, 'stock' => 8]);
Event::assertDispatched(CommandeCreee::class);
}
/** @test */
public function commande_refuse_si_stock_insuffisant(): void
{
$this->plat->update(['stock' => 1]);
$this->actingAs($this->client, 'sanctum')
->postJson('/api/commandes', [
'restaurant_id' => $this->restaurant->id,
'adresse_livraison' => '14 Rue Moulay Ali',
'ville_livraison' => 'Casablanca',
'lignes' => [['plat_id' => $this->plat->id, 'quantite' => 5]],
])->assertStatus(409);
}
/** @test */
public function transition_statut_invalide_retourne_422(): void
{
$commande = Commande::factory()->create([
'client_id' => $this->client->id,
'restaurant_id' => $this->restaurant->id,
'statut' => 'en_attente',
]);
$this->actingAs($this->client, 'sanctum')
->patchJson('/api/commandes/' . $commande->id . '/statut', [
'statut' => 'livree', // Impossible depuis en_attente
])->assertUnprocessable();
}
/** @test */
public function livraison_est_creee_quand_commande_est_creee(): void
{
$this->actingAs($this->client, 'sanctum')
->postJson('/api/commandes', [
'restaurant_id' => $this->restaurant->id,
'adresse_livraison'=> '14 Rue Hassan',
'ville_livraison' => 'Casablanca',
'lignes' => [['plat_id' => $this->plat->id, 'quantite' => 1]],
])->assertCreated();
$this->assertDatabaseCount('livraisons', 1);
$this->assertDatabaseHas('livraisons', ['statut' => 'disponible', 'livreur_id' => null]);
}
}
# Tous les tests
php artisan test
# Avec couverture (necessite Xdebug)
php artisan test --coverage --min=80
# En parallele (plus rapide)
php artisan test --parallel
# Resultat attendu :
# PASS Tests\Feature\AuthTest (4 tests)
# PASS Tests\Feature\RestaurantTest (6 tests)
# PASS Tests\Feature\CommandeTest (4 tests)
# PASS Tests\Feature\LivraisonTest (3 tests)
# Tests: 17 passed
# Duration: 3.45s
Étape 7 — Déploiement production
Préparer l'API DariGo pour la production — .env production, php artisan optimize, config:cache, route:cache, Supervisor pour les queues, SSL et configuration Apache/Nginx.
# 1 — Cloner le projet sur le serveur
git clone https://github.com/equipe/darigo-api.git /var/www/darigo-api
cd /var/www/darigo-api
# 2 — Installer les dependances (sans dev)
composer install --no-dev --optimize-autoloader
# 3 — Configurer le .env production
cp .env.example .env
# Editer .env : APP_ENV=production, APP_DEBUG=false, DB_*, QUEUE_CONNECTION=redis...
# 4 — Generer la cle
php artisan key:generate
# 5 — Executer les migrations
php artisan migrate --force # --force requis en production
# 6 — Creer le lien symbolique Storage
php artisan storage:link
# 7 — Optimiser (ESSENTIEL en production)
php artisan optimize # cache config + routes + vues en une commande
# ou en detail :
php artisan config:cache # Met en cache config/
php artisan route:cache # Met en cache les routes (ATTENTION : plus de closures)
php artisan view:cache # Pre-compile les vues Blade
php artisan event:cache # Cache les listeners
# 8 — Permissions (Apache/Nginx doit pouvoir ecrire dans storage/)
chmod -R 775 storage bootstrap/cache
chown -R www-data:www-data storage bootstrap/cache
APP_NAME="DariGo API"
APP_ENV=production
APP_DEBUG=false # JAMAIS true en production
APP_URL=https://api.darigo.ma
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_DATABASE=darigo_db
DB_USERNAME=darigo_user
DB_PASSWORD=MotDePasseForte2024!
CACHE_STORE=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=smtp.sendgrid.net
MAIL_PORT=587
MAIL_USERNAME=apikey
MAIL_PASSWORD=SG.xxxxxxxxxxxx
MAIL_FROM_ADDRESS=noreply@darigo.ma
php artisan queue:work est un processus qui s'arrête si le serveur redémarre. Supervisor est un gestionnaire de processus qui redémarre automatiquement les workers Laravel au redémarrage du serveur — garantissant que les notifications aux livreurs DariGo sont toujours traitées.
[program:darigo-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/darigo-api/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2 ; 2 workers en parallele
redirect_stderr=true
stdout_logfile=/var/log/darigo-worker.log
stopwaitsecs=3600
sudo supervisorctl reread # Lire les nouvelles configs
sudo supervisorctl update # Appliquer les changements
sudo supervisorctl start darigo-worker:*
sudo supervisorctl status # Verifier le statut
# Redemarrer les workers apres un deploiement
php artisan queue:restart # Signe les workers de redemarrer proprement
server {
listen 80;
server_name api.darigo.ma;
return 301 https://$server_name$request_uri; # Forcer HTTPS
}
server {
listen 443 ssl http2;
server_name api.darigo.ma;
# Certificat SSL (Let's Encrypt via Certbot)
ssl_certificate /etc/letsencrypt/live/api.darigo.ma/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.darigo.ma/privkey.pem;
root /var/www/darigo-api/public; # Toujours pointer vers public/
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all; # Bloquer l'acces aux fichiers caches (.env, .git)
}
}
| Étape | Compétence | Partie du cours |
|---|---|---|
| Architecture & Setup | Composer, Artisan, .env, Sanctum | Partie 1 |
| Migrations & Modèles | Schema Builder, Eloquent ORM, Relations | Parties 5 & 6 |
| Auth Sanctum | API Tokens, Abilities, Middleware rôle | Partie 4 |
| API Restaurants & Plats | Controllers, Scopes, Policies, Form Request | Parties 2, 4 & 6 |
| API Commandes | Transactions BDD, Events, Machine à états | Parties 3 & 5 |
| Livraisons & Queues | Jobs, Notifications, Listeners asynchrones | Partie 3 |
| Tests PHPUnit | Feature tests, actingAs, fake(), assertDatabase | Partie 7 |
| Déploiement | optimize, Nginx, Supervisor, Redis, SSL | Toutes parties |