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

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


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:

Enter full screen mode

Exit full screen mode

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

Enter full screen mode

Exit full screen mode

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

Enter full screen mode

Exit full screen mode

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

Enter full screen mode

Exit full screen mode

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
}
Enter full screen mode

Exit full screen mode

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

Enter full screen mode

Exit full screen mode

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.jsonProfile.

{

    ...

    "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 full screen mode

Exit full screen mode

The following is an overview of the available Composer commands:

To set up your project’s container, just run:

composer setup
Enter full screen mode

Exit full screen mode

After the setup is complete, you can start or stop the project’s containers like this:

composer up
Enter full screen mode

Exit full screen mode

composer stop
Enter full screen mode

Exit full screen mode

Destroy the container (but keep the volume)

composer down
Enter full screen mode

Exit full screen mode

Migrate database

composer migrate
Enter full screen mode

Exit full screen mode

load fixture

composer fixtures
Enter full screen mode

Exit full screen mode

Connect to postgresql database

composer db
Enter full screen mode

Exit full screen mode

show log

composer logs
Enter full screen mode

Exit full screen mode

Fix code lint

composer lint:fix
Enter full screen mode

Exit full screen mode


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" ]
Enter full screen mode

Exit full screen mode

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
Enter full screen mode

Exit full screen mode

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.

2024-12-23 08:08:47

Leave a Reply

Your email address will not be published. Required fields are marked *