LANTORIANDocker, pas à pas

Chapitre 945 min1 exercice

Laravel et Next.js ensemble

C'est la configuration de nos projets : l'API Laravel du chapitre 7 et le front Next.js du chapitre 8, dans un seul compose.yaml. Une seule notion nouvelle, mais elle piège tout le monde.

L'organisation du dépôt

Arborescence
projet/
├── compose.yaml
├── .env.example          UID, GID, mots de passe factices
├── docker/
│   ├── nginx/default.conf
│   └── php/Dockerfile
├── api/                  Laravel
│   └── .env.example
└── front/                Next.js
    ├── Dockerfile.dev
    └── Dockerfile

Deux adresses pour la même API

Avec Next.js, un appel à l'API peut partir de deux endroits : du navigateur (composant client) ou du serveur Next.js (Server Component, route handler). Ils ne sont pas au même endroit du réseau, donc ils n'utilisent pas la même adresse. Teste les quatre combinaisons.

Ça marche. Le navigateur est sur ta machine : localhost:8000 arrive sur le port publié de nginx.

On déclare donc deux variables, et on choisit selon l'endroit où le code s'exécute.

front/lib/api.ts
export function apiUrl(path: string) {
  // Côté serveur : on reste dans le réseau Docker
  // Côté navigateur : on passe par le port publié
  const base =
    typeof window === "undefined"
      ? process.env.API_URL_INTERNAL
      : process.env.NEXT_PUBLIC_API_URL;
  return `${base}${path}`;
}

Le compose.yaml complet

Rien de nouveau : les services du chapitre 7, plus le service front du chapitre 8.

compose.yaml
services:
  nginx:
    image: nginx:stable-alpine
    ports:
      - "8000:80"
    volumes:
      - ./api:/var/www/html
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - php

  php:
    build:
      context: ./docker/php
      args:
        UID: ${UID:-1000}
        GID: ${GID:-1000}
    volumes:
      - ./api:/var/www/html
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

  db:
    image: postgres:18
    environment:
      POSTGRES_DB: laravel
      POSTGRES_USER: laravel
      POSTGRES_PASSWORD: ${DB_PASSWORD:-secret}
    volumes:
      - db-data:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U laravel -d laravel"]
      interval: 5s
      timeout: 3s
      retries: 10

  redis:
    image: redis:8-alpine

  front:
    build:
      context: ./front
      dockerfile: Dockerfile.dev
    ports:
      - "3000:3000"
    volumes:
      - ./front:/app
      - front-modules:/app/node_modules
      - front-next:/app/.next
    environment:
      NEXT_PUBLIC_API_URL: http://localhost:8000
      API_URL_INTERNAL: http://nginx
    depends_on:
      - nginx

volumes:
  db-data:
  front-modules:
  front-next:
Terminal
docker compose up -d --build
docker compose ps
# Front : localhost:3000     API : localhost:8000

CORS et Sanctum

Le front (localhost:3000) et l'API (localhost:8000) n'ont pas le même port, donc pas la même origine. Le navigateur bloque les appels si Laravel ne les autorise pas explicitement.

Terminal
docker compose exec php php artisan install:api      # routes/api.php et Sanctum
docker compose exec php php artisan config:publish cors
api/config/cors.php (extrait)
return [
    'paths' => ['api/*', 'sanctum/csrf-cookie'],
    'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:3000')],
    'supports_credentials' => true,
];
api/.env (ajout)
FRONTEND_URL=http://localhost:3000
SANCTUM_STATEFUL_DOMAINS=localhost:3000
SESSION_DOMAIN=localhost

Mission finale

Tout ce que tu as appris, dans un seul exercice. Prends ton temps : c'est exactement ce que tu feras sur un vrai projet.

Exercice

Une liste d'utilisateurs, de PostgreSQL au navigateur

  1. Monte l'arborescence de ce chapitre avec un Laravel neuf dans api/ et un Next.js neuf dans front/.
  2. Démarre les cinq services avec une seule commande.
  3. Côté Laravel : installe l'API, crée cinq utilisateurs et expose GET /api/users.
  4. Côté Next.js : affiche la liste dans la page d'accueil avec un Server Component.
  5. Ajoute un bouton client Recharger qui rappelle l'API depuis le navigateur. Règle CORS si besoin.
Ce que tu dois obtenir
  • docker compose ps : cinq services, db en healthy.
  • localhost:8000/api/users renvoie un tableau JSON de cinq utilisateurs.
  • localhost:3000 affiche ces utilisateurs dès le chargement (rendu serveur).
  • Le bouton Recharger refait l'appel depuis le navigateur, sans erreur CORS dans la console.
  • Après docker compose down puis up -d, les utilisateurs sont toujours là.
Un indice

Avance couche par couche et vérifie chaque étape avant de passer à la suivante :

la base (docker compose ps), puis l'API (curl localhost:8000/api/users), puis le rendu serveur, et enfin l'appel client.

Le Server Component doit utiliser API_URL_INTERNAL, le composant client NEXT_PUBLIC_API_URL.

Voir la solution
Terminal
docker compose exec php php artisan install:api
docker compose exec php php artisan config:publish cors
docker compose exec php php artisan tinker --execute="App\Models\User::factory(5)->create()"
api/routes/api.php
use App\Models\User;

Route::get('/users', fn () => User::select('id', 'name', 'email')->get());
front/app/page.tsx
import { apiUrl } from "@/lib/api";
import { ReloadUsers } from "./reload-users";

type User = { id: number; name: string; email: string };

export default async function Home() {
  const users: User[] = await fetch(apiUrl("/api/users"), { cache: "no-store" }).then((r) => r.json());
  return (
    <main>
      <h1>{users.length} utilisateurs</h1>
      <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
      <ReloadUsers />
    </main>
  );
}
front/app/reload-users.tsx
"use client";
import { useState } from "react";
import { apiUrl } from "@/lib/api";

export function ReloadUsers() {
  const [count, setCount] = useState<number | null>(null);
  const reload = async () => {
    const users = await fetch(apiUrl("/api/users")).then((r) => r.json());
    setCount(users.length);
  };
  return (
    <button onClick={reload}>
      Recharger {count !== null && `(${count} reçus par le navigateur)`}
    </button>
  );
}
Échap