DEV Community

Cover image for Laravel + Franken PHP template
Denis
Denis

Posted on

Laravel + Franken PHP template

php

Hello everyone, my name is Denis, and I'm a PHP developer.

I participate in hackathons with the team жыбийрыр — and we faced an issue: we didn’t have a ready-made template to work with, forcing us to rewrite the same code repeatedly.

This article will cover how I created this template, the challenges I encountered, and my general desire to share the work I’ve done.

I want to note that this solution is not ideal, and I welcome constructive criticism in the comments.

Template repository

My GitHub

What’s included in the ready-made template:

  • Configured multi-threaded FrankenPHP server
  • Docker environment setup + multi-stage for local and production
  • Admin panel
  • API documentation for the project
  • System monitoring
  • Basic authentication logic (email verification)
  • WebSocket server
  • Configured pipeline (GitHub Actions)

Why FrankenPHP was chosen:

I wanted to experiment with a multi-threaded server, so the choice was between RoadRunner and FrankenPHP.

FrankenPHP was selected over RoadRunner because it’s easier to deploy and performs comparably in many benchmarks.

Tech stack:

  • Laravel 12
  • FrankenPHP
  • Docker/Docker Compose
  • Redis
  • PostgreSQL
  • Laravel Reverb (WebSocket server)
  • Horizon (queue management wrapper)
  • PhpStan / PhpCodeSniffer / Rector (static analyzers)
  • Filament (admin panel)
  • Beszel (lightweight monitoring)
  • Scribe (API documentation)
  • Traefik

Let’s start with the configured server + Docker:

The server is set up using Laravel Octane + FrankenPHP.

Laravel Octane is a provider for running applications on RR, Swoole, and FrankenPHP.

It also has excellent documentation for each server, including deployment guides.

Here’s the resulting Dockerfile:

FROM dunglas/frankenphp:1.4 AS base

RUN apt-get update \
    && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
    git \
    unzip \
    librabbitmq-dev \
    libpq-dev \
    supervisor

RUN install-php-extensions \
    gd \
    pcntl \
    opcache \
    pdo \
    pdo_pgsql \
    pgsql \
    redis \
    zip

WORKDIR /app

COPY --from=composer:2.8 /usr/bin/composer /usr/local/bin/composer

COPY --from=node:23 /usr/local/lib/node_modules /usr/local/lib/node_modules
COPY --from=node:23 /usr/local/bin/node /usr/local/bin/node

RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm

FROM base AS dev

COPY ./.docker/supervisor/supervisord.dev.conf /etc/supervisor/conf.d/supervisord.conf

CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

FROM base AS prod

COPY ./.docker/supervisor/supervisord.prod.conf /etc/supervisor/conf.d/supervisord.conf

CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
Enter fullscreen mode Exit fullscreen mode

This Dockerfile is compact, but it can be expanded in the future using multi-stage builds for both local development and production.

The server, queues, WebSocket, and cron tasks are managed via Supervisor.

Supervisor config:

[supervisord]
user=root
nodaemon=true
logfile=/dev/stdout
logfile_maxbytes=0
pidfile=/var/run/supervisord.pid

[program:octane]
command=php /app/artisan octane:frankenphp --watch
autostart=true
autorestart=true
stdout_events_enabled=true
stderr_events_enabled=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

[program:horizon]
command=php /app/artisan horizon
autostart=true
autorestart=true
stdout_events_enabled=true
stderr_events_enabled=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

[program:schedule]
command=php /app/artisan schedule:run
autostart=true
autorestart=true
stdout_events_enabled=true
stderr_events_enabled=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

[program:reverb]
command=php /app/artisan reverb:start
autostart=true
autorestart=true
stdout_events_enabled=true
stderr_events_enabled=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
Enter fullscreen mode Exit fullscreen mode

Locally, Traefik is used as a reverse proxy to handle port forwarding.

Example:

  traefik:
    image: traefik:v2.10
    container_name: traefik.${APP_NAMESPACE}
    command:
      - --api.insecure=true
      - --providers.docker=true
      - --entrypoints.web.address=:80
    ports:
      - "80:80"
      - "8080:8080"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    networks:
      - app
  php:
    build:
      context: .
      dockerfile: .docker/php/Dockerfile
      target: dev
    volumes:
      - .:/app
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.${APP_NAMESPACE}.rule=Host(`${APP_HOST:-localhost}`)"
      - "traefik.http.services${APP_NAMESPACE}.loadbalancer.server.port=${APP_PORT:-8000}"
Enter fullscreen mode Exit fullscreen mode

Two key nuances to highlight:

  1. Hot reload mode for the server
  2. Alpine images

About Hot Reload:

FrankenPHP can operate in two modes:

  1. Production mode – The server maintains state and requires a restart to reflect changes (resource-efficient).
  2. Development mode – The server detects changes and updates automatically (slower, suitable for local development).

In development mode, Node.js must be installed in the container since file watching relies on a JS library.

About Alpine images:

The documentation clearly explains why they should be avoided:

The static binaries we provide, as well as the Alpine Linux variant of the official Docker images, use the musl libc library.

PHP is known to perform significantly slower with this library compared to the traditional GNU libc, especially when compiled in ZTS mode (thread-safe mode), which is required for FrankenPHP.

Additionally, some bugs only appear when using musl.

Admin Panel:

We use Filament for projects because of its clear documentation, sleek interface, and rapid admin panel development capabilities.

However, it’s not ideal for large-scale projects due to performance issues.

API Documentation:

A crucial tool for us, as we often needed to showcase endpoints to stakeholders and reduce unnecessary questions from frontend developers about request structures.

The best solution for Laravel currently is Scribe.

Advantages of this library:

  • Auto-generates endpoints
  • Auto-generates query/URL/body params
  • Fine-grained config customization
  • Theme customization
  • Explicit parameter specification
  • Attribute support

Auto-generation works best with Laravel’s built-in Request and Resource classes.

Since the template uses DTOs from Spatie’s Laravel-Data, manual parameter descriptions are necessary.

Monitoring:

Initially, I considered the popular stack: Grafana, Loki, Prometheus, and Promtail.

But I realized it would be overkill for our needs.

Instead, I opted for a simpler yet functional solution: Beszel.

It was perfect for me—a clean interface and easy setup.

Basic Authentication:

A recurring headache was rewriting authentication for each hackathon and integrating it with the frontend.

So, I decided to include authentication + email verification in the template.

A role system was also added, including a "developer" role for accessing system services like the Horizon dashboard.

Example auth service:

<?php

declare(strict_types=1);

namespace App\Services\Controllers;

use Illuminate\Validation\ValidationException;
use Illuminate\Support\Facades\Hash;
use App\DTO\User\UserAuthShowDTO;
use App\DTO\Auth\AuthRegisterDTO;
use App\DTO\Auth\AuthLoginDTO;
use App\Models\User;

final class AuthService
{
    /** @return array<string, mixed> */
    public function register(AuthRegisterDTO $authRegisterDTO): array
    {
        $user = User::query()->create([
            'name'     => $authRegisterDTO->name,
            'role'     => $authRegisterDTO->role,
            'email'    => $authRegisterDTO->email,
            'password' => Hash::make($authRegisterDTO->password),
        ]);

        return UserAuthShowDTO::from($user)->toArray();
    }

    /**
     * @return array<string, mixed>
     * @throws ValidationException
     */
    public function login(AuthLoginDTO $authLoginDTO): array
    {
        $user = User::query()->where('email', $authLoginDTO->email)->firstOrFail();

        if (! Hash::check($authLoginDTO->password, $user->password)) {
            throw ValidationException::withMessages(['bad credentials']);
        }

        return UserAuthShowDTO::from($user)->toArray();
    }
}
Enter fullscreen mode Exit fullscreen mode

WebSocket Server:

In our last hackathon, we needed WebSockets for real-time race data transmission.

So, I added it to the template to save time in the future.

Currently, the best options for WebSocket servers are:

  • Centrifugo
  • Laravel Reverb

The best PHP solution for WebSockets is Centrifugo—highly performant and great for large projects.

However, I struggled to set it up quickly during the hackathon.

Instead, I included Laravel Reverb in the template—a decent choice for smaller services. It can later be scaled using queues for better performance.

GitHub Actions:

Configuring pipelines was straightforward, given the abundance of online examples. Here’s what I ended up with:

name: DEPLOY AND BUILD
on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]

jobs:
  coding-standard:
    name: Coding Standard
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          coverage: none

      - name: Get composer cache directory
        id: composer-cache
        run: |
          echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT

      - name: Cache composer dependencies
        uses: actions/cache@v4
        with:
          path: ${{ steps.composer-cache.outputs.dir }}
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: |
            ${{ runner.os }}-composer-

      - name: Install dependencies
        run: composer install --no-progress --no-suggest --prefer-dist --no-interaction --ignore-platform-reqs

      - name: Check coding style
        run: composer cs-check

      - name: Check code rector
        run: composer cs-rector

      - name: Perform a static analysis of the code base
        run: ./vendor/bin/phpstan analyse --memory-limit=2G

      - name: Test
        run: php artisan test
  deploy:
    runs-on: [ ubuntu-latest ]
    environment: deniskorbakov
    needs: coding-standard
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4.2.2
      - name: Push to server
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.SERVER_IP }}
          username: ${{ secrets.SERVER_USERNAME }}
          password: ${{ secrets.SERVER_PASSWORD }}
          script: |
            cd ${{ secrets.PROJECT_PATH }}
            make update-project
Enter fullscreen mode Exit fullscreen mode

One job checks code quality using static analyzers, and another deploys if changes are merged into main.

I moved all necessary commands into a Makefile to avoid overly long configs and to abstract deployment logic.

Makefile:

include .env

# a set of commands for updating a project in production
update-project: pull composer-install db-migrate build-front rm-images build-prod doc-generate restart

# a set of commands to initialize a project locally
init: build composer-install build-front key-generate storage-link db-migrate seed doc-generate restart build-wait

# a set of commands for initializing a project on production
init-prod: build-prod composer-install build-front key-generate storage-link db-migrate seed doc-generate restart build-prod

build:
    @echo "Building containers"
    @docker compose --env-file .env up -d --build
build-wait:
    @echo "Building containers"
    @docker compose --env-file .env up -d --build --wait
up:
    @echo "Starting containers"
    @docker compose --env-file .env up -d --remove-orphans
build-prod:
    @echo "Building containers"
    @docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env up -d --wait --build
up-prod:
    @echo "Starting containers"
    @docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env up -d --wait --remove-orphans
exec:
    @docker exec -it $$(docker ps -q -f name=php.${APP_NAMESPACE}) /bin/bash
code-check:
    @echo "Perform a static analysis of the code base"
    @DOCKER_CLI_HINTS=false docker exec -it $$(docker ps -q -f name=php.${APP_NAMESPACE}) vendor/bin/phpstan analyse --memory-limit=2G
    @echo "Perform a code rector"
    @DOCKER_CLI_HINTS=false docker exec -it $$(docker ps -q -f name=php.${APP_NAMESPACE}) composer cs-rector
    @echo "Perform a code style check"
    @DOCKER_CLI_HINTS=false docker exec -it $$(docker ps -q -f name=php.${APP_NAMESPACE}) composer cs-check
rector-fix:
    @echo "Fix code with rector"
    @DOCKER_CLI_HINTS=false docker exec -it $$(docker ps -q -f name=php.${APP_NAMESPACE}) composer cs-rector-fix
code-baseline:
    @echo "Perform phpstan generate-baseline"
    @DOCKER_CLI_HINTS=false docker exec -it $$(docker ps -q -f name=php.${APP_NAMESPACE}) vendor/bin/phpstan analyse --generate-baseline --memory-limit=2G
composer-install:
    @echo "Running composer install"
    @docker exec -i $$(docker ps -q -f name=php.${APP_NAMESPACE}) composer install --ignore-platform-reqs
db-migrate:
    @echo "Running database migrations"
    @docker exec -i $$(docker ps -q -f name=php.${APP_NAMESPACE}) php artisan migrate --force
build-front:
    @echo "Building admin frontend for production"
    @docker exec -i $$(docker ps -q -f name=php.${APP_NAMESPACE}) npm i
    @docker exec -i $$(docker ps -q -f name=php.${APP_NAMESPACE}) npm run build
pull:
    @echo "Updating project from git and rebuild"
    @git pull
rm-images:
    @echo "Delete extra images"
    @docker system prune -f
key-generate:
    @echo "Key generate"
    @docker exec -i $$(docker ps -q -f name=php.${APP_NAMESPACE}) php artisan key:generate
storage-link:
    @echo "Storage Link"
    @docker exec -i $$(docker ps -q -f name=php.${APP_NAMESPACE}) php artisan storage:link
seed:
    @echo "Db Seed"
    @docker exec -i $$(docker ps -q -f name=php.${APP_NAMESPACE}) php artisan db:seed
doc-generate:
    @echo "Key generate"
    @docker exec -i $$(docker ps -q -f name=php.${APP_NAMESPACE}) php artisan scribe:generate
restart:
    @echo "restart container"
    @docker restart php.${APP_NAMESPACE}
Enter fullscreen mode Exit fullscreen mode

Conclusion:

That’s it! This article covered the key points. If you’d like a deeper dive into any topic, let me know in the comments.

For a detailed look, check out the repository.

Thanks to everyone who read this post. This is my first publication on Habr, so go easy on me—I tried to focus on the most interesting aspects.

Top comments (0)