DEV Community

Cover image for Build a Symfony 7 boilerplate using FrankenPHP, Docker, PostgreSQL and php 8.4
Nicolas Bonnici
Nicolas Bonnici

Posted on

Build a Symfony 7 boilerplate using FrankenPHP, Docker, PostgreSQL and php 8.4

What are we cooking?

Cooking french tv meme

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.

Image description

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:

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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" ]

Enter fullscreen mode Exit fullscreen mode

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 "$@"

Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.*"
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

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

Unlimited power meme

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.jsonconfiguration 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"
        ]
    }
}
Enter fullscreen mode Exit fullscreen mode

Here's an overview of available composer commands:

To setup project's containers simply run:

composer setup
Enter fullscreen mode Exit fullscreen mode

Once built, you can start or stop project's containers like this:

composer up
Enter fullscreen mode Exit fullscreen mode
composer stop
Enter fullscreen mode Exit fullscreen mode

Destroys containers (but keep volumes)

composer down
Enter fullscreen mode Exit fullscreen mode

Migrate database

composer migrate
Enter fullscreen mode Exit fullscreen mode

Load fixtures

composer fixtures
Enter fullscreen mode Exit fullscreen mode

Connect to postgresql database

composer db
Enter fullscreen mode Exit fullscreen mode

Show logs

composer logs
Enter fullscreen mode Exit fullscreen mode

Fix code lint

composer lint:fix
Enter fullscreen mode Exit fullscreen mode

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" ]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Insomnia screen capture

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)