Symfony13 min

Symfony + React : architecture front/back decorrellee

Par Pierre-Arthur Demengel
SymfonyReactAPIArchitecture

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.

Questions fréquentes

13 projets livrésGrand-Est & BelgiqueLighthouse >90Disponible immédiatement

Un projet en tête ?

Discutons de votre site web. Réponse garantie sous 24h.

Ou appelez directement :06 95 41 30 25

WhatsApp
Appeler