Symfony + React : architecture front/back decorrellee
Symfony et React sont deux excellents outils dans leurs domaines respectifs. La question n'est pas de savoir si on peut les utiliser ensemble - c'est trivial - mais comment les architecturer pour que chaque outil brille sans creer de complexite inutile. Voici les deux approches principales, leurs avantages reels et leurs pieges.
Deux architectures, deux philosophies
Quand on dit "Symfony + React", il y a en realite deux architectures radicalement differentes :
- Monolithe enrichi - Symfony gere le routing, le rendu Twig, et React est injecte dans des "ilots" interactifs via Webpack Encore. Le serveur reste maitre.
- SPA decorrellee - Symfony sert uniquement d'API (REST ou GraphQL). React est une application autonome (Vite, Next.js) qui consomme cette API. Deux applications distinctes.
Le choix entre les deux n'est pas technique mais organisationnel. Qui travaille sur le front ? Qui sur le back ? Y a-t-il une application mobile ? Combien de temps avez-vous ?
Approche 1 : Monolithe avec Webpack Encore
Webpack Encore integre React directement dans l'ecosysteme Symfony. Les composants React vivent dans le repertoire assets/ et sont montes dans les templates Twig :
# Installation
composer require symfony/webpack-encore-bundle
npm install @symfony/webpack-encore --save-dev
npm install react react-dom --save-dev
npm install @babel/preset-react --save-dev
Configuration Encore pour React :
// webpack.config.js
const Encore = require('@symfony/webpack-encore');
Encore
.setOutputPath('public/build/')
.setPublicPath('/build')
.addEntry('app', './assets/app.js')
.addEntry('product-configurator', './assets/react/ProductConfigurator.jsx')
.enableReactPreset()
.enableSingleRuntimeChunk()
.enableSourceMaps(!Encore.isProduction())
.enableVersioning(Encore.isProduction());
module.exports = Encore.getWebpackConfig();
Dans un template Twig, montez le composant React :
{# templates/product/show.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
<h1>{{ product.name }}</h1>
<p>{{ product.description }}</p>
{# Ilot React interactif #}
<div
id="product-configurator"
data-product-id="{{ product.id }}"
data-api-url="{{ path('api_product_options', {id: product.id}) }}"
></div>
{% endblock %}
{% block javascripts %}
{{ encore_entry_script_tags('product-configurator') }}
{% endblock %}
// assets/react/ProductConfigurator.jsx
import React, { useState, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
function ProductConfigurator({ productId, apiUrl }) {
const [options, setOptions] = useState([]);
const [selected, setSelected] = useState({});
useEffect(() => {
fetch(apiUrl)
.then(res => res.json())
.then(data => setOptions(data));
}, [apiUrl]);
return (
<div className="configurator">
{options.map(option => (
<button
key={option.id}
onClick={() => setSelected(prev => ({
...prev, [option.type]: option.id
}))}
className={selected[option.type] === option.id ? 'active' : ''}
>
{option.label}
</button>
))}
</div>
);
}
const el = document.getElementById('product-configurator');
if (el) {
const root = createRoot(el);
root.render(
<ProductConfigurator
productId={el.dataset.productId}
apiUrl={el.dataset.apiUrl}
/>
);
}
Cette approche est detaillee dans l'article Webpack Encore pour le frontend Symfony. Elle fonctionne bien quand React n'est utilise que pour quelques composants interactifs dans une application Symfony classique.
Approche 2 : SPA decorrellee (API-first)
Dans cette architecture, Symfony est un pur backend API. React est une application independante, generalement construite avec Vite :
# Structure monorepo recommandee
mon-projet/
api/ # Symfony
src/
config/
composer.json
frontend/ # React
src/
vite.config.js
package.json
docker-compose.yml # Orchestre les deux
Le backend Symfony expose une API REST (ou GraphQL via API Platform) :
// api/src/Controller/Api/ProductController.php
namespace App\Controller\Api;
use App\Repository\ProductRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/products', name: 'api_products_')]
class ProductController extends AbstractController
{
#[Route('', name: 'list', methods: ['GET'])]
public function list(ProductRepository $repo): JsonResponse
{
$products = $repo->findAll();
return $this->json($products, 200, [], [
'groups' => ['product:list'],
]);
}
#[Route('/{id}', name: 'show', methods: ['GET'])]
public function show(Product $product): JsonResponse
{
return $this->json($product, 200, [], [
'groups' => ['product:read'],
]);
}
}
Configuration CORS
En architecture decorrellee, le front (port 5173) et le back (port 8000) sont sur des origines differentes. Il faut configurer CORS cote Symfony :
composer require nelmio/cors-bundle
# config/packages/nelmio_cors.yaml
nelmio_cors:
defaults:
origin_regex: true
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
allow_methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
allow_headers: ['Content-Type', 'Authorization']
expose_headers: ['Link']
max_age: 3600
paths:
'^/api/':
allow_origin: ['http://localhost:5173', 'https://mon-app.com']
En production, une approche plus propre consiste a servir le front et l'API depuis le meme domaine via nginx. Le front est servi sur / et l'API sur /api. Plus de CORS, plus de problemes de cookies cross-origin.
Authentification JWT
Pour une SPA, l'authentification par session ne fonctionne pas bien. JWT est le standard :
// frontend/src/services/auth.js
const API_URL = import.meta.env.VITE_API_URL;
export async function login(email, password) {
const response = await fetch(`${API_URL}/api/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error('Identifiants invalides');
}
const { token } = await response.json();
return token;
}
export function authFetch(url, options = {}) {
const token = localStorage.getItem('jwt_token');
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
}
Note de securite : stocker le JWT en localStorage est vulnerable aux attaques XSS. Pour les applications sensibles, preferez un cookie httpOnly. Cote Symfony, configurez LexikJWTAuthenticationBundle pour emettre un cookie plutot qu'un token dans le body de la reponse.
Workflow de developpement
En mode decouple, vous lancez deux serveurs en parallele :
# Terminal 1 : Symfony API
cd api && symfony serve --port=8000
# Terminal 2 : React frontend
cd frontend && npm run dev # Vite sur port 5173
Avec Docker, un seul docker compose up lance les deux plus la base de donnees :
# docker-compose.yml
services:
api:
build: ./api
ports:
- "8000:80"
volumes:
- ./api:/app
depends_on:
- db
frontend:
image: node:20-alpine
working_dir: /app
command: npm run dev -- --host
ports:
- "5173:5173"
volumes:
- ./frontend:/app
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: secret
Quand utiliser Symfony UX a la place
Avant de lancer une architecture Symfony + React, posez-vous la question : ai-je vraiment besoin d'une SPA ? Symfony UX (Stimulus + Turbo + Live Components) offre une interactivite importante sans la complexite d'une SPA. Voici les criteres de decision :
- Symfony UX suffit quand : formulaires dynamiques, navigation rapide sans rechargement, mises a jour partielles de page, composants interactifs isoles
- React est necessaire quand : application type dashboard avec etat complexe, editeur visuel (drag & drop, canvas), application offline-first, equipe front-end dediee avec expertise React
Dans ma pratique, 70 % des projets qui partent sur "Symfony + React decouple" auraient ete mieux servis par un monolithe Symfony avec UX ou quelques ilots React via Encore. La complexite operationnelle du decouple (CORS, JWT, deploiement de deux applications, versioning d'API) est souvent sous-estimee.
Deploiement
En monorepo, le deploiement peut se faire en une seule pipeline :
# Build du frontend
cd frontend && npm run build
# Copier le build dans le dossier public de Symfony
cp -r frontend/dist/* api/public/spa/
# Deployer Symfony (qui inclut maintenant le front)
cd api && dep deploy production
En depots separes, vous deployez independamment. Le front est heberge sur un CDN (Vercel, Netlify, CloudFront) et l'API sur un serveur classique. C'est plus complexe mais permet des cycles de release independants.
Aller plus loin
L'architecture Symfony + React est un vaste sujet. Pour les API, decouvrez API Platform qui automatise la creation d'endpoints. Pour l'approche alternative server-side, Symfony UX merite votre attention. Et pour le build des assets en monolithe, Webpack Encore reste la reference.
Vous hesitez entre monolithe et architecture decorrellee ? Contactez-moi pour un audit architecture. Consultez mes services et tarifs - en tant que freelance Symfony et React entre la France et la Belgique, j'accompagne les equipes dans ce choix structurant.
