What are we cooking?
Hello everyone, in this article we will build a boilerplate to launch any type of symphony orchestra Project, such as monolith or API. We will use top application servers FrankenPHP Written in Go language. The boilerplate will also use PostgreSQL SGDB is used for relational databases.
Build a stack using Docker and Compose
To first orchestrate all the containers we will use Compose, we will write the stacked container definition.
The directory structure is very simple. One folder stores all docker-related files, and another folder stores the Symfony project source code.
we will add a compose.yml
Place the file directly in the project root directory.
services:
boilerplate-database:
image: postgres:16
container_name: boilerplate-database
env_file:
- symfony/.env
restart: always
environment:
POSTGRES_DB: $localhost
POSTGRES_PASSWORD: $SERVER_NAME:-nbonnici\.info
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: $SERVER_NAME:-nbonnici\.infoboilerplate-app
restart: unless-stopped
environment:
SERVER_NAME: $SERVER_NAME:-nbonnici\.info, 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:
Nothing fancy here, we create a database container on a custom network using the latest PostgreSQL version, and another container using Frankenphp containing the Symfony application.
We can use compose.override.yml in the project root directory to override it in this way for development purposes
# 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 application container Dockerfile located in docker/api/Dockerfile
Let’s see how this image is constructed.
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" ]
Again nothing fancy here, except for the multiple stages of this Dockerfile, first we use Debian Bookworm Based on the Frankenphp image, installs the container’s dependencies and docker entry point. From there we can then build and configure development images as well as production-ready optimized images.
What I use is Durban Bookworm based image as I don’t recommend using alps First, performance doesn’t seem to be consistent and fast. This is related to musl libc library and JIT also known as just-in-time compilation Used by php core, see for more information Frankenphp official documentation.
docker entry point, located at docker/frankenphp
It looks 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 what is provided in the Symfony section of the FrankenPHP documentation.
Configure FrankenPHP
FrankenPHP uses caddy As a proxy server, we need a Caddyfile to configure it and provide basic php configuration. Here, we’ll stick with FrankenPHP files again. You can docker/frankenphp
folder.
By default, FrankenPHP will work in work mode, launching two processes through the CPU core, which can be adjusted according to your project needs and hosting type.
symphony project
The following is a minimal list of dependencies that provide a first-class developer experience. But first we need to install the FrankenPHP runtime, the same one we configured on the worker.Caddyfile configuration:
worker {
file ./public/index.php
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
}
To do this, just install runtime/frankenphp-symfony Composer Pack. Then we install the minimum developer experience, using the linter code sniffer, phpstan As a code quality audit tool, headmaster To simplify and automate code maintenance, there are some useful Symfony components and packages, and of course Doctrine ORM. Here’s composer.json
The file is located in the root directory of the symfony folder.
{
"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 use the latest version of Symfony 7.2 instead of waiting for the last LTS (i.e. 6.4)? Since this is not how Symfony behaves, let’s listen to Nicholas Grekas Said at the PHP Forum 2024 event:
So, first of all, it is easier to maintain the project by updating it once a month than migrating from the last LTS to the next, which can be very painless and time-consuming in some projects. Tools like Rector can really help with this and many other migrations.
Take advantage of all the features of the Composer Package Manager
in this project composer Used to handle category autoloading, dependencies, and managing the project itself. Let’s add a set of useful scripts to our dedicated section composer.json
Profile.
{
...
"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"
]
}
}
The following is an overview of the available Composer commands:
To set up your project’s container, just run:
composer setup
After the setup is complete, you can start or stop the project’s containers like this:
composer up
composer stop
Destroy the container (but keep the volume)
composer down
Migrate database
composer migrate
load fixture
composer fixtures
Connect to postgresql database
composer db
show log
composer logs
Fix code lint
composer lint:fix
Optimize production
The Symfony development model doesn’t cache anything and adds a lot of debugging here and there, so it’s expensive. In a production environment, we will use OPCache to store cached class content, dump Composer class autoloading in a more optimized way, and get rid of development-related dependencies. You can docker/frankephp/conf.d
Different configurations of php.ini
First, we add a production-specific stage to the 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. Create a new composer.override.prod.yml in the root directory of the project 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 current new target frankenphp_prod
And only expose the http port of the container without any forwarding. This is the specific port 80 on the container to which the hostname with SSL support will be located behind the reverse proxy.
benchmark
Now it’s time to benchmark, is FrankenPHP as fast as most people say? The short answer is, yes, but each project has its own needs that you’ll need to adjust to, and it’s very flexible, so there’s no problem in doing so.
For this test, we will create a very simple Todo entity with some fields and foreign keys to the User entity.
Using fixtures, we will create a thousand to-do items and using the top-level REST api build suite API platform, we will load them in json format.
For this test, I used native Docker containers on an 11th Generation Intel(R) Core(TM) i7-1165G7 @ 2.80GHz CPU and 16go RAM.
The test itself consists of making an HTTP request to the RESTFUL API to retrieve a collection of backlog resources containing more items per page (from 10 to 1000 pages).
Therefore, the project must route the request, then use the API platform layer and ORM to query the todos from the database, and then serialize the object in the json response.
The container runs directly on the host where I send the request, so there is almost no network latency, I use Insomnia to measure response time.
GET /todos 10 / 50 / 100 / 500 / 1000 resources development stage
Page 10 resources in total: 177 milliseconds
Pages: 50 resources: 188 milliseconds
Pages with a total of 100 resources: 211 milliseconds
500 resource pages: 259 milliseconds
1000 resource pages: 346 milliseconds
Obtain 10 / 50 / 100 / 500 / 1000 resources production stage
Page 10 resources in total: 9.03 milliseconds
Pages: 50 resources: 15.1 milliseconds
Pages with a total of 100 resources: 29.2 milliseconds
500 resource pages: 106 milliseconds
1000 resource pages: 170 milliseconds
Conclusion The gap is huge, and the gap between development and production is nothing new. By creating the same REST API without Symfony and API Platform and all the comforts they bring, you can gain a few milliseconds more than this, which is completely absent and almost impossible to detect from human perception arrive. Frankenphp uses a similar mechanism by default early tips http codego routines, and many modern and fast concepts that can really improve the performance of your projects.
Go further
Safety instructions
We can make things more secure by not using the root user container side, which is a bad practice.
For this we need to follow Frankenphp official documentation.
Migrating Symfony to the upcoming 7.4 LTS
Again keep your project up to date every month and pay attention to deprecation warnings you can find.
Configuration
It all depends on your needs, are you developing a CLI application, an API or a monolithic application? How to host your application on a cluster, just host a bare metal on lambda, in all these cases you need to find better settings by adjusting the number of worker threads per core and the correct php configuration.
in conclusion
This template can actually start any project from a monolith to a REST API, almost any project built using PHP and Symfony. Use top-notch services like PostgreSQL and extend easily with the likes of Kubernetes and Karpenter, as well as the Gateway API to proxy and absorb most of the GET http incoming requests for high-demand projects. You can also use it to migrate existing projects to Frankenphp.
you can find Final boilerplate source code on Gitlab. Please feel free to contribute and I will maintain and update this post and the boilerplate, thank you for reading.