01 · Contexte & objectif
Le sujet tenait en une phrase : faire une application Laravel. Aucune fonctionnalité imposée, aucune contrainte technique, un mois pour la rendre à trois. Un énoncé aussi ouvert déplace le travail : la difficulté n’est plus de répondre à une liste de critères, elle est de choisir un domaine assez riche pour montrer quelque chose, et assez borné pour être fini.
Nous avons pris une boutique en ligne. Le catalogue est du CRUD, donc peu intéressant en soi — mais tout ce qui l’entoure ne l’est pas : un panier doit rester cohérent avec le stock, un paiement ne doit jamais être cru sur parole, et l’historique des commandes doit survivre à la suppression d’un produit. C’est là que le projet se joue.
02 · Périmètre
Côté client : inscription protégée par captcha, catalogue paginé avec recherche plein texte et filtre par catégorie, fiche produit, panier avec quantités modifiables, paiement, historique de commandes.
Côté administration, derrière un middleware dédié : CRUD complet sur les
articles, les catégories et les commandes, gestion des rôles utilisateurs, et
changement de statut des commandes. Toute route d’administration est fermée par
EnsureUserIsAdmin, et chaque FormRequest revérifie le rôle dans son
authorize() — la protection ne repose donc pas sur un seul point de contrôle.
03 · Architecture
Laravel 13 sur PHP 8.3, Eloquent, authentification Breeze, Tailwind et Vite pour le front, SQLite en développement et MySQL 8.4 via Sail en conteneur.
Six modèles : User, Category, Article, Cart, Order, OrderItem. La
logique de lecture vit dans le modèle plutôt que dans les contrôleurs — Article
porte ses scopes (inStock, search, byCategory, composables en une seule
requête paginée) et ses accessors (imageUrl, qui résout indifféremment un
fichier stocké localement ou une URL distante ; priceFormatted). Un contrôleur
se contente d’enchaîner ces scopes.
La validation est entièrement sortie des contrôleurs dans huit FormRequest,
chacun avec ses règles, son authorize() et ses messages d’erreur en français.
Les e-mails transactionnels passent par des événements : OrderPlaced et
OrderStatusChanged déclenchent des listeners qui envoient les notifications
correspondantes. Le contrôleur de paiement n’a donc aucune connaissance de
l’envoi d’e-mails.
04 · Décisions techniques
Le paiement n’est jamais cru sur parole. Stripe Checkout redirige vers une
URL de succès — mais cette URL est une simple redirection de navigateur, que
n’importe qui peut appeler à la main. La commande n’est donc pas créée au
retour : on récupère la session côté serveur via l’API Stripe et on vérifie que
payment_status vaut bien paid. Tant que Stripe ne l’a pas confirmé, rien
n’est enregistré, rien n’est décrémenté.
Le stock est vérifié deux fois. À l’ajout au panier et à la modification de quantité, puis de nouveau juste avant d’ouvrir la session Stripe. Le premier contrôle sert l’ergonomie — l’erreur arrive tout de suite ; le second est le seul qui compte, car un panier peut rester ouvert longtemps pendant que le stock bouge. Le décrément n’a lieu qu’au moment de la confirmation de paiement.
Les articles sont en suppression douce, et les commandes figent leur prix.
Une commande référence des articles : les supprimer réellement laisserait un
historique troué. Article utilise donc SoftDeletes, et OrderItem stocke le
prix au moment de la commande plutôt que de lire celui du produit. Une
facture de juin reste juste même si le prix change en juillet.
Un administrateur ne peut pas se verrouiller dehors. Il lui est interdit de modifier son propre rôle ou de supprimer son propre compte depuis le back-office. Sans cette garde, un seul clic pouvait laisser l’application sans aucun administrateur, sans moyen de revenir en arrière.
Le captcha se désactive en environnement de test. Il protège l’inscription
contre les robots, mais aucune suite de tests ne sait lire une image : la règle
n’est ajoutée que hors testing. Le compromis est assumé — ce chemin précis
n’est pas couvert par les tests.
05 · Tests
44 tests Pest couvrent l’authentification, le panier, les commandes, le CRUD articles et les accès administrateur. Les plus utiles ne vérifient pas que ça marche, mais que ça refuse : qu’un utilisateur ordinaire est bien redirigé hors des routes d’administration, et qu’un administrateur ne peut pas changer son propre rôle.
06 · Ce que j’en retire
Un sujet libre est plus exigeant qu’un cahier des charges. Sans critères à cocher, c’est à soi de décider où est la difficulté — et il est très facile de passer un mois sur du CRUD confortable sans jamais rien apprendre.
Les trois points sur lesquels j’ai réellement buté sont les mêmes trois points
qu’un tutoriel de boutique passe sous silence : la frontière de confiance avec
le prestataire de paiement, la fenêtre entre la mise au panier et l’achat, et le
fait qu’un historique de commandes doit rester lisible même après que les
produits ont changé ou disparu. Aucun n’est un problème de framework. Laravel
donne les outils — événements, scopes, suppression douce, FormRequest — mais
ne dit à aucun moment où les employer.
01 · Context & goal
The brief was one sentence long: build a Laravel application. No required features, no technical constraints, one month, teams of three. That kind of open-ended brief shifts the work: the difficulty is no longer meeting a checklist, it is picking a domain rich enough to show something and bounded enough to finish.
We chose an online shop. The catalogue itself is CRUD, and not very interesting — but everything around it is: a cart has to stay consistent with stock, a payment must never be taken on trust, and order history has to survive a product being deleted. That is where the project actually lives.
02 · Scope
Customer side: registration protected by a captcha, a paginated catalogue with full-text search and category filtering, product pages, a cart with editable quantities, payment, and order history.
Admin side, behind dedicated middleware: full CRUD on articles, categories and
orders, user role management, and order status changes. Every admin route is
closed by EnsureUserIsAdmin, and each FormRequest re-checks the role in its
authorize() — so protection does not rest on a single checkpoint.
03 · Architecture
Laravel 13 on PHP 8.3, Eloquent, Breeze authentication, Tailwind and Vite on the front end, SQLite in development and MySQL 8.4 through Sail in a container.
Six models: User, Category, Article, Cart, Order, OrderItem. Read
logic lives in the models rather than the controllers — Article carries its
scopes (inStock, search, byCategory, composable into a single paginated
query) and its accessors (imageUrl, which resolves either a locally stored
file or a remote URL; priceFormatted). A controller simply chains those scopes.
Validation is fully extracted from the controllers into eight FormRequest
classes, each with its own rules, authorize() and French error messages.
Transactional e-mails go through events: OrderPlaced and OrderStatusChanged
trigger listeners that send the matching notifications. The payment controller
therefore knows nothing about sending e-mail.
04 · Technical decisions
Payment is never taken on trust. Stripe Checkout redirects to a success URL
— but that URL is just a browser redirect, and anyone can request it by hand. So
the order is not created on return: the session is retrieved server-side
through the Stripe API and checked for payment_status === 'paid'. Until Stripe
confirms it, nothing is recorded and nothing is decremented.
Stock is checked twice. When adding to the cart and when changing a quantity, then again just before opening the Stripe session. The first check is about ergonomics — the error shows up immediately; the second is the one that counts, because a cart can sit open for a long time while stock moves. Stock is only decremented once payment is confirmed.
Articles are soft-deleted, and orders freeze their price. An order
references articles: really deleting them would leave holes in the history. So
Article uses SoftDeletes, and OrderItem stores the price at order time
rather than reading the product’s current one. A June invoice stays correct even
if the price changes in July.
An admin cannot lock themselves out. They are barred from changing their own role or deleting their own account from the back office. Without that guard, a single click could leave the application with no administrator at all and no way back.
The captcha is disabled in the test environment. It protects registration
from bots, but no test suite can read an image: the rule is only added outside
testing. The trade-off is deliberate — that specific path is not covered by
tests.
05 · Tests
44 Pest tests cover authentication, the cart, orders, article CRUD and admin access. The most useful ones do not check that things work, but that they are refused: that a regular user is redirected away from admin routes, and that an admin cannot change their own role.
06 · What I take away
An open brief is more demanding than a specification. With no boxes to tick, it is on you to decide where the difficulty lies — and it is very easy to spend a month on comfortable CRUD without learning anything.
The three things I genuinely got stuck on are the same three a shop tutorial
never mentions: the trust boundary with the payment provider, the window between
adding to a cart and buying, and the fact that order history has to stay
readable after products have changed or disappeared. None of them is a framework
problem. Laravel hands you the tools — events, scopes, soft deletes,
FormRequest — but never tells you where to use them.