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.
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"]
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
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}"
Two key nuances to highlight:
- Hot reload mode for the server
- Alpine images
About Hot Reload:
FrankenPHP can operate in two modes:
- Production mode – The server maintains state and requires a restart to reflect changes (resource-efficient).
- 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();
}
}
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
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}
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)