[How2Tips] How to add a front-end framework to an existing Symfony monolith
Published on
Jun 22, 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
andnginx
- 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 hostblog.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 port3000
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 hostblog.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
- You should have a working Symfony application if you go on http://blog.app.localhost
- You should have a blank page on http://blog.app.localhost/articles (⬅️ This is absolutely normal)
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 LoginFormAuthenticator
you 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 !
Comments