🍃 Spring Boot, Symfony, Laravel, NestJS : quatre frameworks, trois langages, une architecture

📚 Introduction
J’ai passé l’essentiel de ma carrière dans PHP, entre Symfony et Laravel. J’avais bien touché à Java, mais il y a une dizaine d’années. Autant dire un autre langage : à l’époque, ni record, ni var, ni switch expressif, et Spring Boot était encore jeune. Alors quand un projet m’a remis les mains dedans, je m’attendais à ramer. Outillage oublié, écosystème méconnaissable, deux semaines rien qu’à comprendre où sont rangées les choses.
Ça n’est pas arrivé. Au bout de deux jours, je savais où chercher. Ma vieille mémoire de Java n’y était pour rien : la syntaxe avait bien trop bougé. C’est la structure qui m’était restée familière.
Spring Boot, Symfony et Laravel ne se ressemblent pas par accident. Ajoutez NestJS côté Node et vous avez quatre frameworks, trois langages, qui répondent tous à la même question : comment tenir une application serveur pendant cinq ans sans qu’elle pourrisse. Et qui finissent par sortir à peu près les mêmes réponses : un conteneur d’injection, du routing déclaratif, des repositories, une configuration qui change selon l’environnement.
🚀 Spring Boot en cinq minutes
Spring Boot, c’est la couche « opinionated » posée sur Spring Framework. Sa documentation résume l’intention mieux que je ne le ferais : être opinionated par défaut, et s’effacer dès que les besoins divergent.
Version courante : Spring Boot 4.1, sortie le 10 juin 2026, bâtie sur Spring Framework 7 et Jakarta EE 11. Le socle est Java 17, avec une prise en charge native de Java 25 (la LTS livrée le 16 septembre 2025) et une compatibilité jusqu’à Java 26.
En pratique, on part de Spring Initializr et on récupère un projet Maven ou Gradle équipé de starters : des méta-dépendances qui tirent une pile cohérente et la configurent toutes seules.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>Cet unique artefact amène Tomcat embarqué, la sérialisation JSON, la validation et toute la pile MVC. Zéro ligne de configuration. À l’arrivée, un JAR exécutable : java -jar app.jar, et l’application écoute. Si ça vous rappelle composer require symfony/… et les recipes Flex, c’est normal : même idée derrière, la dépendance embarque sa propre configuration par défaut.
🔎 Pourquoi Spring Boot semble-t-il si familier à un développeur Symfony ?
Parce que Symfony s’est ouvertement inspiré de Spring : le conteneur de services, l’injection par constructeur, la configuration déclarative. Laravel a bâti sur les mêmes fondations, avec une ergonomie à lui. Les mots changent, la mécanique est la même.
Un contrôleur Spring Boot :
@RestController
@RequestMapping("/invoices")
class InvoiceController {
private final InvoiceService invoices;
InvoiceController(InvoiceService invoices) {
this.invoices = invoices;
}
@GetMapping("/{id}")
Invoice show(@PathVariable Long id) {
return invoices.findOrFail(id);
}
}Le même contrôleur en Symfony :
#[Route('/invoices')]
final class InvoiceController extends AbstractController
{
public function __construct(private readonly InvoiceService $invoices) {}
#[Route('/{id}', name: 'invoice_show', methods: ['GET'])]
public function show(int $id): JsonResponse
{
return $this->json($this->invoices->findOrFail($id));
}
}Et le même encore en NestJS, côté TypeScript :
@Controller('invoices')
export class InvoicesController {
constructor(private invoicesService: InvoicesService) {}
@Get(':id')
findOne(@Param('id') id: string) {
return this.invoicesService.findOrFail(id);
}
}Injection par constructeur, service métier isolé, route déclarée juste au-dessus de la méthode, sérialisation du retour prise en charge. Trois langages, trois runtimes, une seule forme. Remplacez les mots-clés, vous obtenez le voisin. Laravel est le seul du lot à ranger ses routes ailleurs, dans routes/api.php, mais son service container fonctionne sur le même principe, et les attributs PHP arrivés avec Laravel 13 rapprochent encore les styles.
Côté persistance, même écho. Spring Data JPA va jusqu’à dériver la requête du nom de la méthode :
interface InvoiceRepository extends JpaRepository<Invoice, Long> {
List<Invoice> findByStatusOrderByIssuedAtDesc(InvoiceStatus status);
}Doctrine vous fait écrire le corps, mais l’intention est identique : une classe par agrégat, qui garde le SQL loin du reste du code.
final class InvoiceRepository extends ServiceEntityRepository
{
/** @return Invoice[] */
public function findByStatus(InvoiceStatus $status): array
{
return $this->findBy(['status' => $status], ['issuedAt' => 'DESC']);
}
}🧩 La table de correspondance
La correspondance tient en six lignes :
| Spring Boot | Symfony | Laravel | NestJS |
|---|---|---|---|
@Service / @Component | services.yaml + autowiring | Service container | @Injectable() + modules |
@RestController/@GetMapping | #[Route] | Route::get() | @Controller / @Get |
| Spring Data JPA | Doctrine ORM | Eloquent | TypeORM ou Prisma |
Starters spring-boot-starter-* | Bundles + recipes Flex | Packages + auto-discovery | Packages @nestjs/* |
application.yml + profils | config/packages/ + .env | config/ + .env | @nestjs/config + .env |
@Scheduled, @Async | Messenger, Scheduler | Queues, Scheduler | @nestjs/schedule, BullMQ |
Une nuance sur la dernière ligne : Spring livre @Async et l’intégration Kafka ou RabbitMQ dans le framework lui-même, là où les trois autres passent par un composant dédié : Symfony Messenger, les queues Laravel, @nestjs/bullmq. On arrive au même endroit par un chemin différent.
Le tableau se lit dans les deux sens, d’ailleurs. NestJS n’a pas convergé vers Spring par hasard : il assume s’être inspiré d’Angular, qui tenait lui-même ses décorateurs et son injection de dépendances de la même tradition. Quand un développeur Symfony ouvre du NestJS, il ne découvre pas TypeScript, il retrouve son conteneur de services avec une autre syntaxe.
Il reste une case où personne ne suit vraiment : Spring Boot Actuator. Santé, métriques et informations d’exécution exposées sur /actuator/health et consorts, sans écrire une ligne. NestJS s’en approche le plus avec @nestjs/terminus, mais Terminus couvre les health checks, pas les métriques ; côté PHP, il n’y a rien d’équivalent en standard. C’est le genre de détail qui fait gagner une journée le jour où on branche le monitoring.
Et il ne s’arrête pas à sa propre interface. Actuator délègue les métriques à Micrometer, qui joue le rôle de façade : on choisit le format en ajoutant un registre au classpath, sans toucher au code instrumenté. Avec micrometer-registry-prometheus, l’application publie un endpoint /actuator/prometheus qu’un Prometheus va scraper comme n’importe quelle cible, exactement le ServiceMonitor que déclare kube-prometheus-stack :
scrape_configs:
- job_name: 'spring'
metrics_path: '/actuator/prometheus'
static_configs:
- targets: ['HOST:PORT']Remplacez ce registre par micrometer-registry-otlp et les mêmes métriques partent en OpenTelemetry, vers un collector ou directement vers une plateforme comme SigNoz :
management.otlp.metrics.export.url=https://otlp.example.com:4318/v1/metricsLes traces suivent le même chemin, via le starter spring-boot-starter-opentelemetry et les propriétés management.opentelemetry.tracing.export.otlp.*. C’est là que Micrometer prend tout son sens. On n’arbitre pas entre Prometheus et OTLP au démarrage du projet : ce sont deux exporteurs interchangeables derrière la même instrumentation. Vous scrapez aujourd’hui, vous poussez en OTLP l’an prochain, et le code métier ne bouge pas.
☕ Ce que Java apporte vraiment
Le compilateur travaille pour vous. Génériques, record, classes scellées, switch exhaustif : beaucoup d’erreurs que PHP découvre au runtime, Java refuse tout bonnement de les compiler. PHP 8 a des types, mais ils sont vérifiés à l’exécution, et l’analyse statique reste un outil qu’on ajoute par-dessus. SonarQube et PHPStan comblent une grande partie de l’écart. Une grande partie seulement.
Le processus reste en vie. Une JVM tourne pendant des jours. Elle garde ses caches, ses pools de connexions et ses index en mémoire, et le JIT l’optimise au fil des exécutions. Le modèle PHP historique, un processus par requête où tout repart de zéro, se raisonne plus facilement, mais il repaie le démarrage du framework à chaque appel. C’est exactement ce coût que le worker mode de FrankenPHP est venu supprimer, et l’écart s’est nettement réduit depuis. Sans disparaître.
Vient ensuite le parallélisme, et là il n’y a pas photo. Depuis Java 21, les threads virtuels permettent d’écrire du code bloquant, parfaitement lisible, qui encaisse des dizaines de milliers de requêtes concurrentes. Côté Spring Boot, ça tient sur une ligne :
spring.threads.virtual.enabled=trueL’outillage de diagnostic joue dans la même catégorie. JDK Flight Recorder, les heap dumps, les profilers d’allocation : on ausculte une application de production sans la mettre à genoux, avec un niveau de détail que l’écosystème PHP n’a pas encore atteint.
Et puis il y a l’écosystème ennuyeux. Des drivers JDBC pour à peu près tout ce qui stocke des données, les clients Kafka de référence, les SDK bancaires et assurantiels, les outils de traitement de données. Spring Boot 4.1 y a d’ailleurs ajouté Spring gRPC. Quand une intégration exotique existe quelque part, elle existe généralement en Java d’abord.
La facture, maintenant, parce qu’il y en a une. Spring Boot 4 a modularisé son code pour produire des JAR plus petits, mais l’empreinte mémoire d’une JVM reste sans commune mesure avec un pool PHP-FPM. Les temps de build Maven ou Gradle ne jouent pas dans la même cour qu’un composer install. Et la verbosité du langage est bien réelle, même si les record ont beaucoup arrangé les choses.
⚠️ Quelques précautions
L’auto-configuration est magique jusqu’au jour où elle ne l’est plus. Le jour où un starter instancie un bean dont vous ne vouliez pas, la pile d’appels est profonde et l’ambiance change. --debug sort le rapport de conditions : apprenez à le lire avant d’en avoir besoin.
Posez le versioning d’API dès le premier jour. Spring Framework 7 l’intègre nativement, ce qui évite de voir /v1/ et /v2/ proliférer dans les chemins :
@GetMapping(version = "1.2+")
Account getAccount() { … }Ne choisissez pas Java pour prouver quelque chose. « C’est plus sérieux » n’a jamais été un argument technique. Si votre équipe est PHP, qu’elle livre correctement et que la charge passe, une réécriture en Spring Boot ne réglera aucun problème d’architecture. Elle les traduira dans une syntaxe que personne ne maîtrise encore.
On ne déploie pas un JAR comme une application PHP. Il se conteneurise très bien, mais les arbitrages diffèrent de ceux d’une image PHP multi-stage. Le piège classique, c’est le COPY app.jar en une seule ligne : le fat jar pèse des dizaines de mégaoctets dont l’immense majorité ne bouge jamais, et la moindre correction d’une ligne de code invalide la couche entière.
Spring Boot répond avec un jar en couches, qu’on extrait au build pour que Docker cache séparément ce qui change tous les jours et ce qui change tous les six mois :
# Étage 1 : on extrait les couches du fat jar
FROM bellsoft/liberica-openjre-debian:25-cds AS builder
WORKDIR /builder
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} application.jar
RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted
# Étage 2 : l'image finale, une couche par famille de fichiers
FROM bellsoft/liberica-openjre-debian:25-cds
WORKDIR /application
COPY --from=builder /builder/extracted/dependencies/ ./
COPY --from=builder /builder/extracted/spring-boot-loader/ ./
COPY --from=builder /builder/extracted/snapshot-dependencies/ ./
COPY --from=builder /builder/extracted/application/ ./
ENTRYPOINT ["java", "-jar", "application.jar"]L’ordre des COPY est tout l’intérêt du fichier : les dépendances arrivent en premier parce qu’elles ne changent qu’à la mise à jour du pom.xml, votre code arrive en dernier. Sur un déploiement quotidien, seule la dernière couche est reconstruite et poussée. C’est le même raisonnement que composer install avant COPY . dans une image PHP, appliqué à un artefact Java.
Un mot sur -Djarmode=tools : l’ancien -Djarmode=layertools, qu’on croise encore dans beaucoup de tutoriels, est déprécié depuis Spring Boot 3.3. Une image JRE suffit pour l’exécution, inutile d’embarquer le JDK. Et si le temps de démarrage compte vraiment, il reste l’AOT cache de Java 25 ou une compilation native GraalVM.
🎉 Conclusion
Ce qui m’a marqué en arrivant sur Spring Boot, ce n’est pas ce qui changeait. C’est tout ce qui ne changeait pas.
Ce qui décide du sort d’une application sur cinq ans ne s’écrit dans aucun langage : où passent les frontières de domaine, dans quel sens vont les dépendances, ce qui reste testable sans base de données, ce qu’on arrive à observer en production, comment le modèle de données encaisse le prochain changement de règle métier. Aucune de ces questions n’a de réponse en Java ou en PHP. Elles ont des réponses en architecture.
Un projet Symfony bien découpé se maintient mieux qu’un Spring Boot où tout est empilé dans les contrôleurs. Et réciproquement. Quatre frameworks et trois langages plus loin, le tableau de correspondance ne dit rien d’autre : ce qu’on croit être une compétence Spring, Symfony ou NestJS est presque toujours une compétence d’architecture qui a pris l’accent local.
Alors non, il n’y a pas de camp à choisir. Le langage est un moyen : il fixe des contraintes, ouvre des portes, impose un outillage et une culture, et sur ce terrain Java marque beaucoup de points. Mais l’architecture est le fondement. C’est la seule chose qu’on emporte vraiment d’un écosystème à l’autre.
🔗 Liens utiles
- Spring Boot — page officielle du projet
- Documentation Spring Boot 4.1
- Actuator — métriques, registres Micrometer, Prometheus et OTLP
- Actuator — tracing et export OpenTelemetry
- Spring Boot — prérequis système (Java, Maven, Gradle, conteneurs servlet)
- Spring Initializr — générateur de projet
- Annonce Spring Boot 4.0.0
- Annonce Spring Boot 4.1.0
- Spring Framework 7.0 GA
- Versioning d’API dans Spring MVC
- Code source de Spring Boot sur GitHub
- Symfony — le conteneur de services
- Laravel — service container
- NestJS — contrôleurs et providers
- Spring Boot — Dockerfiles et jar en couches
/faq
Questions fréquentes
Spring Boot est-il difficile à prendre en main quand on vient de Symfony ?
+
Non, les concepts se transposent presque un pour un : conteneur d'injection de dépendances, routing déclaratif par annotations, ORM organisé en repositories, configuration externalisée par environnement. La marche à franchir, c'est l'outillage Java (Maven ou Gradle, la JVM) plus que le framework lui-même.
Quelle version de Java faut-il pour Spring Boot 4.1 ?
+
Java 17 au minimum, et jusqu'à Java 26 inclus. Spring Boot 4 prend nativement en charge Java 25, la dernière version LTS, et s'appuie sur Spring Framework 7 et Jakarta EE 11.
Qu'apporte Java par rapport à PHP sur une application web ?
+
Un typage vérifié à la compilation sur tout le graphe de code, une JVM qui reste en vie entre les requêtes, garde son état en mémoire et se fait optimiser par le JIT, du vrai parallélisme dans un seul processus (threads virtuels depuis Java 21) et un outillage de diagnostic mature comme JDK Flight Recorder.
Faut-il migrer une application Symfony ou Laravel vers Spring Boot ?
+
Rarement pour le seul gain de langage. Une migration se justifie par une contrainte réelle (calcul intensif, intégrations JVM, politique interne), pas par un espoir de qualité : une application mal découpée le restera après réécriture.