Всех приветствую, меня зовут Денис, я 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}
Заключение:
В целом всё — в этой статье я рассказал про ключевые моменты, если нужно что‑то раскрыть более подробно, напишите об этом комментарий.
Для более детального ознакомления прошу перейти в репозиторий.
Благодарю всех, кто прочитал этот пост. Это моя первая публикация на Хабре, поэтому не судите строго, постарался раскрыть только самые интересные моменты
