Contexte
OpsBoard est un projet full-stack réparti sur deux repos distincts :
- Front Next.js 16 : github.com/MengesJean/ops-board-app
- API Laravel 12 : github.com/MengesJean/ops-board-api
Ce projet est né d'une envie : montrer dans un même produit une stack full-stack moderne, sans tricher sur les détails qui font la différence entre une démo joliment cliquable et une vraie application métier. OpsBoard est un outil de pilotage projet pour freelances et petites équipes : on suit ses clients, ses projets, leurs milestones et tasks, et on retrouve d'un coup d'œil ce qui bloque, ce qui est en retard, ce qu'il faut traiter aujourd'hui.
J'ai conçu et développé l'intégralité du projet en solo : API Laravel 12 côté back, Next.js 16 (App Router, React Server Components) côté front, avec une vraie auth Sanctum SPA stateful entre les deux. Le tout tourne en local derrière Traefik en HTTPS sur des sous-domaines distincts, pour reproduire les contraintes d'un déploiement réel.
L'objectif n'était pas de livrer un produit en production mais de tenir le niveau d'exigence d'une vraie équipe : ownership stricte par customer, tests sur les deux faces, documentation API auto-générée via Scribe, observabilité via un activity log dénormalisé.
Objectifs
Le projet devait démontrer plusieurs choses simultanément :
- Auth Sanctum SPA stateful : zéro token en localStorage, cookies cross-subdomain, CSRF automatique et zéro flash d'état auth grâce aux Server Components
- Ownership par construction : un customer ne voit jamais les données d'un autre, garanti au niveau requête (JOIN sur
client.customer_id) et pas seulement par policy - Dashboard agrégé en un appel : tout le payload de la home (stats, priorities, projects, recent activity) servi par un seul endpoint pensé pour le front
- Couverture de tests croisée : Pest côté API, Vitest + MSW côté Next, pour valider le comportement sur les deux faces sans dépendre de l'autre
Architecture technique
Service de progression projet
Toute la progression d'un projet (tasks par statut, taux de complétion, prochaine échéance, retards) est calculée par un service dédié. La requête passe en query builder brut pour éviter que les casts d'enum Eloquent ne cassent le lookup status-keyed, et la source de vérité reste les tasks — les milestones contribuent juste à un compteur agrégé.
public function forProject(Project $project): array
{
// Query builder, not Eloquent: avoids the enum cast
// turning the raw status string back into a TaskStatus
// and breaking the status-keyed lookup below.
$taskCounts = DB::table('task')
->where('project_id', $project->id)
->selectRaw('status, COUNT(*) as total')
->groupBy('status')
->pluck('total', 'status')
->toArray();
$todo = (int) ($taskCounts[TaskStatus::Todo->value] ?? 0);
$inProgress = (int) ($taskCounts[TaskStatus::InProgress->value] ?? 0);
$done = (int) ($taskCounts[TaskStatus::Done->value] ?? 0);
$totalTasks = $todo + $inProgress + $done;
// ...overdue tasks, milestones, next due, completion_rate
}Le même service expose un breakdown par milestone en une seule requête groupée — pas de N+1 quand le front affiche les mini progress bars de la roadmap.
Activity log dénormalisé
L'activity log n'est pas un log technique, c'est une feature produit : la timeline doit rester lisible même après suppression du sujet. Le logger snapshote le label dans properties.label, dénormalise customer_id et project_id sur chaque ligne (index composite), et résout l'acteur automatiquement entre la session Sanctum et le guard Filament. Les échecs sont avalés silencieusement : l'activity log ne doit jamais casser une écriture métier.
public static function record(Model $subject, string $event, array $properties = []): ?ActivityLog
{
try {
$customerId = self::resolveCustomerId($subject);
if ($customerId === null) {
return null;
}
// Snapshot the label so the timeline stays readable
// even after the subject row is deleted.
$properties = array_merge(
['label' => self::resolveLabel($subject)],
$properties,
);
return ActivityLog::create([
'customer_id' => $customerId,
'project_id' => self::resolveProjectId($subject),
'subject_type'=> $subject->getMorphClass(),
'subject_id' => $subject->getKey(),
'event' => $event,
'properties' => $properties,
'created_at' => now(),
]);
} catch (Throwable) {
return null;
}
}Les observers Project, ProjectMilestone et Task sont câblés via l'attribut #[ObservedBy] (Laravel 12 idiomatique) et tracent les transitions métier (task.status_changed, milestone.completed, project.health_changed...).
Client API double URL côté Next
Le wrapper apiFetch doit gérer une contrainte tordue : le domaine public api.ops-board.dev.localhost ne résout pas à l'intérieur du conteneur Docker (TLD .localhost réservé, loopback). Le client détecte donc s'il tourne en SSR ou en CSR et bascule sur l'URL interne du réseau Docker, en injectant manuellement l'header Origin pour que Sanctum reconnaisse le stateful domain.
function resolveBaseUrl(): string {
// Browser uses the public URL (cookies + CORS line up).
// Server uses the Docker-internal URL since the public
// domain loops back inside the container.
const url = typeof window === "undefined"
? INTERNAL_API_URL
: PUBLIC_API_URL;
if (!url) {
throw new Error("NEXT_PUBLIC_API_URL is not defined");
}
return url;
}Le même wrapper centralise la danse CSRF (/sanctum/csrf-cookie puis header X-XSRF-TOKEN lu depuis document.cookie), force credentials: 'include' partout, et mappe les 401 et 422 sur des erreurs typées consommables par les formulaires react-hook-form et zod.
Endpoint dashboard agrégé
La home du front peint un écran complet en un seul appel à GET /api/dashboard, qui retourne stats, priorities, projects et recent_activity dans un même payload. Tout le DashboardService JOIN systématiquement sur client.customer_id — l'ownership cross-customer est verrouillée par construction, pas par une policy ajoutée après coup.
Résultats
- 208 tests Pest côté backend couvrant les CRUDs, l'auth Sanctum SPA, le reorder transactionnel, la progression, l'activity log et l'isolation cross-customer
- 187 tests Vitest + MSW côté frontend : services API, helpers de domaine, formulaires, dashboards, timelines d'activity et progress sections
- 1 endpoint dashboard agrégé : tout le payload de la home en un seul appel HTTP, zéro waterfall de loaders côté front
- 6 PRs livrées en 2 jours : auth → clients → projects → milestones → tasks → dashboard et activity, chaque PR auto-suffisante et merge-ready
Ce que j'ai appris
Concevoir l'API et l'UI en parallèle change tout. Quand l'endpoint dashboard est pensé dès le début pour le shape exact dont la home a besoin, on évite quatre round-trips, un waterfall de skeletons et toute une couche de logique d'agrégation côté front. J'ai aussi appris qu'un activity log bien dénormalisé (label snapshoté, customer_id et project_id sur chaque ligne) vaut mieux qu'un schéma pur — la résilience aux suppressions et les reads en O(log n) sont des features produit, pas des optimisations prématurées.