What are we cooking?
Hi everyone, in this post we're going to build a boilerplate to start any kind of Symfony project, such like a monolith or an API. We'll use the top tier app server FrankenPHP written in Go. The boilerplate will also use PostgreSQL SGDB for relational database.
Compose the stack using Docker and Compose
First thing first to orchestrate all the containers we will use Compose, we're going to write the stack containers definition.
The directory structure will be very simple, one folder for all docker related files and an other for the Symfony project source code.
We'll add a compose.yml
file directly at the project root.
services:
boilerplate-database:
image: postgres:16
container_name: boilerplate-database
env_file:
- symfony/.env
restart: always
environment:
POSTGRES_DB: ${DATABASE_NAME}
POSTGRES_PASSWORD: ${DATABASE_PWD}
ports:
- 15432:5432
volumes:
- database_data:/var/lib/postgresql/data:rw
boilerplate-app:
env_file:
- symfony/.env
container_name: boilerplate-app
build:
context: ./
dockerfile: docker/api/Dockerfile
target: frankenphp_dev
depends_on:
- boilerplate-database
image: ${IMAGES_PREFIX:-}boilerplate-app
restart: unless-stopped
environment:
SERVER_NAME: ${SERVER_NAME:-http://localhost}, boilerplate-app:80
MERCURE_PUBLISHER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!}
MERCURE_SUBSCRIBER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}
TRUSTED_HOSTS: ${TRUSTED_HOSTS:-^${SERVER_NAME:-nbonnici\.info|localhost}|php$$}
DATABASE_URL: postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-16}&charset=${POSTGRES_CHARSET:-utf8}
MERCURE_URL: ${CADDY_MERCURE_URL:-http://php/.well-known/mercure}
MERCURE_PUBLIC_URL: ${CADDY_MERCURE_PUBLIC_URL:-http://${SERVER_NAME:-localhost}/.well-known/mercure}
MERCURE_JWT_SECRET: ${CADDY_MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!}
volumes:
- ./symfony:/app:cached
- caddy_data:/data
- caddy_config:/config
# comment the following line in production, it allows to have nice human-readable logs in dev
tty: true
networks:
default:
external: true
name: proxies
volumes:
database_data:
caddy_data:
caddy_config:
Here nothing fancy, we create on a custom network a database container using the latest PostgreSQL version and another container built using frankenphp containing the Symfony app.
We can override it this way for development purpose using a compose.override.yml at project root
# Development environment override
services:
boilerplate-app:
build:
context: ./
dockerfile: docker/api/Dockerfile
target: frankenphp_dev
ports:
# HTTP
- target: 80
published: ${HTTP_PORT:-80}
protocol: tcp
# HTTPS
- target: 443
published: ${HTTPS_PORT:-443}
protocol: tcp
# HTTP/3
- target: 443
published: ${HTTP3_PORT:-443}
protocol: udp
volumes:
- ./symfony:/app
- /symfony/var
- ./docker/frankenphp/Caddyfile:/etc/caddy/Caddyfile:ro
- ./docker/frankenphp/conf.d/app.dev.ini:/usr/local/etc/php/conf.d/app.dev.ini:ro
# If you develop on Mac or Windows you can remove the vendor/ directory
# from the bind-mount for better performance by enabling the next line:
#- /app/vendor
environment:
MERCURE_EXTRA_DIRECTIVES: demo
# See https://xdebug.org/docs/all_settings#mode
XDEBUG_MODE: "${XDEBUG_MODE:-off}"
extra_hosts:
# Ensure that host.docker.internal is correctly defined on Linux
- host.docker.internal:host-gateway
tty: true
Now let's take a closer look at the app container Dockerfile located in docker/api/Dockerfile
to discover how this image is built.
FROM dunglas/frankenphp:1-php8.4-bookworm AS frankenphp_upstream
FROM frankenphp_upstream AS frankenphp_base
WORKDIR /app
# persistent / runtime deps
# hadolint ignore=DL3008
RUN apt-get update && apt-get install --no-install-recommends -y \
acl \
file \
gettext \
git \
&& rm -rf /var/lib/apt/lists/*
# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser
ENV COMPOSER_ALLOW_SUPERUSER=1
RUN set -eux; \
install-php-extensions \
@composer \
apcu \
intl \
opcache \
zip \
pdo_mysql \
pdo_pgsql \
gd \
intl \
xdebug \
;
COPY --link docker/frankenphp/conf.d/app.ini $PHP_INI_DIR/conf.d/
COPY --link --chmod=755 docker/frankenphp/docker-entrypoint.sh /usr/local/bin/docker-entrypoint
COPY --link docker/frankenphp/Caddyfile /etc/caddy/Caddyfile
ENTRYPOINT ["docker-entrypoint"]
HEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1
# Dev
FROM frankenphp_base AS frankenphp_dev
ENV APP_ENV=dev XDEBUG_MODE=off
VOLUME /app/var/
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
RUN set -eux; \
install-php-extensions \
xdebug \
;
COPY --link docker/frankenphp/conf.d/app.dev.ini $PHP_INI_DIR/conf.d/
CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile", "--watch" ]
Here again nothing fancy unless the multi stages of this Dockerfile, first we build a base image using Debian Bookworm based Frankenphp image, install container's dependencies and docker entrypoint. Then we can build and configure the dev image from it and production ready optimized image.
I use the Debian Bookworm based image since i don't recommend using the Alpine one, the perfs seems a little less stable and fast. This is related to the musl libc library and JIT AKA just in time compilation used by php core, more information here on the official Frankenphp document.
The docker entrypoint, located in docker/frankenphp
look like this:
#!/bin/sh
set -e
if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
if [ -z "$(ls -A 'vendor/' 2>/dev/null)" ]; then
composer install --optimize-autoloader --prefer-dist --no-progress --no-interaction
fi
if grep -q ^DATABASE_URL= .env; then
echo "Waiting for database to be ready..."
ATTEMPTS_LEFT_TO_REACH_DATABASE=60
until [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ] || DATABASE_ERROR=$(php bin/console dbal:run-sql -q "SELECT 1" 2>&1); do
if [ $? -eq 255 ]; then
# If the Doctrine command exits with 255, an unrecoverable error occurred
ATTEMPTS_LEFT_TO_REACH_DATABASE=0
break
fi
sleep 1
ATTEMPTS_LEFT_TO_REACH_DATABASE=$((ATTEMPTS_LEFT_TO_REACH_DATABASE - 1))
echo "Still waiting for database to be ready... Or maybe the database is not reachable. $ATTEMPTS_LEFT_TO_REACH_DATABASE attempts left."
done
if [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ]; then
echo "The database is not up or not reachable:"
echo "$DATABASE_ERROR"
exit 1
else
echo "The database is now ready and reachable"
fi
if [ "$( find ./migrations -iname '*.php' -print -quit )" ]; then
php bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing
fi
fi
setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var
setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var
fi
exec docker-php-entrypoint "$@"
This is exactly the same provided by the Symfony part of the FrankenPHP documentation.
Configure Frankenphp
FrankenPHP use Caddy as proxy server, so we'll need a Caddyfile to configure it and also provide basic php configurations. Here again we'll stick to the FrankenPHP documention. You can find it in the docker/frankenphp
folder.
By default FrankenPHP will work on worker mode, by launching two proc by CPU core which can be tweak according to your project needs and also your hosting type.
Symfony project
Here's a minimal list of dependencies to offer a first class developer experience. But first thing first we need to install the FrankenPHP runtime, the same we configure on the worker.Caddyfile configuration:
worker {
file ./public/index.php
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
}
To do so simply install the runtime/frankenphp-symfony composer package. Then we install the bare minimum for a kick ass developer experience, a linter using Code Sniffer, phpstan as code quality audit tool, Rector to ease and automate code maintenance, some useful Symfony components and package and of course the Doctrine ORM. Here the composer.json
file located at the symfony folder root.
{
"type": "project",
"license": "proprietary",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.13",
"doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^3.2",
"nelmio/cors-bundle": "^2.5",
"phpdocumentor/reflection-docblock": "^5.4",
"phpstan/phpdoc-parser": "^1.30",
"ramsey/uuid-doctrine": "^2.1",
"runtime/frankenphp-symfony": "^0.2.0",
"symfony/asset": "7.2.*",
"symfony/console": "7.2.*",
"symfony/dotenv": "7.2.*",
"symfony/expression-language": "7.2.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "7.2.*",
"symfony/password-hasher": "7.2.*",
"symfony/property-access": "7.2.*",
"symfony/property-info": "7.2.*",
"symfony/runtime": "7.2.*",
"symfony/security-bundle": "7.2.*",
"symfony/serializer": "7.2.*",
"symfony/twig-bundle": "7.2.*",
"symfony/validator": "7.2.*",
"symfony/yaml": "7.2.*"
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.5",
"friendsofphp/php-cs-fixer": "^3.65",
"phpunit/phpunit": "^9.5",
"rector/rector": "^1.2",
"symfony/browser-kit": "7.2.*",
"symfony/css-selector": "7.2.*",
"symfony/maker-bundle": "^1.61",
"symfony/phpunit-bridge": "^7.2",
"symfony/stopwatch": "7.2.*",
"symfony/var-dumper": "7.2.*",
"symfony/web-profiler-bundle": "7.2.*"
},
"config": {
"allow-plugins": {
"php-http/discovery": true,
"symfony/flex": true,
"symfony/runtime": true
},
"sort-packages": true
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-php72": "*",
"symfony/polyfill-php73": "*",
"symfony/polyfill-php74": "*",
"symfony/polyfill-php80": "*",
"symfony/polyfill-php81": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.2.*"
}
}
}
Why using the latest version of Symfony 7.2 and not waiting on the last LTS which is the 6.4 for now? Because this is not the way Symfony behave, let's listen to what Nicolas Grekas said the Forum PHP 2024 event:
so first thing first, it will be more easy to maintain a project by updating it once monthly than migrating from the last LTS to the next one, which can be in some project very painless and time consuming. Tool like Rector can really help by the way this kind of migration and many others too.
Leverage all the power of Composer package manager
In this project Composer is used to handle class autoloading, dependencies and also manage the project itself. Lets add a set of usefull scripts in the dedicated section of our composer.json
configuration file.
{
...
"scripts": {
...
"setup": [
"composer run up",
"composer run deps:install",
"composer run database",
"composer run migrate",
"composer run fixtures"
],
"up": [
"docker compose --env-file symfony/.env up -d --build"
],
"stop": [
"docker compose --env-file symfony/.env stop"
],
"down": [
"docker compose --env-file symfony/.env down"
],
"build": [
"docker compose --env-file symfony/.env build"
],
"deps:install": [
"docker exec -it boilerplate-app bin/composer install -o"
],
"database": [
"docker exec -it boilerplate-app bin/console doctrine:database:create -n --if-not-exists"
],
"migrate": [
"docker exec -it boilerplate-app bin/console doctrine:migration:migrate -n"
],
"fixtures": [
"docker exec -it boilerplate-app bin/console doctrine:fixtures:load -n"
],
"tests": [
"docker exec -t boilerplate-app bash -c 'clear && ./vendor/bin/phpunit --testdox --exclude=smoke'"
],
"lint": [
"docker exec -t boilerplate-app ./vendor/bin/php-cs-fixer ./src/"
],
"lint:fix": [
"docker exec -t boilerplate-app ./vendor/bin/php-cs-fixer fix ./src/"
],
"db": [
"psql postgresql://postgres:password@127.0.0.1:15432/boilerplate"
],
"logs": [
"docker compose logs -f"
],
"generate-keypair": [
"docker exec -t boilerplate-app bin/console lexik:jwt:generate-keypair"
],
"cache-clear": [
"docker exec -t boilerplate-app bin/console c:c"
]
}
}
Here's an overview of available composer commands:
To setup project's containers simply run:
composer setup
Once built, you can start or stop project's containers like this:
composer up
composer stop
Destroys containers (but keep volumes)
composer down
Migrate database
composer migrate
Load fixtures
composer fixtures
Connect to postgresql database
composer db
Show logs
composer logs
Fix code lint
composer lint:fix
Optimize for production
Symfony development mode cost a lot by caching nothing and add a lot of debug everywhere. On production environment we will use OPCache to store in cache class content, dump composer class autoload in a more optimized way and get rid of development related dependencies. You can find on the docker/frankephp/conf.d
the different configurations for php.
First let's add a production specific stage on our Dockerfile
# Prod
FROM frankenphp_base AS frankenphp_prod
ENV APP_ENV=prod
ENV FRANKENPHP_CONFIG="import worker.Caddyfile"
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY --link docker/frankenphp/conf.d/app.prod.ini $PHP_INI_DIR/conf.d/
COPY --link docker/frankenphp/worker.Caddyfile /etc/caddy/worker.Caddyfile
COPY symfony/ .
RUN set -eux; \
composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scripts --no-progress
RUN set -eux; \
mkdir -p var/cache var/log; \
composer dump-autoload --classmap-authoritative --no-dev; \
composer dump-env prod; \
composer run-script --no-dev post-install-cmd; \
chmod +x bin/console; sync;
CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile", "--watch" ]
Then create a specific override for compose to use in production, at the root of the project create a new composer.override.prod.yml with the following content:
# Development environment override
services:
boilerplate-app:
build:
context: ./
dockerfile: ./docker/api/Dockerfile
target: frankenphp_prod
expose:
- 80
volumes:
- ./docker/frankenphp/Caddyfile:/etc/caddy/Caddyfile:ro
- ./docker/frankenphp/conf.d/app.prod.ini:/usr/local/etc/php/conf.d/app.prod.ini:ro
# If you develop on Mac or Windows you can remove the vendor/ directory
# from the bind-mount for better performance by enabling the next line:
#- /app/vendor
environment:
SERVER_NAME: ${SERVER_NAME:-http://api.nbonnici.info}, boilerplate-app:80
MERCURE_EXTRA_DIRECTIVES: demo
# See https://xdebug.org/docs/all_settings#mode
XDEBUG_MODE: "${XDEBUG_MODE:-off}"
extra_hosts:
# Ensure that host.docker.internal is correctly defined on Linux
- host.docker.internal:host-gateway
The purpose is to specify the new build target which is now frankenphp_prod
and only expose the http port of the container without any forwarding. This is this spcecific port 80 on your container that your hostname with SSL support will target after a reverse proxy.
Benchmark
Now it's time to benchmark, is FrankenPHP as blazing fast as most people stated? Short answer yes it is, but each project having his own needs you'll need to tweak it, and it's very flexible so no problem doing it.
For this test we'll create a dead simple Todo entity containing a few columns and a foreign key to our User entity.
Using fixtures we will create one thousand of todos and using the top tier REST api creation bundle API Platform we will load them in json format.
For this test i'am using a local docker container on a 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz CPU with 16go of RAM.
The test itself consist to make an HTTP request to a RESTFUL API to retrieve a collection of todo resources, with more items per page from a page of 10 to 1000.
So the project must route the request then use API Platform layers and the ORM to query from the database the todos, then serialize the objects in a json response.
The container directly run on the host I send the request so there's almost no network latency and I use Insomnia to measure the response time.
GET /todos 10 / 50 / 100 / 500 / 1000 resource in dev staging
Page of 10 resources: 177 ms
Page of 50 resources: 188 ms
Page of 100 resources: 211 ms
Page of 500 resources: 259 ms
Page of 1000 resources: 346 ms
GET 10 / 50 / 100 / 500 / 1000 resource in production staging
Page of 10 resources: 9.03 ms
Page of 50 resources: 15.1 ms
Page of 100 resources: 29.2 ms
Page of 500 resources: 106 ms
Page of 1000 resources: 170 ms
Conclusion the gap is huge, between development and production nothing new here. By creating the same REST API without Symfony and API Platform and all the confort they bring you can win let's say a few milliseconds more which is totally nothing and almost impossible to detect from a human perception. Frankenphp per default using mecanism such like early hint http code, the go routines and many modern and blazing fast concept can really improve your project performances.
Going further
Security notes
We can secure things a little more by not using root user container side, this is a bad practice.
To do so, we need to follow the official Frankenphp documentation.
Migrate Symfony up to the incoming 7.4 LTS
Here again keep your project freshly updated each month and also pay attention to the deprecated warning you can find.
Configuration
All depend on your need, are you working on a CLI app, an API, a monolith? How do you host your app, on a cluster, just one bare metal onto lambda in all those cases you need to find the better settings by tweaking the worker thread number by core and also the right php configuration.
Conclusion
This boilerplate can literally boot up any project from a monolith to a REST API, almost everything you can build with php and Symfony. Using top tier service like PostgreSQL and easily scalable using Kubernetes and Karpenter for instance, as well as a Gateway API to proxy and absorb mostly GET http incoming requests for high demand projects. You can also use it to migrate an existing project to Frankenphp.
You can find the final boilerplate source code here on Gitlab. Feel free to contribute on it, i will maintain and update this post as well as the boilerplate, thank you for reading.
Top comments (0)