44 KiB
Plan de Migration : NestJS/Prisma → Symfony/API Platform
Date de création: 2026-01-10 Dernière mise à jour: 2026-01-11 Objectif: Migrer le backend Inventory (NestJS + Prisma) vers Symfony 8 + API Platform avec JWT auth, tout en conservant les données existantes et en minimisant les disruptions.
📊 État des Lieux - 2026-01-11
✅ Phase 1 : Préparation (TERMINÉE, ajustée)
- ✅ Bundles Symfony installés (JWT, VichUploader, Uid)
- ✅ Entité Profile créée (UserInterface + session ready)
- ✅ pgAdmin configuré et connecté à PostgreSQL
- ✅ Docker fonctionnel (web:8081, db:5433, pgadmin:5050)
- ✅ API Platform configurée avec préfixe
/api - ⚠️ JWT désactivé temporairement (session cookie pour compatibilité front)
✅ Phase 2 : Tests & Validation (TERMINÉE, version session)
- ✅ API Platform retourne JSON-LD correctement
- ✅ Routes session
/api/session/profile+/api/session/profilesopérationnelles - ✅ CORS prévu via Nelmio (mais variable d'env à injecter dans container)
📦 Données à Migrer (Base: inventory-data)
Total: 673 lignes réparties sur 22 tables (migrées)
| Table | Lignes | Priorité | Commentaire |
|---|---|---|---|
sites |
3 | ⚠️ Critique | Point d'entrée, relations cascades |
machines |
3 | ⚠️ Critique | Entité centrale |
type_machines |
3 | ⚠️ Critique | Configuration machines |
ModelType |
71 | 🔴 Haute | Templates composants/pièces/produits |
composants |
23 | 🔴 Haute | - |
pieces |
82 | 🔴 Haute | Plus grande table métier |
products |
2 | 🟡 Moyenne | - |
constructeurs |
20 | 🟡 Moyenne | - |
custom_fields |
95 | 🔴 Haute | Configuration champs custom |
custom_field_values |
203 | 🔴 Haute | Plus grande table (valeurs) |
documents |
14 | 🟡 Moyenne | Fichiers uploadés |
machine_component_links |
25 | 🔴 Haute | Relations machines ↔ composants |
machine_piece_links |
99 | 🔴 Haute | Relations machines ↔ pièces |
machine_product_links |
0 | 🟢 Basse | Table vide |
type_machine_*_requirements (×3) |
22 | 🟡 Moyenne | Config requirements types |
_*Constructeurs (×4) |
14 | 🟡 Moyenne | Tables de liaison ManyToMany |
profiles |
2 | 🟢 Basse | Utilisateurs session (migrés) |
Complexités identifiées (résolues ou contournées) :
- ✅ IDs CUID Prisma → conservés en
string(36)côté Doctrine (compatibles) - ✅ Champs JSON complexes importés (components, criticalParts, machinePieces, specifications)
- ✅ Relations polymorphiques Document + CustomFieldValue migrées
- ✅ Tables de liaison ManyToMany (
_*Constructeurs) migrées - ✅ Custom fields dynamiques migrés (95 définitions, 203 valeurs)
🏗️ Structure Actuelle des Projets
/home/r-dev/Inventory/
├── Inventory_backend/ # NestJS + Prisma (11k lignes)
│ ├── src/ # 18 modules NestJS
│ ├── prisma/schema.prisma # Schéma DB source
│ └── package.json
├── Inventory_frontend/ # Nuxt 3 (front principal)
│ ├── app/
│ │ ├── composables/
│ │ └── pages/
│ └── package.json
├── src/ # ⚡ Symfony backend
│ ├── Entity/ # Toutes entités créées
│ ├── Controller/ # Session + custom fields + documents + skeleton
│ └── Repository/
├── config/ # Configuration Symfony
├── migrations/ # Migrations Doctrine
└── docker-compose.yml # Web + DB + pgAdmin
🎯 Prochaines Étapes (Phase 3 - Mise en production fonctionnelle)
Étape 3.1 : Réorganisation du Projet (optionnel)
- Déplacer
Inventory_frontend/versfrontend/(si besoin) - Archiver
Inventory_backend/vers_archives/backend_nestjs/ - Mettre à jour .gitignore
Étape 3.2 : Entités & API Platform (TERMINÉE)
- Site, TypeMachine, Machine
- ModelType, Composant, Piece, Product
- Constructeur, Document
- CustomField, CustomFieldValue
- MachineComponentLink, MachinePieceLink, MachineProductLink
- TypeMachine*Requirement (×3)
Étape 3.3 : Migration des Données (TERMINÉE)
- Backup
inventory-data - Schéma Doctrine aligné
- Import
inventory-data→inventory - Validation des comptes (script
scripts/validate-migration.php)
Étape 3.4 : Front Nuxt (EN COURS)
- Base URL API ->
http://localhost:8081/api - Parsing JSON-LD dans composables
- Normalisation des relations vers IRI
- Endpoints session profile compatibles
- Corriger CORS (injecter
CORS_ALLOW_ORIGINdans container + clear cache) - Valider le squelette machine (endpoint
/api/machines/{id}/skeleton) - Valider documents catalogue (composant/piece/produit)
Étape 3.5 : Sécurité (À FAIRE)
- Réactiver JWT (après stabilisation)
- Ajouter refresh token si nécessaire
Table des Matières
- Vue d'ensemble
- Architecture Cible
- Mapping Prisma → Doctrine
- Stratégie de Migration des Données
- Configuration Docker
- Plan d'Exécution Phase par Phase
- Gestion des Risques
- Checklist de Migration
1. Vue d'ensemble
Situation Actuelle
- Backend NestJS: Port 3000, Prisma ORM, PostgreSQL, Sessions cookie-based
- Frontend Nuxt 3: Port 3001, API calls vers localhost:3000
- Base de données: PostgreSQL 16 sur port 5433, 28 modèles Prisma, données en production
- Docker: Container unique avec PHP 8.4 + Node 24.12, Apache, XDebug
Objectif Final
- Backend Symfony 8: Port 8081, Doctrine ORM, API Platform 4.2, JWT auth
- Frontend Nuxt 3: Port 3001, API calls vers localhost:8081
- Base de données: Même PostgreSQL, migration Prisma → Doctrine
- Docker: 2 backends en parallèle pendant la transition (NestJS:3000 + Symfony:8081)
Pourquoi cette migration ?
- Code généré par IA (ChatGPT) à nettoyer et reprendre en main
- Symfony + API Platform = stack PHP standard, mature, bien documentée
- JWT auth plus adapté pour API REST moderne
- Meilleure intégration avec l'écosystème PHP existant
2. Architecture Cible
Stack Technique
Backend Symfony
Symfony 8.0
├── API Platform 4.2 # REST API auto-documentée (OpenAPI)
├── Doctrine ORM 3.6 # ORM pour PostgreSQL
├── Lexik JWT Bundle # JWT authentication
├── Nelmio CORS Bundle # CORS pour frontend
├── VichUploader Bundle # Upload de documents
├── Symfony Validator # Validation des DTOs
└── Doctrine Migrations # Gestion des migrations DB
Structure de Projet
src/
├── Entity/ # 16 entités Doctrine
│ ├── Site.php
│ ├── Machine.php
│ ├── Composant.php
│ ├── Piece.php
│ ├── Product.php
│ ├── TypeMachine.php
│ ├── ModelType.php
│ ├── Constructeur.php
│ ├── Profile.php
│ ├── Document.php
│ ├── CustomField.php
│ ├── CustomFieldValue.php
│ ├── MachineComponentLink.php
│ ├── MachinePieceLink.php
│ ├── MachineProductLink.php
│ └── TypeMachine*Requirement.php (×3)
├── Repository/ # Repositories Doctrine
├── Controller/ # Controllers custom si besoin
├── State/ # State providers/processors API Platform
├── Validator/ # Contraintes de validation custom
└── Service/ # Services métier (équivalents NestJS services)
Configuration JWT
Flow d'authentification:
1. POST /api/login_check
Body: {"username": "...", "password": "..."}
Response: {"token": "eyJ0eXAiOiJKV1QiLCJhbGc..."}
2. Requêtes authentifiées
Header: Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGc...
3. Token refresh (optionnel)
POST /api/token/refresh
Body: {"refresh_token": "..."}
Configuration sécurité (config/packages/security.yaml):
security:
password_hashers:
App\Entity\Profile:
algorithm: auto
providers:
app_user_provider:
entity:
class: App\Entity\Profile
property: email # À ajouter à Profile
firewalls:
login:
pattern: ^/api/login
stateless: true
json_login:
check_path: /api/login_check
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
api:
pattern: ^/api
stateless: true
jwt: ~
access_control:
- { path: ^/api/login, roles: PUBLIC_ACCESS }
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
3. Mapping Prisma → Doctrine
3.1 Types de Données
| Prisma | Doctrine | Notes |
|---|---|---|
String |
string |
@ORM\Column(type="string") |
String @id @default(cuid()) |
Ulid |
@ORM\Id + @ORM\GeneratedValue(strategy="CUSTOM") |
Int |
integer |
@ORM\Column(type="integer") |
Decimal @db.Decimal(10,2) |
string |
@ORM\Column(type="decimal", precision=10, scale=2) |
Boolean |
bool |
@ORM\Column(type="boolean") |
DateTime |
\DateTimeImmutable |
@ORM\Column(type="datetime_immutable") |
Json |
array |
@ORM\Column(type="json") |
String[] |
array |
@ORM\Column(type="json") |
String? |
?string |
@ORM\Column(nullable=true) |
3.2 Relations
| Prisma | Doctrine | Exemple |
|---|---|---|
@relation(onDelete: Cascade) |
@ORM\JoinColumn(onDelete="CASCADE") |
Machine → Site |
@relation(onDelete: SetNull) |
@ORM\JoinColumn(nullable=true, onDelete="SET NULL") |
Composant → Product |
Model[] |
@ORM\OneToMany + Collection |
Site → machines |
Model |
@ORM\ManyToOne |
Machine → site |
Pas de @relation |
@ORM\ManyToMany |
Machine ↔ Constructeurs |
3.3 Contraintes et Index
| Prisma | Doctrine |
|---|---|
@unique |
@ORM\Column(unique=true) |
@@unique([field1, field2]) |
@ORM\UniqueConstraint(columns=["field1", "field2"]) |
@@map("table_name") |
@ORM\Table(name="table_name") |
@db.VarChar(120) |
@ORM\Column(type="string", length=120) |
@default("") |
#[ORM\Column(options: ['default' => ''])] |
3.4 Mapping Détaillé des 16 Entités
1. Site
#[ORM\Entity(repositoryClass: SiteRepository::class)]
#[ORM\Table(name: 'sites')]
#[ApiResource(
operations: [
new Get(),
new GetCollection(),
new Post(),
new Put(),
new Delete()
]
)]
class Site
{
#[ORM\Id]
#[ORM\Column(type: 'ulid', unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: UlidGenerator::class)]
private ?Ulid $id = null;
#[ORM\Column(type: 'string', length: 255)]
#[Assert\NotBlank]
private string $name;
#[ORM\Column(type: 'string', length: 255, options: ['default' => ''])]
private string $contactName = '';
#[ORM\Column(type: 'string', length: 20, options: ['default' => ''])]
private string $contactPhone = '';
#[ORM\Column(type: 'string', length: 500, options: ['default' => ''])]
private string $contactAddress = '';
#[ORM\Column(type: 'string', length: 10, options: ['default' => ''])]
private string $contactPostalCode = '';
#[ORM\Column(type: 'string', length: 100, options: ['default' => ''])]
private string $contactCity = '';
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $updatedAt;
#[ORM\OneToMany(mappedBy: 'site', targetEntity: Machine::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $machines;
#[ORM\OneToMany(mappedBy: 'site', targetEntity: Document::class, cascade: ['remove'], orphanRemoval: true)]
private Collection $documents;
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$this->createdAt = new \DateTimeImmutable();
$this->updatedAt = new \DateTimeImmutable();
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
}
2. Machine
#[ORM\Entity]
#[ORM\Table(name: 'machines')]
#[ApiResource]
class Machine
{
#[ORM\Id]
#[ORM\Column(type: 'ulid')]
private ?Ulid $id = null;
#[ORM\Column(type: 'string', length: 255, unique: true)]
private string $name;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $reference = null;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, nullable: true)]
private ?string $prix = null;
#[ORM\ManyToOne(targetEntity: Site::class, inversedBy: 'machines')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Site $site;
#[ORM\ManyToOne(targetEntity: TypeMachine::class, inversedBy: 'machines')]
#[ORM\JoinColumn(nullable: true)]
private ?TypeMachine $typeMachine = null;
#[ORM\ManyToMany(targetEntity: Constructeur::class, inversedBy: 'machines')]
#[ORM\JoinTable(name: 'machine_constructeurs')]
private Collection $constructeurs;
#[ORM\OneToMany(mappedBy: 'machine', targetEntity: MachineComponentLink::class, cascade: ['persist', 'remove'])]
private Collection $componentLinks;
#[ORM\OneToMany(mappedBy: 'machine', targetEntity: MachinePieceLink::class, cascade: ['persist', 'remove'])]
private Collection $pieceLinks;
#[ORM\OneToMany(mappedBy: 'machine', targetEntity: MachineProductLink::class, cascade: ['persist', 'remove'])]
private Collection $productLinks;
#[ORM\OneToMany(mappedBy: 'machine', targetEntity: Document::class, cascade: ['remove'])]
private Collection $documents;
#[ORM\OneToMany(mappedBy: 'machine', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'])]
private Collection $customFieldValues;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $updatedAt;
}
3. TypeMachine (avec champs JSON)
#[ORM\Entity]
#[ORM\Table(name: 'type_machines')]
#[ApiResource]
class TypeMachine
{
#[ORM\Id]
#[ORM\Column(type: 'ulid')]
private ?Ulid $id = null;
#[ORM\Column(type: 'string', length: 255, unique: true)]
private string $name;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null;
#[ORM\Column(type: 'string', length: 100, nullable: true)]
private ?string $category = null;
#[ORM\Column(type: 'string', length: 100, nullable: true)]
private ?string $maintenanceFrequency = null;
// Champs JSON (structure hiérarchique, specs techniques)
#[ORM\Column(type: 'json', nullable: true)]
private ?array $components = null;
#[ORM\Column(type: 'json', nullable: true)]
private ?array $criticalParts = null;
#[ORM\Column(type: 'json', nullable: true)]
private ?array $machinePieces = null;
#[ORM\Column(type: 'json', nullable: true)]
private ?array $specifications = null;
#[ORM\OneToMany(mappedBy: 'typeMachine', targetEntity: Machine::class)]
private Collection $machines;
#[ORM\OneToMany(mappedBy: 'typeMachine', targetEntity: CustomField::class, cascade: ['remove'])]
private Collection $customFields;
#[ORM\OneToMany(mappedBy: 'typeMachine', targetEntity: TypeMachineComponentRequirement::class, cascade: ['persist', 'remove'])]
private Collection $componentRequirements;
#[ORM\OneToMany(mappedBy: 'typeMachine', targetEntity: TypeMachinePieceRequirement::class, cascade: ['persist', 'remove'])]
private Collection $pieceRequirements;
#[ORM\OneToMany(mappedBy: 'typeMachine', targetEntity: TypeMachineProductRequirement::class, cascade: ['persist', 'remove'])]
private Collection $productRequirements;
}
4. ModelType (avec Enum)
enum ModelCategory: string
{
case COMPONENT = 'COMPONENT';
case PIECE = 'PIECE';
case PRODUCT = 'PRODUCT';
}
#[ORM\Entity]
#[ORM\Table(name: 'model_types')]
#[ORM\UniqueConstraint(name: 'unique_category_name', columns: ['category', 'name'])]
#[ApiResource]
class ModelType
{
#[ORM\Id]
#[ORM\Column(type: 'ulid')]
private ?Ulid $id = null;
#[ORM\Column(type: 'string', length: 120)]
private string $name;
#[ORM\Column(type: 'string', length: 60, unique: true)]
private string $code;
#[ORM\Column(type: 'string', enumType: ModelCategory::class)]
private ModelCategory $category;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $notes = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null;
#[ORM\Column(type: 'json', nullable: true)]
private ?array $componentSkeleton = null;
#[ORM\Column(type: 'json', nullable: true)]
private ?array $pieceSkeleton = null;
#[ORM\Column(type: 'json', nullable: true)]
private ?array $productSkeleton = null;
#[ORM\OneToMany(mappedBy: 'typeComposant', targetEntity: Composant::class)]
private Collection $composants;
#[ORM\OneToMany(mappedBy: 'typePiece', targetEntity: Piece::class)]
private Collection $pieces;
#[ORM\OneToMany(mappedBy: 'typeProduct', targetEntity: Product::class)]
private Collection $products;
// Relations avec les requirements (×3)
// Relations avec les custom fields (×3)
}
5. Document (Relations Polymorphiques)
#[ORM\Entity]
#[ORM\Table(name: 'documents')]
#[ApiResource(
normalizationContext: ['groups' => ['document:read']],
denormalizationContext: ['groups' => ['document:write']]
)]
class Document
{
#[ORM\Id]
#[ORM\Column(type: 'ulid')]
private ?Ulid $id = null;
#[ORM\Column(type: 'string', length: 255)]
private string $name;
#[ORM\Column(type: 'string', length: 255)]
private string $filename;
#[ORM\Column(type: 'string', length: 500)]
private string $path;
#[ORM\Column(type: 'string', length: 100)]
private string $mimeType;
#[ORM\Column(type: 'integer')]
private int $size;
// Relations polymorphiques (nullable)
#[ORM\ManyToOne(targetEntity: Machine::class, inversedBy: 'documents')]
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
private ?Machine $machine = null;
#[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'documents')]
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
private ?Composant $composant = null;
#[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'documents')]
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
private ?Piece $piece = null;
#[ORM\ManyToOne(targetEntity: Product::class, inversedBy: 'documents')]
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
private ?Product $product = null;
#[ORM\ManyToOne(targetEntity: Site::class, inversedBy: 'documents')]
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
private ?Site $site = null;
}
6-16. Autres Entités
Les autres entités suivent le même pattern :
- Composant, Piece, Product : Similaires à Machine avec relations vers ModelType
- Constructeur : ManyToMany avec Machine/Composant/Piece/Product
- Profile : Entité utilisateur (à enrichir avec email/password pour JWT)
- CustomField, CustomFieldValue : Système de champs dynamiques
- MachineComponentLink, MachinePieceLink, MachineProductLink : Tables de liaison avec overrides
- TypeMachine*Requirement (×3) : Définition des requirements (minCount, maxCount, label, etc.)
4. Stratégie de Migration des Données
4.1 Problème : CUID vs ULID
Prisma utilise CUID (@default(cuid())) :
- Format:
clabcd123xyz...(25 caractères) - Exemple:
cldu8kzj30000vh8q9k0y8h7i
Doctrine recommande ULID :
- Format:
01ARZ3NDEKTSV4RRFFQ69G5FAV(26 caractères) - Type Symfony :
Symfony\Component\Uid\Ulid
Solutions possibles :
Option A : Garder les CUID comme chaînes (RECOMMANDÉE)
#[ORM\Id]
#[ORM\Column(type: 'string', length: 30)]
private string $id;
Avantages :
- ✅ Aucune modification des IDs existants
- ✅ Migration transparente, pas de mapping d'IDs
- ✅ Relations FK préservées
- ✅ Frontend continue à utiliser les mêmes IDs
Inconvénients :
- ❌ Pas de type Ulid natif Symfony
- ❌ Moins "moderne"
Option B : Migrer CUID → ULID avec mapping
#[ORM\Id]
#[ORM\Column(type: 'ulid')]
private Ulid $id;
Nécessite :
- Créer une table de mapping
cuid_to_ulid - Générer de nouveaux ULIDs pour chaque ligne
- Mettre à jour toutes les FK
- Adapter le frontend
Risque : Complexité élevée, risque de perte de données.
4.2 Approche Recommandée : Migration en 3 Étapes
Étape 1 : Créer le schéma Doctrine en parallèle (SAFE)
-
Générer les migrations Doctrine sans exécution :
php bin/console doctrine:migrations:diff -
NE PAS exécuter la migration immédiatement
-
Analyser le SQL généré pour détecter les différences :
- Noms de colonnes (camelCase → snake_case)
- Types de données (Decimal en Prisma → string en Doctrine)
- Contraintes (vérifier les FK, indexes)
Étape 2 : Script de Validation (Dry-run)
Créer un script PHP qui :
- Lit toutes les tables Prisma actuelles
- Compare avec le schéma Doctrine généré
- Génère un rapport de différences
- Valide que toutes les données peuvent être mappées
// scripts/validate-migration.php
use Doctrine\ORM\EntityManagerInterface;
class MigrationValidator
{
public function validate(): array
{
$issues = [];
// Vérifier chaque table
foreach ($this->getTables() as $table) {
// Comparer schéma Prisma vs Doctrine
$schemaIssues = $this->compareSchemas($table);
// Vérifier les données peuvent être migrées
$dataIssues = $this->validateData($table);
$issues = array_merge($issues, $schemaIssues, $dataIssues);
}
return $issues;
}
}
Étape 3 : Migration réelle (avec Backup)
# 1. BACKUP de la DB
pg_dump -U postgres -h localhost -p 5433 inventory_db > backup_$(date +%Y%m%d_%H%M%S).sql
# 2. Exécuter les migrations Doctrine
php bin/console doctrine:migrations:migrate --no-interaction
# 3. Vérifier l'intégrité
php bin/console doctrine:schema:validate
# 4. Si erreur : ROLLBACK
psql -U postgres -h localhost -p 5433 inventory_db < backup_YYYYMMDD_HHMMSS.sql
4.3 Mapping des Noms de Colonnes
Prisma → Doctrine naming strategy :
| Prisma | Doctrine | Action |
|---|---|---|
siteId |
site_id |
Annotation #[ORM\JoinColumn(name="siteId")] ou laisser Doctrine snake_case |
createdAt |
created_at |
Idem |
contactName |
contact_name |
Idem |
Recommandation : Utiliser #[ORM\Column(name="xxx")] pour garder les noms Prisma existants et éviter les ALTER TABLE.
4.4 Checklist de Migration DB
- Créer backup complet de la DB
- Générer migration Doctrine (
doctrine:migrations:diff) - Analyser SQL généré manuellement
- Tester migration sur copie locale de la DB
- Valider que les données sont intactes après migration
- Créer script de rollback
- Documenter les différences schéma Prisma vs Doctrine
- Tester toutes les requêtes via Doctrine
- Vérifier les performances (indexes, FK)
5. Configuration Docker
5.1 Setup : 2 Backends en Parallèle
Objectif : Faire tourner NestJS (port 3000) ET Symfony (port 8081) simultanément pendant la transition.
Modification docker-compose.yml
services:
web:
container_name: php-${DOCKER_APP_NAME}-apache
build:
context: ./docker/php
dockerfile: Dockerfile
args:
DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION}
DOCKER_NODE_VERSION: ${DOCKER_NODE_VERSION}
CURRENT_UID: ${CURRENT_UID}
CURRENT_GID: ${CURRENT_GID}
environment:
PHP_IDE_CONFIG: serverName=${DOCKER_APP_NAME}-docker
XDEBUG_CLIENT_HOST: ${XDEBUG_CLIENT_HOST:-host.docker.internal}
XDEBUG_CONFIG: client_host=${XDEBUG_CLIENT_HOST:-host.docker.internal} client_port=9003
DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?serverVersion=16&charset=utf8"
# Variables pour NestJS
NESTJS_DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public"
NESTJS_PORT: 3000
NESTJS_CORS_ORIGIN: "http://localhost:3001"
# Variables pour Symfony
APP_ENV: dev
APP_SECRET: ${APP_SECRET}
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY}
JWT_PASSPHRASE: ${JWT_PASSPHRASE}
volumes:
- ./:/var/www/html
- ~/.cache:/var/www/.cache
- ~/.config:/var/www/.config
- ~/.composer:/var/www/.composer
- ./docker/php/config/php.ini:/usr/local/etc/php/php.ini
- ./docker/php/config/vhost.conf:/etc/apache2/sites-available/000-default.conf
- ./docker/php/config/docker-php-ext-xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
- ./LOG:/var/www/html/LOG
- ./LOG/logs_apache:/var/log/apache2/
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
- db
ports:
- "8081:80" # Apache/Symfony
- "3000:3000" # NestJS backend (nouveau mapping)
- "3001:3001" # Nuxt frontend
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pg_data:/var/lib/postgresql/data
ports:
- "${POSTGRES_PORT:-5433}:5432"
restart: unless-stopped
volumes:
pg_data:
Configuration Apache VirtualHost
# /docker/php/config/vhost.conf
<VirtualHost *:80>
ServerName localhost
DocumentRoot /var/www/html/public
<Directory /var/www/html/public>
AllowOverride All
Require all granted
FallbackResource /index.php
</Directory>
# Logs
ErrorLog /var/log/apache2/error.log
CustomLog /var/log/apache2/access.log combined
</VirtualHost>
Script de Démarrage des 2 Backends
Créer /docker/start-services.sh :
#!/bin/bash
# Démarrer Apache (Symfony)
apache2-foreground &
# Installer dépendances NestJS si besoin
cd /var/www/html/Inventory_backend
if [ ! -d "node_modules" ]; then
npm install
fi
# Démarrer NestJS
npm run start:dev &
# Installer dépendances Nuxt si besoin
cd /var/www/html/Inventory_frontend
if [ ! -d "node_modules" ]; then
npm install
fi
# Démarrer Nuxt
npm run dev &
# Garder le container actif
wait -n
Modifier Dockerfile pour utiliser ce script :
# À la fin du Dockerfile
COPY start-services.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/start-services.sh
CMD ["start-services.sh"]
5.2 Variables d'Environnement
Créer /docker/.env.docker.local (déjà existant, à compléter) :
# Existant
DOCKER_APP_NAME=inventory
DOCKER_PHP_VERSION=8.4.6
DOCKER_NODE_VERSION=24.12.0
CURRENT_UID=1000
CURRENT_GID=1000
POSTGRES_DB=inventory_db
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_PORT=5433
# Nouveau : Symfony
APP_ENV=dev
APP_SECRET=changeme_super_secret_key_123456789
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=your_jwt_passphrase
# Nouveau : NestJS
NESTJS_PORT=3000
SESSION_SECRET=changeme_session_secret
CORS_ORIGIN=http://localhost:3001
5.3 Génération des Clés JWT
Ajouter au script de démarrage :
# Générer clés JWT si inexistantes
mkdir -p /var/www/html/config/jwt
if [ ! -f /var/www/html/config/jwt/private.pem ]; then
openssl genpkey -out /var/www/html/config/jwt/private.pem -aes256 -algorithm rsa -pkeyopt rsa_keybits:4096 -pass pass:${JWT_PASSPHRASE}
openssl pkey -in /var/www/html/config/jwt/private.pem -passin pass:${JWT_PASSPHRASE} -out /var/www/html/config/jwt/public.pem -pubout
chmod 600 /var/www/html/config/jwt/*.pem
fi
6. Plan d'Exécution Phase par Phase
Phase 1 : Préparation (Semaine 1)
Tâches
-
Installer bundles Symfony :
composer require lexik/jwt-authentication-bundle composer require vich/uploader-bundle composer require symfony/uid -
Configurer JWT :
- Générer clés RSA
- Configurer
lexik_jwt_authentication.yaml - Configurer
security.yaml
-
Créer entité Profile enrichie :
class Profile implements UserInterface { // Ajouter email, password, roles } -
Analyser schéma Prisma :
- Documenter toutes les relations
- Identifier les champs JSON complexes
- Lister les contraintes de validation
-
Setup Docker :
- Modifier
docker-compose.yml - Créer script de démarrage multi-services
- Tester les 2 backends en parallèle
- Modifier
Livrables
- Symfony configuré avec tous les bundles
- JWT fonctionnel avec endpoint
/api/login_check - Docker lance NestJS (3000) + Symfony (8081) + Nuxt (3001)
- Documentation schéma Prisma complète
Phase 2 : Création des Entités (Semaine 2-3)
Tâches
-
Créer toutes les entités Doctrine (16 entités) :
- Utiliser les attributs PHP 8 (
#[ORM\Entity]) - Mapper exactement le schéma Prisma
- Ajouter annotations API Platform
- Implémenter les lifecycle callbacks (
#[ORM\PrePersist], etc.)
- Utiliser les attributs PHP 8 (
-
Générer repositories :
php bin/console make:entity --regenerate -
Créer migration Doctrine :
php bin/console doctrine:migrations:diff -
Analyser SQL généré :
- Comparer avec schéma Prisma actuel
- Identifier les différences
- Préparer script d'ajustement si besoin
-
Tester sur DB vide :
# Créer DB test createdb inventory_test # Appliquer migrations php bin/console doctrine:migrations:migrate --env=test # Valider schéma php bin/console doctrine:schema:validate
Livrables
- 16 entités Doctrine complètes avec relations
- Migration Doctrine générée et validée
- Tests sur DB vide réussis
- Documentation des différences Prisma/Doctrine
Phase 3 : Migration de la Base de Données (Semaine 4)
Tâches
-
Backup complet :
pg_dump -U postgres -h localhost -p 5433 inventory_db > backup_avant_migration.sql -
Créer script de validation :
// scripts/validate-schema.php // Comparer schéma Prisma vs Doctrine -
Exécuter migration Doctrine :
php bin/console doctrine:migrations:migrate --no-interaction -
Valider données migrées :
- Compter les lignes par table (Prisma count vs Doctrine count)
- Vérifier intégrité référentielle
- Tester quelques requêtes
-
Tests de régression :
- Lire toutes les entités via Doctrine
- Vérifier les relations
- Tester les champs JSON
Livrables
- Base de données migrée avec succès
- Backup de sécurité créé
- Script de validation OK (0 différences)
- Tests de lecture/écriture via Doctrine OK
Phase 4 : Logique Métier (Semaine 5-6)
Tâches
- Migrer services NestJS → Symfony :
Mapping des services :
| NestJS Service | Symfony Service | Responsabilités |
|---|---|---|
SitesService |
SiteManager |
CRUD sites |
MachinesService |
MachineManager |
CRUD machines, reconfiguration |
ComposantsService |
ComposantManager |
CRUD composants |
PiecesService |
PieceManager |
CRUD pieces |
ProductsService |
ProductManager |
CRUD products |
TypesService |
TypeMachineManager |
Gestion types machines |
ModelTypeService |
ModelTypeManager |
Gestion model types |
DocumentsService |
DocumentManager |
Upload/download documents |
CustomFieldsService |
CustomFieldManager |
Gestion custom fields |
ConstructeursService |
ConstructeurManager |
Gestion constructeurs |
ProfilesService |
ProfileManager |
Gestion profiles |
SessionService |
Supprimé (JWT) | - |
-
Implémenter upload de documents :
- Configurer VichUploaderBundle
- Créer endpoint
POST /api/documents - Créer endpoint
GET /api/documents/{id}/download
-
Implémenter custom fields :
- Service de gestion des définitions
- Service de gestion des valeurs
- Validation dynamique
-
Créer State Processors API Platform :
- Pour logique custom (ex: reconfiguration machine)
- Pour validation complexe
-
Tests unitaires :
- Tester chaque service
- Mocker les repositories
- Vérifier logique métier
Livrables
- Tous les services métier migrés
- Upload/download documents fonctionnel
- Custom fields opérationnels
- Tests unitaires couvrant 80%+ du code
Phase 5 : API Platform (Semaine 7)
Tâches
-
Configurer les endpoints API Platform :
- Vérifier routes auto-générées
- Customiser opérations si besoin
- Ajouter filtres de recherche
- Configurer pagination
-
Sérialisation :
- Créer groupes de normalisation
- Gérer relations circulaires
- Optimiser avec
@Groups
-
Validation :
- Ajouter contraintes de validation
- Créer validators custom
- Gérer messages d'erreur
-
Documentation API :
- Générer OpenAPI spec
- Tester via Swagger UI
- Documenter endpoints custom
-
Tests d'intégration :
- Tester tous les endpoints CRUD
- Vérifier codes de réponse HTTP
- Valider structure JSON
Livrables
- Tous les endpoints API fonctionnels
- Documentation OpenAPI complète
- Tests d'intégration OK
- Swagger UI accessible sur
/api/docs
Phase 6 : Adaptation Frontend (Semaine 8)
Tâches
-
Modifier
useApi.js:// Ancien const baseURL = 'http://localhost:3000'; // Nouveau const baseURL = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'; -
Implémenter gestion JWT :
// composables/useAuth.js export const useAuth = () => { const token = ref(localStorage.getItem('jwt_token')); const login = async (username, password) => { const response = await fetch('/api/login_check', { method: 'POST', body: JSON.stringify({ username, password }) }); const { token } = await response.json(); localStorage.setItem('jwt_token', token); token.value = token; }; const logout = () => { localStorage.removeItem('jwt_token'); token.value = null; }; return { token, login, logout }; }; -
Adapter les composables :
useSites.js→ vérifier format réponseuseMachines.js→ idem- Tous les autres composables
-
Gestion des changements d'API :
- Ajuster parsing des réponses JSON:API vs JSON simple
- Gérer les nouveaux formats d'erreur
- Adapter les IDs si changement
-
Tests fonctionnels :
- Tester chaque page du frontend
- Vérifier tous les flows utilisateur
- Tester upload/download documents
Livrables
- Frontend connecté au backend Symfony
- Authentification JWT fonctionnelle
- Tous les composables adaptés
- Tests fonctionnels réussis
Phase 7 : Tests & Décommissionnement NestJS (Semaine 9)
Tâches
-
Tests de charge :
- Comparer performances NestJS vs Symfony
- Vérifier temps de réponse
- Tester avec données réelles
-
Tests de régression complète :
- Rejouer tous les scénarios utilisateur
- Comparer résultats NestJS vs Symfony
- Fixer les bugs
-
Documentation :
- Documenter nouvelle architecture
- Créer guide migration pour équipe
- Documenter différences API
-
Décommissionner NestJS :
- Arrêter le service NestJS
- Supprimer du docker-compose
- Archiver le code (ne pas supprimer)
-
Cleanup :
- Supprimer dépendances NestJS inutilisées
- Nettoyer fichiers de config
- Optimiser Docker
Livrables
- Tests de charge validés
- 0 bugs de régression
- Documentation complète
- NestJS arrêté, Symfony en production
- Code NestJS archivé
7. Gestion des Risques
7.1 Risques Techniques
| Risque | Probabilité | Impact | Mitigation |
|---|---|---|---|
| Perte de données pendant migration DB | Faible | Critique | Backup complet avant migration, script de rollback, validation post-migration |
| Incompatibilité schéma Prisma/Doctrine | Moyenne | Élevé | Validation schema dry-run, tests sur DB copie, garder noms de colonnes Prisma |
| Performances Symfony < NestJS | Faible | Moyen | Benchmarks, optimisation requêtes Doctrine, cache API Platform |
| Bugs lors migration logique métier | Moyenne | Élevé | Tests unitaires + intégration, comparison NestJS/Symfony side-by-side |
| JWT incompatible avec frontend | Faible | Moyen | Tests d'auth dès Phase 1, documentation claire |
| Upload documents ne fonctionne pas | Faible | Moyen | Tester VichUploader tôt, fallback manuel si besoin |
| Relations Doctrine mal configurées | Moyenne | Élevé | Tests avec données réelles, validation via doctrine:schema:validate |
7.2 Risques Projet
| Risque | Probabilité | Impact | Mitigation |
|---|---|---|---|
| Timeline trop optimiste | Élevée | Moyen | Buffer de 2 semaines, découpage en phases indépendantes |
| Manque de tests | Moyenne | Élevé | Écrire tests dès Phase 2, coverage > 80% |
| Documentation insuffisante | Moyenne | Moyen | Documenter au fur et à mesure, pas à la fin |
| Réversion nécessaire | Faible | Critique | Garder NestJS actif en parallèle, backup DB, script de rollback |
7.3 Plan de Rollback
Si problème critique :
-
Arrêter Symfony :
docker-compose stop web -
Restaurer DB depuis backup :
psql -U postgres -h localhost -p 5433 inventory_db < backup_avant_migration.sql -
Redémarrer NestJS :
cd Inventory_backend && npm run start:dev -
Reconfigurer frontend :
// Revert API URL const baseURL = 'http://localhost:3000'; -
Analyser l'erreur :
- Lire logs Symfony (
var/log/dev.log) - Lire logs PostgreSQL
- Identifier root cause
- Lire logs Symfony (
-
Fix et retry :
- Corriger le problème
- Retester en local
- Relancer migration
8. Checklist de Migration
Avant de Commencer
- Lire entièrement ce document
- Comprendre schéma Prisma actuel
- Valider que Docker fonctionne
- Créer backup DB initial
- Setup environnement de dev local
Phase 1 : Préparation
- Installer bundles Symfony (JWT, VichUploader, Uid)
- Configurer JWT avec clés RSA
- Enrichir entité Profile (email, password, roles)
- Modifier docker-compose.yml pour 2 backends
- Tester démarrage des 2 backends en parallèle
- Documenter schéma Prisma
Phase 2 : Entités
- Créer 16 entités Doctrine
- Ajouter annotations API Platform
- Générer migration Doctrine
- Analyser SQL généré
- Tester migration sur DB vide
- Valider schéma Doctrine
Phase 3 : Migration DB
- Créer backup DB production
- Créer script de validation
- Exécuter migration Doctrine
- Appliquer migrations post-migration (ajouts colonnes, types JSON)
- Valider count de lignes par table
- Tester requêtes Doctrine
- Vérifier intégrité référentielle
Phase 4 : Services
- Migrer les 11 services métier
- Implémenter upload documents
- Implémenter custom fields
- Écrire tests unitaires
- Valider logique métier
Phase 5 : API
- Configurer endpoints API Platform
- Implémenter sérialisation
- Ajouter validation
- Générer doc OpenAPI
- Tests d'intégration
Phase 6 : Frontend
- Modifier useApi.js (nouveau baseURL)
- Implémenter gestion JWT
- Adapter tous les composables
- Tester toutes les pages
- Valider flows utilisateur
Phase 7 : Finalisation
- Tests de charge
- Tests de régression complète
- Documenter nouvelle architecture
- Arrêter NestJS
- Archiver code NestJS
- Cleanup Docker
Annexes
A. Commandes Utiles
# Symfony
php bin/console doctrine:schema:validate
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate
php bin/console doctrine:query:sql "SELECT COUNT(*) FROM sites"
php bin/console debug:router
php bin/console cache:clear
# Docker
docker-compose up -d --build
docker-compose logs -f web
docker-compose exec web bash
docker-compose down
# Base de données
pg_dump -U postgres -h localhost -p 5433 inventory_db > backup.sql
psql -U postgres -h localhost -p 5433 inventory_db < backup.sql
psql -U postgres -h localhost -p 5433 inventory_db -c "SELECT COUNT(*) FROM sites"
# Tests
php bin/phpunit
php vendor/bin/phpstan analyse src
B. Structure Finale du Projet
/home/r-dev/Inventory/
├── Inventory_backend/ # À archiver après migration
├── Inventory_frontend/ # Frontend Nuxt (à garder)
│ ├── app/
│ │ ├── composables/
│ │ │ ├── useApi.js # Modifier baseURL
│ │ │ ├── useAuth.js # Nouveau : JWT
│ │ │ └── ...
│ │ └── pages/
├── src/ # Backend Symfony (nouveau)
│ ├── Entity/
│ │ ├── Site.php
│ │ ├── Machine.php
│ │ └── ... (16 entités)
│ ├── Repository/
│ ├── Service/
│ │ ├── SiteManager.php
│ │ ├── MachineManager.php
│ │ └── ... (11 services)
│ ├── Controller/ # Si besoin de custom controllers
│ └── State/ # State providers API Platform
├── config/
│ ├── packages/
│ │ ├── doctrine.yaml
│ │ ├── api_platform.yaml
│ │ ├── lexik_jwt_authentication.yaml
│ │ ├── security.yaml
│ │ └── nelmio_cors.yaml
│ └── jwt/
│ ├── private.pem
│ └── public.pem
├── migrations/ # Migrations Doctrine
├── docker/
│ ├── php/
│ │ ├── Dockerfile
│ │ └── config/
│ ├── start-services.sh # Nouveau : lance Symfony + Nuxt
│ ├── .env.docker
│ └── .env.docker.local
├── docker-compose.yml # Modifié : 2 backends
├── composer.json # Symfony dependencies
└── MIGRATION_PLAN.md # Ce document
C. Ressources
- API Platform Docs
- Doctrine ORM Docs
- Lexik JWT Bundle
- Symfony Best Practices
- Prisma to Doctrine Migration Guide
Conclusion
Ce plan de migration détaille la stratégie pour passer de NestJS/Prisma à Symfony/API Platform de manière progressive et sécurisée. Les points clés :
- Migration incrémentale : Garder NestJS actif en parallèle
- Sécurité des données : Backups, validation, rollback
- Modernisation : JWT auth, API Platform, documentation OpenAPI
- Minimiser les risques : Tests à chaque phase, validation continue
Durée estimée : 9 semaines (avec buffer) Effort requis : 1 développeur full-time
Prêt à démarrer ? Validation de ce plan avant de commencer la Phase 1.