Всех приветствую, меня зовут Денис, я PHP разработчик
Я выступаю на хакатонах за команду «жыбийрыр» — https://жыбийрыр.рф/ и у нас была проблема, что не было готового шаблона, с которым мы могли спокойно выступать и заново не писать один и тот же код
Эта статья будет посвящена тому, как я писал этот шаблон, с какими ошибками столкнулся и в целом есть больше желание, поделиться проделанной работой
Хочу отметить, что это решение не эталонное и буду ждать в комментариях конструктивной критики
телеграм для связи: @Deniskorbakov
Что есть в готовом шаблоне:
Настроеный многопоточный сервер FrankenPHP
Поднятно окружение в докере + multi stage под локально и прод
Админка
Апи документация проекта
Мониторинг системы
Логика базовой авторизацию - верификации по почте
Веб сокет сервер
Настроен pipline - GitHub Actions
Почему был выбран FrankenPHP:
Было желание попробовать многопоточный сервер, поэтому выбор пал на RoadRunner и FrankenPHP
FrankenPHP был выбран вместо RoadRunner, потому что его легче развернуть и во многих бенчмарках он не уступает RR
Стек технологий:
Laravel 12
FrankenPHP
Docker/Docker-compose
Redis
PostgreSQL
Laravel Reverb - вебсокет сервер
Horizon - овбертка для очередей
PhpStan/PhpCodesniffer/Rector - стат анализаторы
Filament - админка
Beszel - легковесный мониторинг
Scribe - апи документация
Traefik
Давайте начнём с настроеного сервера + docker:
Сервер настроен с помощью Laravel Octane + FrankenPHP
Laravel Octane - это провайдер для обслуживания серверов на RR, Swoole, Franken
так же у laravel Octane хорошая документация по каждому серверу + сказано как развернуть это всё дело на проде
Покажу 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"]
Этот докер файл небольшой, из интересного что в будущем его можно расшить благодаря multi-stage для локальной разработки, так и для прода
Запуск сервера, очередей, вебсокета, крон задач происходит через supervisor
Конфиг:
[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
Так же локально стоит traefik, он выступает как реверс прокси - который просто делает переадресацию по портам
Пример:
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}"
Хочу подметить два главных нюанса:
Hot reload режим сервера
Alpine образы
Про Hot reload:
Franken может работать в двух режимах
1) Prod режим - когда сервер сохраняет состояние и надо заново перезапускать сервер, чтоб он изменил состояние (простая экономия ресурсов)
2) Dev режим - когда сервер подтягивает изменения и сам под капотом обновляет сервер (данное решение медленее и подходит для локальной разработки)
При Dev режиме надо чтоб у вас в контейнере обязательно была установлена Node js так как для просмотра изменения файлов выступает js библиотека
Про alpine образы:
В документации четко описывается, почему их не стоит использовать:
Статические бинарники, которые мы предоставляем, а также Alpine Linux-вариант официальных Docker-образов используют библиотеку musl libc.
Известно, что PHP значительно медленнее работает с этой библиотекой по сравнению с традиционной GNU libc, особенно при компиляции в ZTS режиме (потокобезопасный режим), который требуется для FrankenPHP.
Кроме того, некоторые ошибки проявляются исключительно при использовании musl.
Админка:
Мы используем Filament для проектов, так как у нее легкая документация, красивая оболочка и с ее помощью можно быстро и красиво строить админку
Если ее использовать в нормальных проектах, то она особо для этого не подойдет, так как она сильно тормозит
Апи документация:
Один из важных инструментов для нас, так как надо было часто показывать наши ручки кейсодателям и также отгородить себя от лишних вопросов фронтендеров, что должно быть в том или ином запросе
Сейчас лучшее решение для laravel, это - https://scribe.knuckles.wtf/laravel/
Плюсы данной библиотеке:
Автогенерация ручек
Автогенерация query params
Автогенерация url params
Автогенерация body params
Тонкая настройка конфига
Кастомизация темы
Возможность указыть явно те или иные параметры
Поддержка атрибутов
Автогенерация будет работать только в том случае, если вы используете Laravel Request и Resource - то есть встроенные классы laravel
Так как в шаблоне я использую DTO от https://spatie.be/docs/laravel-data/v4/introduction
То приходиться ручками описать все параметры
Мониторинг:
В этот проект, я хотел добавить популярный стек - grafana, loki, prometeus, promtail
Но потом понял что это будет пустая трата времени и ресурсов сервера
Поэтому подключил простенький, но зато функциональный https://github.com/henrygd/beszel
Для меня он подошел идеально - так как красивый интерфейс и очень простая установка
Базовая авторизация:
Главная проблема и головная боль, что мы с каждым хакатоном заново писали авторизацию и связывали с фронтом
Поэтому было принято решение сразу в шаблон добавить авторизацию + верификацию пользователей
Также есть ролевка и добавлена роль - разработчик, при которой можно получить доступ только к системным сервисам, как, например дашборд Horizon
Пример сервиса для авторизации:
<?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();
}
}
Вебсокет сервер:
На последнем хакатоне нам надо было использовать вебсокеты для передачи данных для забега в real time
Поэтому также решил его добавить в шаблон, чтоб в будущем на это не тратить время
На данный этап для вебсокет сервера стоит использовать:
Centrifugo
Larvel reverb
Самое лучшее решение на пхп в использовании вебсокетов - это использовать Centrifugo, так как она очень производительная и хорошо подойдет для больших проектов
Но у меня не получилось на хакатоне быстро его поднять и связать с Laravel
Поэтому я решил включить в сборку Laravel Reverb, неплохое решение, если ваш сервис будет небольшой - так же в будущем можно расширяться благодря очердям, что позволить улучшить производительность вебсокетов
GitHub Actions:
Настройка пайпланов была самой легкой, так как в интернете очень много примеров - поэтому покажу, что у меня получилось
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
В одной джобе будет проверяться код стат анализаторами и также деплоиться в случае если изменения слиты в мейн ветку
Я решил все нужные мне команды вынести в makefile, чтобы не писать слишком длинный конфиг, да и в целом неплохая идея вынести нужные команды для работы с деплоем в отдельную прослойку
Makefile:
include .env
# набор команд для обновление проекта в продакшене
update-project: pull composer-install db-migrate build-front rm-images build-prod doc-generate restart
# набор команд для инициализации проекта локально
init: build composer-install build-front key-generate storage-link db-migrate seed doc-generate restart build-wait
# набор команд для инициализации проекта на проде
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
shell:
@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}
Заключение:
В целом всё — в этой статье я рассказал про ключевые моменты, если нужно что‑то раскрыть более подробно, напишите об этом комментарий.
Для более детального ознакомления прошу перейти в репозиторий.
Благодарю всех, кто прочитал этот пост. Это моя первая публикация на Хабре, поэтому не судите строго, постарался раскрыть только самые интересные моменты