Skip to content

Backend - API Laravel

Laravel 12 PHP 8.4 PostgreSQL 17 Redis

Le backend de Mindlet est une API RESTful développée avec Laravel 12, conçue selon une architecture en couches (Layered Architecture) avec une séparation stricte des responsabilités. Cette API constitue le point central de communication entre l’application mobile et le service d’intelligence artificielle.

  • 🔐 Authentification multi-providers (email/password, Google, Apple)
  • 📚 Gestion des collections et des cartes d’apprentissage
  • 🎴 Orchestration de la génération de cartes via le service IA
  • 📊 Suivi de progression et sessions d’étude
  • 💬 Messagerie temps réel via WebSocket
  • 🏢 Gestion d’organisations et collaboration
ComposantTechnologieRôle
FrameworkLaravel 12.xStructure applicative
LangagePHP 8.4Typage strict, enums natifs
Base de donnéesPostgreSQL 17Stockage relationnel, JSONB
Cache & QueuesRedis 8.2Sessions, jobs asynchrones
WebSocketLaravel ReverbTemps réel bidirectionnel
AuthLaravel SanctumTokens API, OAuth
JobsLaravel HorizonMonitoring des workers
API DocsScrambleGénération OpenAPI
AdminFilament 4.0Interface d’administration
StockageAWS S3 / MinIOFichiers et médias

L’organisation du code suit le pattern Service Layer enrichi de DTOs pour le transfert de données et de Policies pour l’autorisation :

Architecture en couches
100%
flowchart TB
    subgraph "Couche HTTP"
        REQ["🌐 Request"]
        CTRL["Controllers"]
        RES["Resources"]
        MW["Middlewares"]
    end

    subgraph "Couche Validation"
        FR["Form Requests"]
        DTO["DTOs"]
    end

    subgraph "Couche Métier"
        SVC["Services"]
        POL["Policies"]
        EVT["Events"]
        JOB["Jobs"]
    end

    subgraph "Couche Données"
        MDL["Models"]
        ENUM["Enums"]
        DB[("PostgreSQL")]
    end

    REQ --> MW --> FR --> CTRL
    CTRL --> SVC
    FR --> DTO
    SVC --> MDL --> DB
    SVC --> EVT --> JOB
    CTRL --> POL
    CTRL --> RES

    style REQ fill:#1565c0,stroke:#1976d2
    style SVC fill:#2e7d32,stroke:#388e3c
    style MDL fill:#e65100,stroke:#f57c00
    style DB fill:#7b1fa2,stroke:#9c27b0
app/
├── Console/ # Commandes Artisan
├── Dtos/ # Data Transfer Objects
│ ├── Ai/ # DTOs pour l'IA
│ │ └── GeneratedCards/ # DTOs par type de carte
│ ├── CardGeneration/ # DTOs du flux de génération
│ └── Embedding/ # DTOs des embeddings
├── Enums/ # Énumérations typées
│ ├── Card/ # CardType, CardDifficulty
│ ├── Collection/ # Visibility, CollaboratorRole
│ └── Ai/ # AiBatchStatus, CardGenerationStatus
├── Events/ # Événements broadcast
│ └── CardGeneration/ # 6 événements de génération
├── Exceptions/ # Exceptions personnalisées
├── Http/
│ ├── Controllers/ # 22+ contrôleurs par domaine
│ ├── Middleware/ # Middlewares personnalisés
│ ├── Requests/ # Form Requests (validation)
│ └── Resources/ # Transformateurs JSON
├── Jobs/ # Jobs asynchrones
│ └── CardGeneration/ # GenerateCardsJob
├── Listeners/ # Écouteurs d'événements
├── Models/ # 35+ modèles Eloquent
├── Notifications/ # Notifications push/email
├── Policies/ # Politiques d'autorisation
├── Providers/ # Fournisseurs de services
└── Services/ # Logique métier
├── Ai/ # Services IA (quota, historique)
├── CardGeneration/ # Orchestration de génération
└── LangGraph/ # Client service IA

L’architecture du backend repose sur plusieurs design patterns reconnus, chacun répondant à une problématique spécifique :

Objectif : Centraliser la logique métier dans des classes dédiées, indépendantes du framework HTTP.

Ce pattern permet de :

  • Découpler la logique métier des contrôleurs
  • Réutiliser la même logique depuis différents points d’entrée (API, Jobs, Console)
  • Tester unitairement sans dépendances HTTP
app/Services/CardGeneration/CardGenerationService.php
class CardGenerationService
{
public function __construct(
private readonly QuotaService $quotaService,
private readonly LangGraphGeneratorService $generator,
private readonly CardPersistenceService $persistence,
private readonly GenerationHistoryService $history,
) {}
/**
* Dispatche une génération de cartes de manière asynchrone.
*
* @throws QuotaExceededException Si le quota utilisateur est dépassé
*/
public function dispatchGeneration(
User $user,
CardGenerationInput $input
): CardGenerationDispatchResult {
// 1. Vérification du quota
$this->quotaService->guardCanGenerate(
$user,
$input->getTotalCardsRequested()
);
// 2. Enregistrement dans l'historique
$generation = $this->history->recordRequest($user, $input);
// 3. Dispatch du job asynchrone
GenerateCardsJob::dispatch(
$user->id,
$generation->generation_id,
$input
);
return new CardGenerationDispatchResult(
generationId: $generation->generation_id,
status: CardGenerationStatus::QUEUED,
totalCardsRequested: $input->getTotalCardsRequested(),
);
}
}

PHP 8.1+ permet l’utilisation d’énumérations natives. Mindlet les utilise intensivement pour garantir la cohérence des données :

app/Enums/Card/CardType.php
enum CardType: string
{
case FLASHCARD = 'flashcard'; // Question/Réponse
case MCQ = 'mcq'; // QCM
case FREE_TEXT = 'free_text'; // Réponse libre
case TRUE_OR_FALSE = 'true_or_false'; // Vrai/Faux
case SLIDER = 'slider'; // Estimation numérique
case MATCH_PAIRS = 'match_pairs'; // Associer des paires
case DRAG_AND_DROP = 'drag_and_drop'; // Glisser-déposer
case RANKING = 'ranking'; // Classement
case GEO_GUESS = 'geo_guess'; // Géolocalisation
public function label(): string
{
return match($this) {
self::FLASHCARD => 'Flashcard',
self::MCQ => 'QCM',
self::FREE_TEXT => 'Réponse libre',
// ...
};
}
}

Les modèles Eloquent représentent les entités métier et encapsulent les relations, accesseurs et casts :

app/Models/Card.php
class Card extends Model
{
use HasFactory, SoftDeletes;
use InteractsWithMedia; // Spatie Media Library
use LogsActivity; // Audit trail
/**
* Table avec UUID comme clé primaire.
*/
protected $keyType = 'string';
public $incrementing = false;
/**
* Attributs assignables en masse.
*/
protected $fillable = [
'collection_id',
'type',
'data', // JSONB : question, réponse, options
'config', // JSONB : comportement, feedback
'metadata', // JSONB : tags, niveau, source
];
/**
* Casting automatique des attributs.
*
* - CardType::class : conversion automatique string ↔ enum
* - 'array' : JSON ↔ array PHP
*/
protected $casts = [
'type' => CardType::class,
'data' => 'array',
'config' => 'array',
'metadata' => 'array',
];
// ═══════════════════════════════════════════════════
// RELATIONS
// ═══════════════════════════════════════════════════
public function collection(): BelongsTo
{
return $this->belongsTo(Collection::class);
}
public function progressions(): HasMany
{
return $this->hasMany(CardProgression::class);
}
/**
* Progression de l'utilisateur courant.
*/
public function userProgression(): HasOne
{
return $this->hasOne(CardProgression::class)
->where('user_id', auth()->id());
}
// ═══════════════════════════════════════════════════
// ACCESSEURS (colonnes virtuelles)
// ═══════════════════════════════════════════════════
/**
* Accès simplifié à data.question.text
*/
protected function question(): Attribute
{
return Attribute::get(
fn() => $this->data['question']['text'] ?? null
);
}
/**
* Accès simplifié à data.answer.value
*/
protected function answer(): Attribute
{
return Attribute::get(
fn() => $this->data['answer']['value'] ?? null
);
}
// ═══════════════════════════════════════════════════
// MEDIA LIBRARY
// ═══════════════════════════════════════════════════
public function registerMediaCollections(): void
{
$this->addMediaCollection('attachments')
->acceptsMimeTypes([
'image/jpeg', 'image/png', 'image/webp',
'audio/mpeg', 'audio/wav',
]);
}
}

Les tâches longues sont déléguées à des Jobs exécutés en arrière-plan via Laravel Horizon :

app/Jobs/CardGeneration/GenerateCardsJob.php
class GenerateCardsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Nombre maximum de tentatives.
*/
public int $tries = 5;
/**
* Timeout en secondes (10 minutes pour l'IA).
*/
public int $timeout = 600;
/**
* File d'attente dédiée aux jobs IA.
*/
public string $queue = 'ai';
private int $waitAttempts = 0;
private const MAX_WAIT_ATTEMPTS = 10;
public function __construct(
private readonly string $userId,
private readonly string $generationId,
private readonly CardGenerationInput $input,
) {}
public function handle(
CardGenerationService $service,
GenerationHistoryService $history,
): void {
$user = User::findOrFail($this->userId);
// 1. Marquer comme "en cours"
$history->markProcessing($this->generationId);
event(new CardGenerationStarted(
$this->userId,
$this->generationId,
$this->input->getTotalCardsRequested()
));
// 2. Attendre que le batch soit prêt (si applicable)
if ($this->input->batchId) {
$this->guardBatchIsReady($this->input->batchId);
}
// 3. Générer les cartes
$result = $service->generate($user, $this->input);
event(new CardsGenerated(
$this->userId,
$this->generationId,
$result->stats->cardsGenerated
));
// 4. Persister en base
$persistenceResult = app(CardPersistenceService::class)
->persist($user, $result, $this->input);
if ($persistenceResult->collectionCreated) {
event(new CollectionCreated(
$this->userId,
$this->generationId,
$persistenceResult->collectionId,
$persistenceResult->collectionTitle
));
}
event(new CardsPersisted(
$this->userId,
$this->generationId,
$persistenceResult->collectionId,
$persistenceResult->cardsPersisted
));
// 5. Consommer le quota APRÈS la persistance
app(QuotaService::class)->consumeQuota(
$user,
$result->stats->cardsGenerated
);
// 6. Mettre à jour l'historique
$history->markCompleted(
$this->generationId,
$persistenceResult->collectionId,
$result->stats
);
// 7. Notifier le succès
event(new CardGenerationCompleted(
$this->userId,
$this->generationId,
$persistenceResult->collectionId,
$result->stats->cardsGenerated,
$persistenceResult->cardsPersisted
));
}
/**
* Attente active pour la préparation du batch d'embeddings.
*/
private function guardBatchIsReady(string $batchId): void
{
$batch = AiResourceBatch::findOrFail($batchId);
if ($batch->status === AiBatchStatus::COMPLETED) {
return;
}
if ($this->waitAttempts >= self::MAX_WAIT_ATTEMPTS) {
throw new BatchNotReadyException(
"Batch $batchId not ready after max attempts"
);
}
$this->waitAttempts++;
$delay = min(5 + ($this->waitAttempts * 5), 30); // 5s → 30s
$this->release($delay); // Relâche le job, sera réessayé
}
/**
* Gestion des échecs.
*/
public function failed(\Throwable $exception): void
{
app(GenerationHistoryService::class)->markFailed(
$this->generationId,
$exception->getMessage()
);
event(new CardGenerationFailed(
$this->userId,
$this->generationId,
$exception->getMessage(),
$this->input->batchId
));
}
}

L’intégration avec le service LangGraph se fait via un client HTTP dédié :

Communication Backend → Service IA
100%
sequenceDiagram
    participant Job as GenerateCardsJob
    participant SVC as LangGraphGeneratorService
    participant Client as LangGraphClient
    participant IA as LangGraph Cloud

    Job->>SVC: generate(user, input)
    SVC->>SVC: buildInput(embeddings, params)
    SVC->>Client: runWait(payload)

    Client->>IA: POST /runs/wait
    Note over Client,IA: Timeout: 300s<br/>Retries: 3x avec backoff

    IA-->>Client: { generated_cards, metadata }
    Client-->>SVC: Response array

    SVC->>SVC: parseResponse()
    SVC-->>Job: CardGenerationResult
app/Services/LangGraph/LangGraphClient.php
class LangGraphClient
{
private const MAX_RETRIES = 3;
private const MAX_BACKOFF_MS = 5000;
public function __construct(
private readonly string $baseUrl,
private readonly string $apiKey,
private readonly float $connectTimeout = 5.0,
private readonly float $readTimeout = 300.0,
) {}
/**
* Exécute une requête synchrone avec attente du résultat.
*/
public function runWait(array $payload): array
{
return $this->post('/runs/wait', $payload);
}
/**
* POST avec retry et backoff exponentiel.
*/
private function post(string $path, array $payload): array
{
$attempt = 0;
$lastException = null;
while ($attempt < self::MAX_RETRIES) {
try {
$response = Http::baseUrl($this->baseUrl)
->withToken($this->apiKey)
->timeout($this->readTimeout)
->connectTimeout($this->connectTimeout)
->post($path, $payload);
if ($response->successful()) {
return $response->json();
}
throw new \RuntimeException(
"LangGraph error: {$response->status()}"
);
} catch (\Exception $e) {
$lastException = $e;
$attempt++;
if ($attempt < self::MAX_RETRIES) {
$backoff = min(
(2 ** $attempt) * 100 + random_int(0, 100),
self::MAX_BACKOFF_MS
);
usleep($backoff * 1000);
}
}
}
throw $lastException;
}
}

La validation est centralisée dans des Form Requests avec des règles déclaratives :

app/Http/Requests/CardGeneration/GenerateCardsRequest.php
class GenerateCardsRequest extends FormRequest
{
public function rules(): array
{
return [
// Prompt requis si pas de batch
'prompt' => [
'nullable',
'string',
'max:5000',
Rule::requiredIf(fn() => !$this->filled('batch_id')),
],
// Langue obligatoire (enum validé)
'language' => [
'required',
Rule::enum(LanguageCode::class),
],
// Contexte utilisateur optionnel
'user_context' => ['nullable', 'string', 'max:2000'],
// Batch ID requis si pas de prompt
'batch_id' => [
'nullable',
'uuid',
Rule::requiredIf(fn() => !$this->filled('prompt')),
$this->batchExistsRule(),
],
// Collection cible (optionnelle)
'collection_id' => [
'nullable',
'uuid',
Rule::exists('collections', 'id'),
],
// Difficulté (enum optionnel)
'difficulty' => [
'sometimes',
Rule::enum(CardDifficulty::class),
],
// Types de cartes demandés
'card_types' => ['required', 'array', 'min:1'],
'card_types.*.card_type' => [
'required',
Rule::enum(CardType::class),
],
'card_types.*.count' => [
'required',
'integer',
'min:1',
'max:50',
],
];
}
/**
* Règle personnalisée : le batch doit appartenir à l'utilisateur.
*/
private function batchExistsRule(): \Closure
{
return function ($attribute, $value, $fail) {
$exists = AiResourceBatch::query()
->where('id', $value)
->where('user_id', $this->user()->id)
->exists();
if (!$exists) {
$fail("Le batch spécifié n'existe pas.");
}
};
}
/**
* Conversion vers DTO après validation.
*/
public function toDto(): CardGenerationInput
{
return CardGenerationInput::fromRequest($this);
}
}

L’API est documentée automatiquement via Scramble, qui génère une spécification OpenAPI 3.0 à partir du code :

config/scramble.php
return [
'api_path' => 'api',
'export_path' => 'api.json',
'ui' => [
'theme' => 'light',
'layout' => 'responsive',
],
// Schéma de sécurité
'security' => [
'default' => [
['bearerAuth' => []],
],
],
];
// AppServiceProvider.php
Scramble::configure()
->withDocumentTransformers(function (OpenApi $openApi) {
$openApi->secure(
SecurityScheme::http('bearer', 'JWT')
);
});

Résultat : Documentation interactive accessible à /docs/api

L’authentification API utilise Laravel Sanctum avec des tokens Bearer :

// Génération d'un token
$token = $user->createToken('mobile-app', ['*']);
// Header de requête
Authorization: Bearer {token}

Flux d’authentification :

  1. L’utilisateur envoie ses credentials (email/password ou OAuth)
  2. L’API valide et génère un access token (courte durée) et un refresh token
  3. Le client stocke les tokens de manière sécurisée
  4. Chaque requête inclut l’access token dans le header Authorization
  5. Le middleware auth:sanctum vérifie le token
  6. À expiration, le client utilise le refresh token pour en obtenir un nouveau
Flux d'authentification Sanctum
100%
sequenceDiagram
    autonumber
    participant Client as 📱 Mobile App
    participant API as 🖥️ Laravel API
    participant MW as 🔒 Middleware Sanctum
    participant DB as 🗄️ PostgreSQL

    rect rgb(46, 125, 50)
        Note over Client,DB: Phase 1 : Authentification initiale
        Client->>+API: POST /auth/login<br/>{email, password}
        API->>+DB: Vérifier credentials
        DB-->>-API: User trouvé ✓
        API->>DB: Créer personal_access_token
        API-->>-Client: 200 OK<br/>{access_token, refresh_token, expires_in}
        Client->>Client: Stocker tokens (SecureStore)
    end

    rect rgb(21, 101, 192)
        Note over Client,DB: Phase 2 : Requêtes authentifiées
        Client->>+API: GET /api/collections<br/>Authorization: Bearer {token}
        API->>+MW: Vérifier token
        MW->>+DB: SELECT * FROM personal_access_tokens
        DB-->>-MW: Token valide ✓
        MW-->>-API: User authentifié
        API->>DB: Fetch collections
        API-->>-Client: 200 OK<br/>{collections: [...]}
    end

    rect rgb(230, 81, 0)
        Note over Client,DB: Phase 3 : Rafraîchissement du token
        Client->>+API: POST /auth/refresh<br/>{refresh_token}
        API->>+DB: Vérifier refresh_token
        DB-->>-API: Token valide ✓
        API->>DB: Révoquer ancien access_token
        API->>DB: Créer nouveau access_token
        API-->>-Client: 200 OK<br/>{access_token, expires_in}
    end

    rect rgb(198, 40, 40)
        Note over Client,DB: Cas d'erreur : Token expiré
        Client->>+API: GET /api/cards<br/>Authorization: Bearer {expired_token}
        API->>+MW: Vérifier token
        MW->>DB: SELECT * FROM personal_access_tokens
        MW-->>-API: Token expiré ✗
        API-->>-Client: 401 Unauthorized
        Client->>Client: Déclencher refresh flow
    end

Explication du diagramme :

  • Phase 1 : L’utilisateur s’authentifie avec ses credentials. L’API génère deux tokens : un access_token de courte durée (1h) et un refresh_token de longue durée (30 jours).
  • Phase 2 : Chaque requête API inclut l’access token. Le middleware Sanctum vérifie sa validité dans la table personal_access_tokens.
  • Phase 3 : Avant expiration, le client utilise le refresh token pour obtenir un nouvel access token sans re-authentification.
  • Cas d’erreur : Si le token est expiré, l’API retourne une erreur 401 et le client déclenche automatiquement le flow de refresh.

Les migrations versionnent le schéma de base de données :

database/migrations/xxxx_create_cards_table.php
Schema::create('cards', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('collection_id')
->constrained()
->cascadeOnDelete();
$table->string('type'); // Enum stocké en string
$table->jsonb('data'); // Question, réponse, options
$table->jsonb('config')->nullable();
$table->jsonb('metadata')->nullable();
$table->timestamps();
$table->softDeletes();
// Index composites pour les requêtes fréquentes
$table->index(['collection_id', 'type']);
$table->index('type');
});
Relations principales
100%
erDiagram
    USERS ||--o{ COLLECTIONS : owns
    USERS ||--o{ AI_CARD_GENERATIONS : requests
    USERS ||--|| USER_AI_QUOTAS : has
    USERS ||--o{ AI_RESOURCE_BATCHES : submits

    COLLECTIONS ||--o{ CARDS : contains
    COLLECTIONS ||--o{ COLLECTION_COLLABORATORS : has
    COLLECTIONS ||--o{ STUDY_SESSIONS : studied_in

    CARDS ||--o{ CARD_PROGRESSIONS : tracks

    AI_RESOURCE_BATCHES ||--o{ AI_RESOURCE_ITEMS : contains
    AI_RESOURCE_ITEMS ||--o{ AI_RESOURCE_EMBEDDINGS : has

    USERS {
        uuid id PK
        string name
        string email
        timestamp email_verified_at
    }

    COLLECTIONS {
        uuid id PK
        uuid owner_id FK
        string title
        string visibility
    }

    CARDS {
        uuid id PK
        uuid collection_id FK
        string type
        jsonb data
    }

    AI_CARD_GENERATIONS {
        uuid id PK
        uuid user_id FK
        string status
        int cards_generated
    }

Le système de quotas limite l’utilisation des ressources IA :

app/Services/Ai/QuotaService.php
class QuotaService
{
private const DEFAULT_DAILY_LIMIT = 30;
private const DEFAULT_MONTHLY_LIMIT = 100;
private const DEFAULT_MAX_PER_GENERATION = 50;
/**
* Vérifie si l'utilisateur peut générer des cartes.
*/
public function guardCanGenerate(User $user, int $cardsRequested): void
{
$quota = $this->getOrCreateQuota($user);
// Utilisateur illimité (admin, premium)
if ($quota->is_unlimited) {
return;
}
// Reset automatique des compteurs
$this->resetIfNeeded($quota);
// Vérification des limites
if ($cardsRequested > self::DEFAULT_MAX_PER_GENERATION) {
throw new QuotaExceededException(
"Maximum {self::DEFAULT_MAX_PER_GENERATION} cartes par génération"
);
}
if ($quota->daily_generations_used >= $quota->daily_generation_limit) {
throw new QuotaExceededException(
"Limite quotidienne atteinte"
);
}
if ($quota->monthly_generations_used >= $quota->monthly_generation_limit) {
throw new QuotaExceededException(
"Limite mensuelle atteinte"
);
}
}
/**
* Consomme le quota après génération réussie.
*/
public function consumeQuota(User $user, int $cardsGenerated): void
{
$quota = $this->getOrCreateQuota($user);
$quota->increment('daily_generations_used');
$quota->increment('monthly_generations_used');
$quota->increment('total_generations_used');
$quota->increment('total_cards_generated', $cardsGenerated);
}
}
CatégorieTechnologies
CoreLaravel 12, PHP 8.4, PostgreSQL 17
AuthSanctum, OAuth 2.0 (Google, Apple)
AsyncRedis, Laravel Horizon, Jobs
Real-timeLaravel Reverb (WebSocket)
StorageAWS S3, Spatie Media Library
ValidationForm Requests, Enums, DTOs
DocsScramble (OpenAPI 3.0)
AdminFilament 4.0

Service IA

Découvrez comment le backend communique avec le service d’intelligence artificielle.

Voir le Service IA →


Backend conçu pour la robustesse, la scalabilité et la maintenabilité.