Module 3Approfondir la programmation Laravel
Chapitre 13 sur 17
Le projet complet : commande, API, tests et déploiement
Panier et commande transactionnelle, API REST, tests automatisés et mise en production.
- ≈ 175 min
- 7 leçons
- 2 vidéos
- 4 exercices
Module 3Approfondir la programmation Laravel
Chapitre 13 sur 17
Panier et commande transactionnelle, API REST, tests automatisés et mise en production.
Objectifs
Avec ce chapitre, tu sauras…
Leçon 1
app/
├── Enums/Langue.php
├── Http/
│ ├── Controllers/
│ │ ├── Admin/ DashboardController, LivreController, AuteurController, CategorieController
│ │ ├── Api/LivreController.php
│ │ ├── Auth/ InscriptionController, ConnexionController
│ │ ├── CatalogueController.php
│ │ ├── PanierController.php
│ │ └── CommandeController.php
│ ├── Middleware/ EnsureUserHasRole, AddSecurityHeaders
│ ├── Requests/ StoreLivreRequest, UpdateLivreRequest
│ └── Resources/LivreResource.php
├── Models/ User, Categorie, Auteur, Livre, Commande
├── Policies/ LivrePolicy, CommandePolicy
├── Services/PanierService.php
└── View/Components/Prix.php
database/
├── factories/ CategorieFactory, AuteurFactory, LivreFactory
├── migrations/ categories, auteurs, livres, auteur_livre, role (users), commandes, commande_livre
└── seeders/ DatabaseSeeder, AdminSeeder
resources/views/
├── layouts/app.blade.php, components/ (layout, alerte, prix, etoiles)
├── catalogue/ index, show panier/index commandes/ index, show
├── admin/ dashboard, livres/ (index, create, edit, _form)
└── auth/ connexion, inscription errors/ 404
routes/ web.php, api.php
tests/Feature/ CatalogueTest, AdminLivreTest, CommandeTest, ApiLivreTest<?php
use App\Http\Controllers\Admin;
use App\Http\Controllers\Auth\ConnexionController;
use App\Http\Controllers\Auth\InscriptionController;
use App\Http\Controllers\CatalogueController;
use App\Http\Controllers\CommandeController;
use App\Http\Controllers\PanierController;
use Illuminate\Support\Facades\Route;
// Site public
Route::get('/', [CatalogueController::class, 'accueil'])->name('accueil');
Route::get('/livres', [CatalogueController::class, 'index'])->name('livres.index');
Route::get('/livres/{livre}', [CatalogueController::class, 'show'])->name('livres.show');
// Panier (session, accessible aux invités)
Route::controller(PanierController::class)->prefix('panier')->name('panier.')->group(function () {
Route::get('/', 'index')->name('index');
Route::post('/', 'ajouter')->name('ajouter');
Route::delete('/{livre}', 'retirer')->name('retirer');
});
// Authentification
Route::middleware('guest')->group(function () {
Route::get('/inscription', [InscriptionController::class, 'create'])->name('register');
Route::post('/inscription', [InscriptionController::class, 'store']);
Route::get('/connexion', [ConnexionController::class, 'create'])->name('login');
Route::post('/connexion', [ConnexionController::class, 'store'])->middleware('throttle:connexion');
});
// Espace client
Route::middleware('auth')->group(function () {
Route::post('/deconnexion', [ConnexionController::class, 'destroy'])->name('logout');
Route::post('/commandes', [CommandeController::class, 'store'])->name('commandes.store');
Route::get('/mes-commandes', [CommandeController::class, 'index'])->name('commandes.index');
Route::get('/mes-commandes/{commande}', [CommandeController::class, 'show'])->name('commandes.show');
});
// Administration
Route::prefix('admin')->name('admin.')->middleware(['auth', 'role:admin,libraire'])->group(function () {
Route::get('/', Admin\DashboardController::class)->name('dashboard');
Route::get('/livres/export', [Admin\LivreController::class, 'export'])->name('livres.export');
Route::resource('livres', Admin\LivreController::class);
Route::resource('auteurs', Admin\AuteurController::class)->except('show');
Route::resource('categories', Admin\CategorieController::class)
->except('show')
->parameters(['categories' => 'categorie']);
});use App\Http\Controllers\Admin; importe l'espace de noms : on écrit ensuite Admin\LivreController::class. Remarque la correction du piège du singulier anglais sur categories (chapitre 6).
Leçon 2
Le panier est gardé en session : un tableau [livre_id => quantité]. Le service du chapitre 6 est complété :
<?php
namespace App\Services;
use App\Models\Livre;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Session;
class PanierService
{
private const CLE = 'panier';
public function ajouter(int $livreId, int $quantite = 1): void
{
$panier = Session::get(self::CLE, []);
$panier[$livreId] = ($panier[$livreId] ?? 0) + $quantite;
Session::put(self::CLE, $panier);
}
public function retirer(int $livreId): void
{
Session::forget(self::CLE . '.' . $livreId);
}
public function vider(): void
{
Session::forget(self::CLE);
}
public function nombreArticles(): int
{
return array_sum(Session::get(self::CLE, []));
}
/** Les lignes du panier : chaque livre avec sa quantité et son sous-total. */
public function lignes(): Collection
{
$panier = Session::get(self::CLE, []);
return Livre::whereIn('id', array_keys($panier))->get()
->map(fn (Livre $livre) => (object) [
'livre' => $livre,
'quantite' => $panier[$livre->id],
'sous_total' => round($livre->prix * $panier[$livre->id], 2),
]);
}
public function total(): float
{
return round($this->lignes()->sum('sous_total'), 2);
}
}La validation de la commande doit être atomique : créer la commande, ses lignes et diminuer les stocks, tout ou rien. On vérifie aussi le stock au moment de la commande, en verrouillant les lignes des livres (lockForUpdate) pour que deux clients ne puissent pas acheter le dernier exemplaire en même temps.
use App\Models\Commande;
use App\Models\Livre;
use App\Services\PanierService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
public function store(Request $request, PanierService $panier): RedirectResponse
{
Gate::authorize('create', Commande::class);
$lignes = $panier->lignes();
if ($lignes->isEmpty()) {
return to_route('panier.index')->with('status', 'Votre panier est vide.');
}
$commande = DB::transaction(function () use ($lignes, $request) {
$commande = $request->user()->commandes()->create([
'numero' => 'K-' . now()->format('Ymd') . '-' . strtoupper(str()->random(5)),
'statut' => 'en_attente',
'total' => 0,
]);
$total = 0;
foreach ($lignes as $ligne) {
$livre = Livre::lockForUpdate()->findOrFail($ligne->livre->id); // verrou jusqu'à la fin de la transaction
if ($livre->stock < $ligne->quantite) {
throw ValidationException::withMessages([
'panier' => "Stock insuffisant pour « {$livre->titre} » ({$livre->stock} disponible(s)).",
]); // l'exception annule TOUTE la transaction
}
$commande->livres()->attach($livre->id, [
'quantite' => $ligne->quantite,
'prix_unitaire' => $livre->prix,
]);
$livre->decrement('stock', $ligne->quantite);
$total += $livre->prix * $ligne->quantite;
}
$commande->update(['total' => $total]);
return $commande;
});
$panier->vider();
return to_route('commandes.show', $commande)->with('status', "Commande {$commande->numero} enregistrée.");
}Pour que $request->user()->commandes()->create([...]) fonctionne, le modèle Commande déclare protected $fillable = ['numero', 'statut', 'total']; : user_id est rempli par la relation, pas par l'affectation de masse. Une ValidationException levée dans la transaction provoque le ROLLBACK, puis Laravel redirige vers la page précédente avec le message d'erreur.
Leçon 3
| Verbe et URI | Action | Réponse |
|---|---|---|
GET /api/livres | lister (paginé, filtrable) | 200 + liste |
GET /api/livres/{id} | afficher | 200, ou 404 |
POST /api/livres | créer (authentifié) | 201 + livre créé, ou 422 |
PUT /api/livres/{id} | modifier (authentifié) | 200, ou 403, 404, 422 |
DELETE /api/livres/{id} | supprimer (authentifié) | 204 sans contenu |
Leçon 4
| Assertion | Vérifie que… |
|---|---|
assertOk(), assertStatus(201), assertNotFound(), assertForbidden() | le code de statut |
assertRedirect('/connexion') | la réponse redirige vers cette URL |
assertSee('texte'), assertDontSee(...) | le texte apparaît (ou non) dans la page |
assertSessionHas('status'), assertSessionHasErrors(['isbn']) | la session contient un message, des erreurs de validation |
assertDatabaseHas('livres', [...]), assertDatabaseMissing, assertDatabaseCount |
Leçon 5
| Solution | Principe | Pour qui |
|---|---|---|
| hébergement mutualisé (cPanel) | serveur partagé avec PHP et MySQL ; on envoie les fichiers | petits sites, budget faible ; vérifier PHP 8.3+ et l'accès SSH |
| VPS (serveur privé virtuel) avec Nginx ou Apache | un serveur entier à configurer | contrôle total, demande des compétences système |
| Laravel Forge | outil qui configure et déploie sur ton propre VPS | équipes qui veulent un VPS sans tout faire à la main |
Leçon 6
| Domaine | Critère | ✓ |
|---|---|---|
| environnement | le projet s'installe avec composer install, .env.example, key:generate, migrate --seed | |
| routage | routes nommées, groupes, ressources, liaison de modèle, route:list propre | |
| middlewares | auth, guest, un middleware personnalisé (rôle), throttle | |
| CSRF |
Leçon 7
Vidéo · À regarder
EN30 Days to Learn Laravel - Complete 8 Hour Course
Laracasts (Jeffrey Way) · YouTube
Vidéo · À regarder
EN30 Days to Learn Laravel - Complete 8 Hour Course
Laracasts (Jeffrey Way) · YouTube
1lockForUpdate() dans une transaction sert à…
2Une ressource d'API (JsonResource) sert à…
3Après la création d'une ressource, une API REST renvoie…
4RefreshDatabase dans un test…
5actingAs($user) dans un test…
6Le dossier racine du serveur web doit pointer vers…
7En production, la commande de migration est…
8Après une modification du .env sur un serveur optimisé, il faut…
L'essentiel du chapitre
auth), administration (auth + role), API (routes/api.php, Sanctum) ; use App\Http\Controllers\Admin; pour un espace de noms.DB::transaction + lockForUpdate() + vérification du stock ; une exception annule tout ; le prix payé est copié dans la table pivot.JsonResource (make:resource, toArray, whenLoaded, ::collection avec pagination data/links/meta) ; {livre:id} ; tester avec Accept: application/json.make:test (--unit), php artisan test ; RefreshDatabase, fabriques, actingAs, get/post/delete, getJson/postJson ; assertions de statut, assertRedirect, assertSee, assertSessionHasErrors, assertDatabaseHas, assertSoftDeleted, assertJsonCount ; Pest possible ; GitLab CI.public/ ; composer install --no-dev --optimize-autoloader ; .env (APP_DEBUG=false, HTTPS) ; key:generate une fois ; migrate --force ; storage:link ; droits sur storage/ et bootstrap/cache/ ; php artisan optimize (à relancer après chaque changement du .env) ; queue:work, cron schedule:run ; down / up pour les mises à jour.| l'état de la base |
assertSoftDeleted($modele) | le modèle a été supprimé « en douceur » |
assertJson([...]), assertJsonCount(3, 'data'), assertJsonPath('data.0.titre', '…') | le contenu d'une réponse JSON |
| plateforme officielle gérée par l'équipe Laravel |
| déploiement depuis Git en quelques clics |
git clone (ou git pull) sur le serveur ; le dossier racine du site (document root) doit pointer vers public/.composer install --no-dev --optimize-autoloader (sans les outils de développement), puis npm ci && npm run build (ou envoyer le dossier public/build compilé)..env de production (APP_ENV=production, APP_DEBUG=false, APP_URL=https://…, base de données, SESSION_SECURE_COOKIE=true) puis php artisan key:generate une seule fois.php artisan migrate --force (et un seeder d'administration la première fois) ; jamais migrate:fresh.php artisan storage:link ; les dossiers storage/ et bootstrap/cache/ doivent être accessibles en écriture par le serveur web.php artisan optimize (met en cache la configuration, les routes, les vues et les événements).php artisan queue:work surveillé ; pour les tâches planifiées : une tâche cron * * * * * cd /var/www/kitab && php artisan schedule:run./up qui répond, une erreur volontaire qui n'affiche pas de détails, php artisan about en environnement production.@csrf dans tous les formulaires, @method pour PUT/DELETE |
| contrôleurs | contrôleurs de ressources, injection de dépendances, pas de logique dans les routes |
| requêtes | Form Requests, validation complète, messages en français, envoi de fichier |
| réponses | redirections avec messages flashés, JSON pour l'API, page 404 |
| vues | layout ou composant layout, partiels, composants, @forelse, pagination, old() et @error |
| base de données | migrations avec clés étrangères, table pivot, seeders et fabriques |
| Eloquent | modèles avec $fillable, casts, relations, portées, chargement anticipé (pas de N+1) |
| sécurité | authentification, hachage, policy, pas de faille IDOR, APP_DEBUG=false en production |
| qualité | au moins 5 tests de fonctionnalité qui passent, code versionné dans Git |
Écris les tests de fonctionnalité : (1) un client connecté voit ses commandes sur /mes-commandes mais pas celles d'un autre client ; (2) un client qui ouvre la commande d'un autre reçoit une 403 ; (3) passer commande avec un panier vide redirige vers le panier avec un message ; (4) l'API refuse la suppression d'un livre par un client (403).
Après la mise en ligne, voici quatre symptômes. Donne la cause la plus probable et la correction : (1) la page d'accueil affiche la liste des fichiers du projet ; (2) toutes les pages affichent « The stream or file storage/logs/laravel.log could not be opened » ; (3) les couvertures des livres ne s'affichent pas (erreur 404 sur /storage/couvertures/...) ; (4) une erreur affiche une page détaillée avec le mot de passe de la base de données.
Les ressources d'API
Renvoyer directement un modèle expose toutes ses colonnes, avec leurs noms internes. Une ressource d'API (API Resource) définit exactement le JSON produit :
php artisan make:resource LivreResource<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class LivreResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'isbn' => $this->isbn,
'titre' => $this->titre,
'prix' => (float) $this->prix,
'disponible' => $this->stock > 0,
'langue' => $this->langue->value,
'couverture' => $this->couverture_url,
'categorie' => $this->whenLoaded('categorie', fn () => $this->categorie->libelle),
'auteurs' => $this->whenLoaded('auteurs', fn () => $this->auteurs->pluck('nom')),
'lien' => route('livres.show', $this->resource),
];
}
}whenLoaded n'inclut la relation que si elle a été chargée avec with() : pas de requête cachée (N+1). Le stock exact n'est pas exposé, seulement disponible.
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreLivreRequest;
use App\Http\Resources\LivreResource;
use App\Models\Livre;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Gate;
class LivreController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$livres = Livre::with(['categorie', 'auteurs'])
->recherche($request->query('q'))
->when($request->query('langue'), fn ($q, $langue) => $q->where('langue', $langue))
->orderBy('titre')
->paginate($request->integer('par_page', 20));
return LivreResource::collection($livres);
}
public function show(Livre $livre): LivreResource
{
return new LivreResource($livre->load(['categorie', 'auteurs']));
}
public function store(StoreLivreRequest $request): LivreResource
{
$livre = Livre::create($request->safe()->except('auteurs', 'couverture'));
$livre->auteurs()->sync($request->validated('auteurs'));
return new LivreResource($livre->load(['categorie', 'auteurs'])); // code 201 automatique
}
public function destroy(Livre $livre): Response
{
Gate::authorize('delete', $livre);
$livre->delete();
return response()->noContent(); // 204
}
}<?php
use App\Http\Controllers\Api\LivreController;
use Illuminate\Support\Facades\Route;
Route::get('/livres', [LivreController::class, 'index']);
Route::get('/livres/{livre:id}', [LivreController::class, 'show']);
Route::middleware('auth:sanctum')->group(function () {
Route::post('/livres', [LivreController::class, 'store']);
Route::delete('/livres/{livre:id}', [LivreController::class, 'destroy']);
});{livre:id} force la recherche par id dans l'API, alors que le site utilise le slug (getRouteKeyName). Quand une ressource qui vient d'être créée est renvoyée, Laravel répond automatiquement 201. Une collection paginée produit un JSON avec trois clés : data (les livres), links (première, précédente, suivante, dernière page) et meta (page courante, total…).
{
"data": [
{
"id": 1,
"isbn": "9782070612758",
"titre": "Le Petit Prince",
"prix": 65,
"disponible": true,
"langue": "fr",
"couverture": "http://kitab.test/storage/couvertures/Xk2b9Qm4.jpg",
"categorie": "Jeunesse",
"auteurs": ["Antoine de Saint-Exupéry"],
"lien": "http://kitab.test/livres/le-petit-prince"
}
],
"links": { "first": "http://kitab.test/api/livres?page=1", "next": null },
"meta": { "current_page": 1, "per_page": 20, "total": 1 }
}On teste une API avec Postman, l'extension VS Code Thunder Client, ou curl (en pensant à l'en-tête Accept: application/json, sans lequel une erreur de validation renverrait une redirection au lieu d'un JSON 422) :
curl -H "Accept: application/json" "http://kitab.test/api/livres?q=prince"
curl -X POST -H "Accept: application/json" -H "Authorization: Bearer 1|q3Xv9kHf2..." \
-d "isbn=9789954123456&titre=Essai&prix=80&stock=3&langue=fr&categorie_id=1&auteurs[]=2" \
http://kitab.test/api/livresUn test automatisé vérifie qu'une fonctionnalité marche, à chaque modification du code, en quelques secondes. Laravel distingue les tests unitaires (tests/Unit : une classe isolée) et les tests de fonctionnalité (tests/Feature : une requête HTTP complète, de la route à la réponse). Le fichier phpunit.xml configure par défaut une base SQLite en mémoire pour les tests : la vraie base n'est jamais touchée.
php artisan make:test CatalogueTest # tests/Feature/CatalogueTest.php
php artisan make:test PanierTest --unit # tests/Unit/PanierTest.php
php artisan test # lance tous les tests
php artisan test --filter=CatalogueTest # un seul fichier<?php
namespace Tests\Feature;
use App\Models\Livre;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CatalogueTest extends TestCase
{
use RefreshDatabase; // base vide et migrée avant chaque test
public function test_le_catalogue_affiche_les_livres(): void
{
$livre = Livre::factory()->create(['titre' => 'Le Petit Prince']);
$reponse = $this->get('/livres');
$reponse->assertOk(); // code 200
$reponse->assertSee('Le Petit Prince');
}
public function test_un_livre_inexistant_renvoie_404(): void
{
$this->get('/livres/livre-qui-n-existe-pas')->assertNotFound();
}
public function test_la_recherche_filtre_par_titre(): void
{
Livre::factory()->create(['titre' => 'Le Petit Prince']);
Livre::factory()->create(['titre' => 'La Boîte à merveilles']);
$this->get('/livres?q=prince')
->assertSee('Le Petit Prince')
->assertDontSee('La Boîte à merveilles');
}
}<?php
namespace Tests\Feature;
use App\Models\Auteur;
use App\Models\Categorie;
use App\Models\Livre;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AdminLivreTest extends TestCase
{
use RefreshDatabase;
private function admin(): User
{
$user = User::factory()->create();
$user->role = 'admin';
$user->save();
return $user;
}
public function test_un_visiteur_est_redirige_vers_la_connexion(): void
{
$this->get('/admin/livres')->assertRedirect('/connexion');
}
public function test_un_client_recoit_une_erreur_403(): void
{
$client = User::factory()->create(); // role = 'client' par défaut
$this->actingAs($client)->get('/admin/livres')->assertForbidden();
}
public function test_un_admin_peut_ajouter_un_livre(): void
{
$categorie = Categorie::factory()->create();
$auteur = Auteur::factory()->create();
$this->actingAs($this->admin())
->post('/admin/livres', [
'isbn' => '9789954123456',
'titre' => 'Le Pain nu',
'prix' => 70,
'stock' => 5,
'langue' => 'ar',
'categorie_id' => $categorie->id,
'auteurs' => [$auteur->id],
])
->assertRedirect('/admin/livres')
->assertSessionHas('status');
$this->assertDatabaseHas('livres', ['isbn' => '9789954123456', 'titre' => 'Le Pain nu']);
$this->assertDatabaseHas('auteur_livre', ['auteur_id' => $auteur->id]);
}
public function test_l_isbn_est_obligatoire(): void
{
$this->actingAs($this->admin())
->post('/admin/livres', ['titre' => 'Sans ISBN'])
->assertSessionHasErrors(['isbn', 'prix']);
$this->assertDatabaseCount('livres', 0);
}
public function test_un_admin_peut_supprimer_un_livre_jamais_commande(): void
{
$livre = Livre::factory()->create();
$this->actingAs($this->admin())->delete("/admin/livres/{$livre->slug}")->assertRedirect();
$this->assertSoftDeleted($livre);
}
}Tableau: Les choix d'hébergement
Avant l'examen de fin de module, vérifie que ton projet coche chaque ligne :
Tests automatisés (TDD) sur un vrai projet Laravel.
(1) Termine le panier (ajout, retrait, page récapitulative) et la commande avec transaction. (2) Crée l'API api/livres avec LivreResource, protégée par Sanctum pour l'écriture. (3) Écris au moins 8 tests de fonctionnalité et fais-les passer. (4) Ajoute le pipeline GitLab CI qui lance les tests. (5) Remplis la grille d'auto-évaluation de la section 6. (6) Rédige un fichier README.md d'installation en 6 commandes. (7) Étiquette la version : git tag v1.0.
Pour chaque appel à l'API de Kitab, donne le code de statut attendu : (1) GET /api/livres ; (2) GET /api/livres/9999 (inexistant) ; (3) POST /api/livres sans jeton ; (4) POST /api/livres avec un jeton valide mais sans titre ; (5) POST /api/livres correct ; (6) DELETE /api/livres/3 par un libraire (la policy réserve la suppression aux admins) ; (7) DELETE /api/livres/3 par un admin ; (8) 61e requête en une minute sur une route throttle:60,1.
public function test_l_api_liste_les_livres(): void
{
Livre::factory()->count(3)->create();
$this->getJson('/api/livres')
->assertOk()
->assertJsonCount(3, 'data')
->assertJsonStructure(['data' => [['id', 'titre', 'prix', 'disponible']], 'meta']);
}
public function test_creer_un_livre_exige_un_jeton(): void
{
$this->postJson('/api/livres', [])->assertUnauthorized(); // 401
}actingAs($user) simule un utilisateur connecté ; getJson et postJson envoient l'en-tête Accept: application/json. Si le projet utilise Pest, le même test s'écrit :
<?php
use App\Models\Livre;
test('le catalogue affiche les livres', function () {
Livre::factory()->create(['titre' => 'Le Petit Prince']);
$this->get('/livres')->assertOk()->assertSee('Le Petit Prince');
});Les tests peuvent tourner automatiquement à chaque git push dans un pipeline GitLab CI (module M110) :
tests:
stage: test
image: php:8.3-cli
before_script:
- apt-get update && apt-get install -y git unzip libzip-dev && docker-php-ext-install zip
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
- composer install --no-interaction --prefer-dist
- cp .env.example .env && php artisan key:generate
script:
- php artisan testDans ce pipeline, les assets ne sont pas compilés (pas de Node dans l'image PHP) : une vue qui contient @vite provoquerait l'erreur « Vite manifest not found ». On désactive Vite pendant les tests en ajoutant dans tests/TestCase.php une méthode setUp() qui appelle parent::setUp(); puis $this->withoutVite();.
La liste de contrôle du déploiement
Mettre Kitab en production
server {
listen 80;
server_name kitab.ma;
root /var/www/kitab/public; # TOUJOURS le dossier public
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string; # tout passe par index.php
}
location ~ ^/index\.php(/|$) {
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all; # bloque .env, .git...
}
}Sur un hébergement mutualisé où l'on ne peut pas changer le dossier racine (public_html), on place le projet à côté de public_html (hors de la partie web), et on fait pointer le domaine vers son dossier public (ou on copie le contenu de public/ dans public_html en adaptant les chemins de index.php). On ne met jamais le projet entier dans public_html : le .env serait téléchargeable.
Les mises à jour suivantes
php artisan down --retry=60 # mode maintenance (page 503) pendant la mise à jour
git pull
composer install --no-dev --optimize-autoloader
php artisan migrate --force
npm ci && npm run build
php artisan optimize
php artisan up # fin de la maintenanceL'épisode de synthèse : le projet final assemblé.