[How2Tips] How to add a front-end framework to an existing Symfony monolith

Publié le

22/06/2021

This article is for all developers who want to add a front-end framework like React to an existing Symfony + Twig application. But first, a little bit more context :

Symfony (released in 2005) has been the most used PHP framework of the previous decade. The core team is doing an amazing job and has managed to keep the framework competitive and stable along all these years. The release schedule is also cristal clear and it gives us the time and vision to anticipate updates in order to keep our projects up-to-date with the latest version of the framework. Therefore it is not rare to see mature Symfony powered projects that have been initiated with the first versions of the framework and have been pushed to the latest ones.

In the meantime, frontend frameworks such as React or Vue have become very popular and stand as a solid alternative to the good old Twig templates that were promoted in the Symfony standard stack. In this case, the Symfony monolith application becomes an API that feeds your frontend application.

Disclaimer: such architecture has pros and cons and is not the only possibility. Symfony monolith using Twig is still a good option in many situations.

If you are reading this article, it means that you have to add a front-end framework to your monolith stack, but of course you don't want to restart the project from scratch and lose all the features that are already working fine with Symfony and Twig.

Great news for you guys! This article aims to present how React (or whatever framework) can be added to an existing Symfony + Twig application thanks to the power of Traefik!

Prerequisites


  • Knowledge about docker, docker-compose and nginx
  • Knowledge about Symfony & React (all the needed code will be provided and explained but we won't explain how these tools work internally)

Global architecture


You can find a full working example here.

The main concept of this approach is to take the advantage of Traefik features that allow finetuneing the routing rules of incoming requests. In other words, we will configure some paths to be routed to Symfony and others to the React application.

We will use docker-compose to manage the different services we need. Here is a simplified diagram that represents our stack.

As you can see, incoming client requests are caught by Traefik and routed to the right service (Symfony or React). At the end of the day, the client does not even have to know which service is answering.

Disclaimer: There a plenty of solutions. Of course, other approaches might fit better to your needs. For example, this great tutorial using Stimulus.

Create the stack


Our docker-compose stack will require 4 services:

  • Traefik
  • php-fpm
  • nginx
  • node (to build the React application)

Let's create the following folder structure:

Bootstrap Symfony


Move to the apps/back/ folder and create the Symfony application using the symfony binary (documentation). Run the following command:

symfony new . --version=next --full

You should have a Symfony application installed in the apps/back/ folder.

Now let's create a apps/back/Dockerfile file to wrap this service in a Docker image:

FROM composer:2 as composer

###########################

FROM php:8.0-fpm-alpine3.13 as base

RUN apk add --no-cache --virtual=.build-deps \
        autoconf==2.69-r3 \
        g++==10.2.1_pre1-r3 \
        make==4.3-r0 \
    && pecl install apcu-5.1.19 \
    && docker-php-ext-enable apcu \
    && apk del .build-deps

WORKDIR /usr/src/app

COPY --chown=1000:1000 . /usr/src/app

RUN PATH=$PATH:/usr/src/app/vendor/bin:bin

COPY --from=composer /usr/bin/composer /usr/bin/composer

RUN composer install --no-scripts --prefer-dist --no-interaction

###########################

FROM base as dev

ENV APP_ENV dev

Configure nginx


In our stack nginx will be the http server of our Symfony application. It transfers incoming http requests to php-fpm. The configuration serves the Symfony files. Let's create the following file in apps/nginx/default.conf:

server {
 server_name ~.*;

 location / {
     root /usr/src/app;

     try_files $uri /index.php$is_args$args;
 }

 location ~ ^/index\.php(/|$) {
     client_max_body_size 50m;

     fastcgi_pass php:9000;
     fastcgi_buffers 16 16k;
     fastcgi_buffer_size 32k;
     include fastcgi_params;
     fastcgi_param SCRIPT_FILENAME /usr/src/app/public/index.php;
 }

 error_log /dev/stderr debug;
 access_log /dev/stdout;
}

This is the default configuration of Symfony recommended in the official documentation.

Bootstrap React app


Move to the apps/front/ and create the React application with the create-react-app tool (documentation). Run the following command:

npx create-react-app . --template=typescript

You should have a React application installed in the apps/front folder.

This example is using Typescript on top of React. You are free to use React with Javascript only.

Now let's create a apps/front/Dockerfile file to wrap this service in a Docker image:

FROM node:16-alpine3.11 as base

ARG USER_ID

RUN mkdir -p /usr/src/app
RUN deluser --remove-home node
RUN addgroup docker && \
    adduser -S -h /home/docker -u ${USER_ID:-1000} -G docker docker && \
    chown -R docker /home/docker /usr/src/app

USER docker
WORKDIR /usr/src/app

COPY --chown=docker package.json yarn.lock ./
RUN yarn install

COPY --chown=docker . ./

##################################

FROM base as dev

ENV NODE_ENV development

COPY ./docker/entrypoint /docker/entrypoint
ENTRYPOINT ["/docker/entrypoint"]

CMD ["yarn", "start"]

EXPOSE 3000

entrypoint: Let's create the following file apps/front/docker/entrypoint:

#!/bin/sh

if [[ $NODE_ENV == 'development' ]]
then
    yarn install --silent --non-interactive
fi

exec "$@"

This entrypoint will ensure that node modules are installed when you run the container in the context of docker-compose. In some cases, when you share a volume between the container and your host, node modules seem to be installed on the host but are not available inside the container.

Make the entrypoint file executable chmod +x apps/front/docker/entrypoint

docker-compose


Let's declare the services in the docker-compose.yaml file:

---
version: '3.8'
services:
  nginx:
    build:
      context: apps/nginx
      dockerfile: Dockerfile
    ports:
      - '80:80'
    restart: unless-stopped

  php:
    build:
      context: apps/back
      dockerfile: Dockerfile
      target: dev
    volumes:
      - './apps/back:/usr/src/app:rw'
    restart: unless-stopped

  front:
    build:
      context: apps/front
      dockerfile: Dockerfile
      target: dev
    volumes:
      - './apps/front:/usr/src/app:rw'
    ports:
      - '3000:3000'
    restart: unless-stopped

Run docker-compose build and docker-compose up -d

At this stage, you should now have your Symfony app running on http://localhost and the React app on http://localhost:3000

Traefik configuration


It is time to add the cornerstone of the our stack! This service will be in charge of catching any incoming request and redirect it to the right service (Symfony or React).

In the apps/traefik/ folder, let's create these 2 files:

apps/traefik/dynamic_conf.yaml:

http:
  routers:
    traefik:
      rule: "Host(`traefik.app.localhost`)"
      service: "api@internal"

And apps/traefik/traefik.yaml:

api:
  dashboard: true
  insecure: true

providers:
  docker:
    endpoint: unix:///var/run/docker.sock
    watch: true
    exposedbydefault: false

  file:
    filename: /etc/traefik/dynamic_conf.yaml
    watch: true

log:
  level: DEBUG
  format: common

entryPoints:
  web:
    address: ':80'

We are not going to explain in details every line of these configuration files. Basically it tells Traefik to look into the docker.sock to auto discover available services in the internal docker-compose network.

Then we need a dedicated apps/traefik/Dockerfile file to run Traefik in a properly configured container:

FROM traefik:v2.4.7

COPY dynamic_conf.yaml /etc/traefik/dynamic_conf.yaml
COPY traefik.yaml /etc/traefik/traefik.yaml

The last step is to declare the Traefik service in the docker-compose stack.

Let's update the docker-compose.yaml file to the following:

---
version: '3.8'
services:
  traefik:
    build:
      context: apps/traefik
      dockerfile: Dockerfile
    ports:
      - '80:80'
    security_opt:
      - no-new-privileges:true
    volumes:
      - '/var/run/docker.sock:/var/run/docker.sock:ro'
    restart: unless-stopped

  nginx:
    build:
      context: apps/nginx
      dockerfile: Dockerfile
    labels:
      - 'traefik.enable=true'
      - 'traefik.http.routers.back.rule=Host(`blog.app.localhost`)'
    restart: unless-stopped

  php:
    build:
      context: apps/back
      dockerfile: Dockerfile
      target: dev
    volumes:
      - './apps/back:/usr/src/app:rw'
    restart: unless-stopped

  front:
    build:
      context: apps/front
      dockerfile: Dockerfile
      target: dev
    labels:
      - 'traefik.enable=true'
      - 'traefik.http.routers.front.rule=Host(`blog.app.localhost`) && 
        PathPrefix(`/articles`)
      '
      - 'traefik.http.services.front.loadbalancer.server.port=3000'
    volumes:
      - './apps/front:/usr/src/app:rw'
    restart: unless-stopped

There are some points we need to explain! First, you may have noticed that now there is only one ports binding defined on the traefik service. We bind the port 80 of the container to the port 80 of the host. Which means any incoming HTTP request to the docker-compose stack will be handled by traefik.

Now let's see in details what is under the hood:

traefik

  traefik:
    build:
      context: apps/traefik
      dockerfile: Dockerfile
    ports:
      - '80:80'
    security_opt:
      - no-new-privileges:true
    volumes:
      - '/var/run/docker.sock:/var/run/docker.sock:ro'
    restart: unless-stopped

Volumes: we have to share the docker.sock with the traefik container through a volume so that it can gather configuration from Docker.

nginx

  nginx:
    build:
      context: apps/nginx
      dockerfile: Dockerfile
    labels:
      - 'traefik.enable=true'
      - 'traefik.http.routers.back.rule=Host(`blog.app.localhost`)'
    restart: unless-stopped

Labels:

  • 'traefik.enable=true': Tells traefik to consider this service
  • 'traefik.http.routers.back.rule=Host(blog.app.localhost)': Tells treafik that any incoming HTTP request on the host blog.app.localhost must be redirected to this service

front

  front:
    build:
      context: apps/front
      dockerfile: Dockerfile
      target: dev
    labels:
      - 'traefik.enable=true'
      - 'traefik.http.routers.front.rule=Host(`blog.app.localhost`) && 
        PathPrefix(`/articles`)
      '
      - 'traefik.http.services.front.loadbalancer.server.port=3000'
    volumes:
      - './apps/front:/usr/src/app:rw'
    restart: unless-stopped

Labels:

  • 'traefik.enable=true': Tells traefik to consider this service
  • 'traefik.http.services.front.loadbalancer.server.port=3000': Incoming HTTP requests are routed to the port 3000 which is the default port of the React development server.
  • 'traefik.http.routers.front.rule=Host(blog.app.localhost) && PathPrefix(/articles)': Tells treafik that any incoming HTTP request on the host blog.app.localhost and having a path starting with /articles must be redirected to this service

This PathPrefix() statement is one of the most important points of this tutorial! This statement will allow to dispatch incoming request to either Symfony or React.

You can now re-run commands docker-compose build and docker-compose up -d.

At this stage

React configuration


So why is it normal that our React app serves a blank page ? If you open your developer console on this page you'll get an hint. All JS chunks are not served and return a 404. This is because our page looks like static/js/... and so with our current configuration Traefik routes those requests to the Symfony app.

A first solution would be to add a PathPrefix(/static) rule to the front container but what if you have some static content to serve like images ? We could have added each path to the router rule but it would become unmaintainable. As we used create-react-app to init our React app we have react-scripts for serving our application and they have a solution for this kind of problem Building for Relative Paths. So adding "homepage": "/react" in our package.json and refresh the page... Same result, what ? Remember that Traefik is managing the routing and we have to add a rule to our front container :

# docker-compose.yaml
front:
    build:
      context: apps/front
      dockerfile: Dockerfile
      target: dev
    labels:
      - 'traefik.enable=true'
      - 'traefik.http.routers.front.rule=Host(`blog.app.localhost`) && (
        PathPrefix(`/react`) ||
        PathPrefix(`/articles`)
      )'
      - 'traefik.http.services.front.loadbalancer.server.port=3000'
    environment:
      WDS_SOCKET_PATH: '/react/sockjs-node'
    volumes:
      - './apps/front:/usr/src/app:rw'
    restart: unless-stopped

Notice that if we added an environment variable it's because on dev environment react-scripts starts a WebSocket to reload our app each time we make changes on its source code. This WebSocket is normally served on /sockjs-node path that Traefik will route to the Symfony app. To overcome this we can override the path with WDS_SOCKET_PATH environment variable. Now you can access your React app, I promise !

One last thing about React : how can we handle routing ? For internal routing we use React Router that comes with a <Link /> component. But if you want to go to the Symfony app you'll have to somehow force the page reload which is done easy with a regular <a /> tag. For example our Go back to homepage button is done like this :

<a 
  className="button is-fullwidth is-success is-light" 
  href={process.env.REACT_APP_SYMFONY_HOST}
>
  Go back to homepage
</a>

We added an environment variable to the origin of the Symfony app http://blog.app.localhost so it will be easier to manage those links.

Authentication


By default Symfony returns a 302 (Redirect) status code to the login page whenever the user tries to reach a protected page without being logged. It also sets in the user session the path of this protected page (target_path) in order to be able to redirect the user back to it when they will be logged.

This is perfectly fine in the context of a Symfony/Twig application but now we also have an API that is used by the React application. In the context of an API it would be handier to have Symfony returning a 401 (Unauthorized) status code instead of a redirection.

To do so the Symfony app uses 2 different firewalls :

  • main used by the monolith Symfony/Twig app ;
  • api used by the REST API.

To prevent the redirection the api firewall uses a dedicated entry point to return 401 status codes instead of default 302 made by Symfony security core :

# config/packages/security.yaml
api:
    pattern: ^/api/
  guard:
    authenticators:
      - App\Security\LoginFormAuthenticator
  entry_point: App\Security\AuthenticationEntryPoint

In this entry point we define the session parameter that contains the target_path of the login form. This parameter is used to redirect the user to the defined value when the login form succeeds

// src/Security/AuthenticationEntryPoint.php
public function start(Request $request, AuthenticationException $authException = null): Response
{
    $request->getSession()->set(
        '_security.main.target_path', 
            $request->headers->get('referer', $this->urlGenerator->generate('index'))
    );

        return new Response('Unauthorized.', 401);
}

The target is made from the Referer header whose value is the React page that made the request. If not provided we will go to the Symfony app homepage.

The parameter is defined for the main firewall, even if the request is coming from the api firewall, because it's the main firewall which handles the login process and so the redirection.

The last part of the job is to use this session parameter to redirect the user. This is handled by the LoginFormAuthenticatoryou may have noticed in the firewall configuration.

This class is what Symfony calls an Authenticator (replacement of Guards for old Symfony developers) which manages how the login form will authenticate the user. It will internally call our user provider and password encoder to check if the user exists and their password matches the provided one. The important part of this class is the onAuthenticationSuccess callback that will read the target_path session value and redirect the user to it. If it's not provided we redirect to the Symfony app homepage.

// src/security/LoginFormAuthenticator.php
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey): ?Response
{
    if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
        return new RedirectResponse($targetPath);
    }

     return new RedirectResponse($this->urlGenerator->generate('index'));
}

The Symfony security component comes with a useful feature that allows us to manage the session parameter : Symfony\Component\Security\Http\Util\TargetPathTrait. This trait gives us the getTargetPath method.

nginx prod config for the react container


We chose to give you production ready docker-compose configurations. Symfony ones are quite simple but it's a bit harder for React.

Our production container is based on react-scripts build output that are served by an nginx. At first sight, you can think a simple nginx config file like this one can do the job :

server {
    server_name ~.*;

    root /usr/src/app/build;

    expires 1y;

    location / {
        client_max_body_size 4k;

        try_files $uri $uri/ /index.html;
    }

    error_log /dev/stderr debug;
    access_log /dev/stdout;
}

Guess what ? It won't remember the "homepage": "/react" parameter in package.json means that all our static or public assets will be served prefixed with that fake directory. For example our favicon will be requested with http://blog.app.localhost/react/favicon.ico. We must tell Nginx to remove this directory when trying to serve those files because the build folder will not contain a sub react folder.

To do so we have to add another location directive like this :

location ~* /react/(.*) {
    try_files /$1 /$1/ =404;
}

Now everything will be served (manifest.json, chunks, favicon, and all other public assets) !

Conclusion


This approach is cool and of course, it has its pros and cons:

Pros

  • The configuration is quite simple
  • You keep your React application decoupled from Symfony
  • You can progressively refactor sections of your site and replace it by react

Cons

  • You cannot add a small section of React inside an existing Twig template
  • You will probably have to recreate the app layouts on the React side
  • If you navigate from the React app to the Symfony app, the states of React will be lost. If you need to keep them you will probably need to add a centralized state manager (like Redux) and synchronize it with the browser local storage.

Questions ?

Feel free to ping us on twitter !

Publié par

Thomas
Thomas Triboult


Antoine
Antoine Lelaisant


Commentaires