Symfony Init — быстрый старт проекта без лишней рутины

в 17:15, , рубрики: frankenphp, php, php-fpm, symfony

Каждый раз, когда хотелось быстро попробовать что‑то на Symfony, начиналась одна и та же история: поднять контейнер с PHP‑FPM или FrankenPHP, провалиться в контейнер, поставить symfony/skeleton, настроить Nginx или Caddyfile, прописать переменные окружения... И всё это до того, как написана хоть одна строчка кода.

DI‑контейнер, консольные команды, компонентная архитектура... Ни для кого не секрет, Symfony заметно ориентируется на Java‑экосистему. Почему бы не попробовать сделать что‑то подобии start.spring.io подумал я.

Так появился пет-проект symfony-init.dev

Что получает пользователь

Выбираешь параметры на сайте:

  • PHP: 8.3, 8.4, 8.5 (версии из php.net в реальном времени)

  • Сервер приложения: PHP-FPM + Nginx или FrankenPHP

  • Symfony: актуальные версии (из symfony.com в реальном времени)

  • База данных: PostgreSQL, MySQL, MariaDB, SQLite или без БД

  • Кэш: Redis, Memcached или без кэша.

  • Распространенные расширения: Doctrine ORM, Security, Mailer, Messenger, Validator, Serializer, API Platform, HTTP Client, Nelmio API Doc

  • Брокер сообщений: RabbitMQ

Нажимаешь "Generate" - получаешь ZIP с полноценным проектом. Внутри: Symfony, docker-compose.yml, Dockerfile, конфиг веб-сервера, настроенный .env. Запускаешь одной командой:

docker compose up -d --build
Symfony Init — быстрый старт проекта без лишней рутины - 1

Вот и всё.

Стек самого сервиса

Немного иронично, но сервис написан на том же Symfony 7.4, работает под FrankenPHP, покрыт PHP CS Fixer с правилами @Symfony.

Как это устроено изнутри

Генерация

Ключевой класс - ProjectBuilder. Он последовательно:

  • Запускает composer create-project symfony/skeleton с флагом --no-install

  • Генерирует через Twig Dockerfile, docker-compose.yml и конфиг сервера

  • Патчит composer.json - выставляет нужную версию PHP в require

  • Устанавливает выбранные пакеты через composer require

  • Запускает composer install --optimize-autoloader

  • Чистит docker-блоки, которые Symfony Flex добавляет в compose-файл

Последний пункт - важный нюанс. Symfony Flex при установке пакетов сам добавляет ###>bundle### блоки в docker-compose.yml. Чтобы они не дублировали наши сервисы (postgres, redis и т.д.), генерация идёт с SYMFONY_SKIP_DOCKER=1, а остатки зачищаются регуляркой:

$content = preg_replace('/n*###>.*?###.*?n.*?###<.*?###.*?n*/s', "n", $content);
Symfony Init — быстрый старт проекта без лишней рутины - 2

Расширения - через систему тегов Symfony. Каждое расширение - отдельный класс с тегом app.extension, ExtensionRegistry собирает их через #[TaggedIterator] и умеет резолвить граф зависимостей. Например, выбрал API Platform - автоматически подтянется Doctrine ORM, Serializer и Nelmio API Doc.

Кэширование

Самое интересное решение в проекте - кэш на файловой системе. Каждая комбинация параметров (PHP × Symfony × сервер × расширения × БД × кэш × RabbitMQ) превращается в стабильный ключ:

public function cacheKey(): string
{
    $ext = $this->extensions;
    sort($ext);

    return sprintf(
        'project_%s_%s_%s_%s_%s_%s_%s',
        $this->phpVersion,
        $this->server,
        $this->symfonyVersion,
        implode('_', $ext),
        $this->database ?? 'none',
        $this->cache ?? 'nocache',
        $this->rabbitmq ? 'rabbitmq' : 'norabbitmq'
    );
}
Symfony Init — быстрый старт проекта без лишней рутины - 3

Имя проекта в ключ не входит намеренно - оно влияет только на имя папки в архиве, но не на содержимое файлов. Так один и тот же собранный проект используется для всех пользователей с одинаковым стеком. Когда приходит запрос, сервис проверяет кэш. Если нашёл - берёт готовую директорию и упаковывает в ZIP с именем проекта пользователя. Если нет - запускает полную сборку, сохраняет результат в var/share/projects/, возвращает путь. TTL кэша - 24 часа.

$cachedPath = $this->cache->get(
    $config->cacheKey(),
    function (ItemInterface $item) use ($config): string {
        $item->expiresAfter(self::CACHE_TTL); // 86400 сек
        return $this->buildAndCache($config);
    }
);

return $this->zipper->createZip($cachedPath, $config->projectName);
Symfony Init — быстрый старт проекта без лишней рутины - 4

Для прогрева кэша есть консольная команда:

# только популярные конфигурации (по умолчанию)
php bin/console app:warm-cache --popular-only

# все базовые комбинации PHP × Symfony × сервер, без расширений/БД (дольше)
php bin/console app:warm-cache --all-base
Symfony Init — быстрый старт проекта без лишней рутины - 5

Почему нет Redis и базы данных - и вообще зачем так просто

Вопрос закономерный. Redis - очевидное решение для кэша с TTL. Но здесь мы кэшируем не строки и не сессии, а директории с файлами весом несколько мегабайт каждая. Хранить такие данные в Redis - не его назначение: он оперирует данными в памяти, и кэшировать туда целые проекты было бы расточительно.

Файловая система - правильный выбор для этой задачи. Symfony Cache компонент из коробки поддерживает cache.adapter.filesystem, данные живут в var/share/pools/, ключи - в var/share/pools/cache.projects/. Никакой дополнительной инфраструктуры, никакой синхронизации.

База данных тоже не нужна: у сервиса нет пользователей, нет истории запросов, нет ничего, что требует реляционного хранилища. Добавление postgres только ради rate limiting или метрик - оверинжиниринг.

Та же логика применима к архитектуре в целом. FrankenPHP умеет работать в worker mode - когда PHP-процесс не умирает после каждого запроса, а крутится в цикле, и приложение загружается один раз. Это даёт реальный прирост производительности для типичного веб-приложения с высоким RPS. Но у нас узкое место - не PHP, а запуск процессов Composer, который занимает секунды. Worker mode тут ничего не ускорит, зато потребует аккуратной работы со state между запросами. Не нужно.

Аналогично с горизонтальным масштабированием. Можно поднять несколько реплик сервиса - но тогда файловый кэш, который живёт в var/share/, перестаёт работать как единое хранилище: каждый конте��нер видит только свои данные. Понадобился бы shared volume, или пришлось бы переходить на централизованное хранилище. Для текущей нагрузки это лишний уровень сложности, который ничего не даёт. Один контейнер, один файловый кэш, lock через Symfony Lock - вполне достаточно.

Почему ZIP, а не просто composer.json

Это ключевой вопрос, который определяет ценность сервиса. Если отдавать только composer.json - пользователь всё равно оказывается в той же ситуации: нужен PHP, нужен Composer, нужно вручную запустить composer install, настроить веб-сервер. Проблема не решена.

Настоящая ценность - именно в собранном проекте: уже выполнен composer install, уже есть vendor/, уже сгенерирован composer.lock, уже прописан Dockerfile с правильными PHP-расширениями, уже есть docker-compose.yml с нужными сервисами, уже настроен .env с корректными DSN. Пользователь получает проект в состоянии "распакуй и docker compose up".

Кроме того, composer.lock в проекте важен: он фиксирует точные версии всех зависимостей, что обеспечивает воспроизводимые сборки. Сгенерировать его правильно без реальной установки пакетов невозможно.

Rate limiting и безопасность

На /generate стоит rate limiter: 30 запросов в час на IP, sliding window. Реализован через RateLimitSubscriber - event subscriber на kernel.controller. При превышении лимита возвращается 429 с заголовками Retry-After и X-RateLimit-Remaining.

Генерация - дорогая операция: запускаются процессы Composer, уходит несколько секунд на сборку. Кэш амортизирует большинство запросов - популярные конфигурации уже прогреты и отдаются мгновенно. Но если намеренно перебирать редкие сочетания параметров, каждый такой запрос будет промахиваться мимо кэша и запускать полную сборку с нуля. На слабом сервере это ощутимо. Rate limiter отсекает такой сценарий.

Небольшие замечания

Метод cleanupFlexDockerFiles через регулярку - работает, но хрупко. Если Symfony Flex изменит формат своих блоков, тихо сломается. Можно наверное добавить хотя бы предупреждение в лог, если файл после зачистки выглядит подозрительно.

Имя проекта не попадает в ключ кэша - это правильное решение по логике, но стоит задокументировать явно, потому что composer.json содержит name пакета, и два пользователя с разными именами проекта получат одинаковый composer.json. Для утилитарного стартера это нормально, но может удивить.

Итог

Сервис решает для меня конкретную проблему: убирает рутину при старте Symfony-проекта, плюс это отличный способ попрактиковаться с фреймворком. Каждое архитектурное решение - worker mode, масштабирование, Redis — было осознанно отклонено, потому что добавило бы сложность без реальной пользы. Один контейнер, файловый кэш, простой lock. Иногда лучшее решение — самое скучное.

Сервис крутится на довольно скромном VPS: 1 ядро 2.2 ГГц, 0.5 ГБ RAM, 10 ГБ HDD. Если он окажется недоступен или нагрузка вырастет - его всегда можно поднять локально, репозиторий открыт

Буду рад обратной связи, пулл-реквестам и звёздочкам на GitHub (они бесплатные, но очень мотивируют :-)

Попробовать: symfony-init.dev

Автор: HotFixer

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js