Деплой Symfony каждый раз сводится к одному и тому же короткому списку: включить продакшен-режим, выставить нужные переменные окружения, установить зависимости без dev-пакетов, прогреть кеш, прогнать миграции базы, сделать каталог var/ доступным для записи и решить, что будет запускать ваши воркеры Messenger. FrankenPHP этот список не меняет. Он меняет сервер под ним: вместо nginx, который обращается к PHP-FPM, HTTP обслуживает и PHP выполняет один процесс на базе Caddy. Для приложения на Symfony это в основном хорошая новость, а в этом руководстве разобраны места, где всё не так гладко.
Текст написан для линейки Laravel / PHP на RE:NODE: она запускает Laravel или Symfony на FrankenPHP с PHP 8.3, 8.4 или 8.5, а Composer устанавливает зависимости при старте. Symfony-часть каждой команды ниже одинакова на любом хостинге. Платформенная часть - куда класть переменные, как до вас доходит слот прокси, что даёт панель - выделена отдельно, а всё, что платформа не документирует, названо прямо, без догадок.
Что меняется с FrankenPHP#
Классический стек PHP состоит из трёх частей: веб-сервера (nginx или Apache), PHP-FPM и связки между ними. FrankenPHP сводит всё это в один бинарный файл. Он встраивает интерпретатор PHP в веб-сервер на Go, построенный на Caddy, поэтому запускать нужно один процесс, читать один лог, и нет строки fastcgi_pass, в которой можно ошибиться.
Для приложения на Symfony это даёт три практических следствия.
- Конфиг nginx переносить не нужно. Блоки
try_filesиfastcgi_split_path_infoиз документации Symfony нужны, чтобы направить каждый запрос вpublic/index.php. В Caddy для этого есть собственная идиома front controller, и проекту на Symfony под FrankenPHP ваш старый блок server не требуется. - Режим worker существует, но необязателен. В обычном режиме FrankenPHP ведёт себя как PHP-FPM: каждый запрос загружает фреймворк, обрабатывается, и всё выбрасывается. В режиме worker ядро Symfony загружается один раз и остаётся в памяти между запросами, что может заметно сократить время запроса. Для него нужен пакет
runtime/frankenphp-symfony, и действуют те же предостережения, что для любого долгоживущего процесса PHP: статическое состояние и состояние сервисов утекает между запросами, если сервисы не сбрасываются. Symfony сбрасывает большинство своих сервисов сама, но за ваш код отвечаете вы. Считайте режим worker этапом тюнинга после того, как приложение заработало, а не условием деплоя. - Версия PHP - настройка платформы. На этой линейке RE:NODE предлагает 8.3, 8.4 и 8.5. Symfony 7.4 требует PHP 8.2 или новее, а Symfony 8.0 требует PHP 8.4, поэтому перед выбором проверьте ограничение
require.phpи версииsymfony/*в вашемcomposer.json. Берите самый новый PHP, который допускает всё дерево ваших зависимостей.
Всё остальное в этом руководстве - обычный Symfony. Если вы прикидываете, сколько из этого переносится с Laravel, деплой Laravel в продакшен разбирает фреймворковую сторону того же стека, и эти два текста различаются в основном названиями: APP_ENV=prod против production, bin/console против artisan, Messenger против воркера очередей.
Продакшен-режим: APP_ENV, APP_DEBUG и APP_SECRET#
Symfony почти всё определяет по трём переменным. В продакшене они такие:
APP_ENV=prodAPP_DEBUG=0APP_SECRET=replace-with-a-long-random-stringAPP_ENV=prod выбирает конфигурацию prod и блоки when@prod в config/. Он же не даёт фреймворку на каждом запросе заново компилировать контейнер и проверять время изменения шаблонов. APP_DEBUG=0 отключает debug-контейнер и страницу исключения, которая печатает пути к файлам, аргументы сервисов и сведения об окружении. Запускать APP_ENV=prod вместе с APP_DEBUG=1 можно, но не стоит: вы получите кеширование продакшена и утечки информации, свойственные разработке.
APP_SECRET используется для CSRF-токенов, подписанных URL, cookie «запомнить меня» и всего остального, что подписывает Symfony. Сгенерируйте его один раз и больше не трогайте: смена значения делает недействительными подписанные ссылки и разлогинивает пользователей. Разумный способ получить такой секрет:
$ php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;'Окружение по умолчанию, когда ничто его не задаёт, берётся из строки APP_ENV в .env, а рецепт поставляет её со значением dev. Это первое, что нужно проверить, если свежезадеплоенное приложение показывает всему интернету панель профилировщика Symfony: ни одна настоящая переменная не переопределила файл.
.env, .env.local и настоящие переменные окружения#
Компонент Dotenv в Symfony загружает файлы в строго определённом порядке, и если его понимать, исчезает большинство обращений вида «почему он использует не ту базу». Файлы, от низшего приоритета к высшему:
| Файл | В репозитории? | Назначение |
|---|---|---|
.env | Да | Значения по умолчанию и описание каждой переменной |
.env.local | Нет | Переопределения для конкретной машины, не читается при APP_ENV=test |
.env.prod | Да | Значения по умолчанию для окружения prod |
.env.prod.local | Нет | Переопределения prod для конкретной машины |
Над всеми ними стоят настоящие переменные окружения: значения, которые задаёт процесс, запускающий PHP. Dotenv никогда не перезаписывает уже существующую переменную, поэтому настоящая переменная всегда побеждает любой файл. Именно это свойство нужно в продакшене. Файлы - удобство для разработки, а источник истины - окружение самого сервера.
Из этого следует:
- Коммитьте
.envтолько с безопасными заглушками. Он документирует имена переменных и служит базовой линией. - Не кладите продакшен-секреты в
.envи никогда не коммитьте.env.local. Всё, что попало в git, нужно считать утёкшим. - На RE:NODE задавайте продакшен-значения как переменные окружения на вкладке Startup сервера. Это настоящие переменные, поэтому они переопределяют файлы. В материале переменные окружения и секреты разобрано, что где должно лежать и что нельзя писать в лог.
- Если вам удобнее файл, создайте
.env.localна сервере через файловый менеджер или SFTP. Это работает, но файл лежит в каталоге проекта, поэтому не включайте его ни в какие архивы, которыми делитесь.
Стоит знать две родные альтернативы Symfony. Первая - composer dump-env prod: она компилирует файлы .env в один массив .env.local.php, который загружается без разбора текста при каждом старте. Запускайте её на той машине, которая будет обслуживать приложение, как часть деплоя, и помните, что она замораживает значения: изменили переменную - нужно выполнить dump заново. Вторая - хранилище секретов, php bin/console secrets:set DATABASE_PASSWORD --env=prod: оно хранит зашифрованные значения в config/secrets/prod/, и их можно коммитить. Тогда на сервере нужна всего одна настоящая переменная - ключ расшифровки SYMFONY_DECRYPTION_SECRET, - а php bin/console secrets:decrypt-to-local --env=prod превратит хранилище в локальный файл, если он вам понадобится.
Composer при старте и продакшен-установка#
На этой линейке приложений Composer устанавливает зависимости при запуске сервера, поэтому новый composer.lock вступает в силу при следующем перезапуске. Правило со стороны Symfony то же, что и везде: коммитьте composer.lock, чтобы установка разрешала в точности те версии, которые вы тестировали. Без lock-файла каждый старт заново разрешает ограничения, и версия в продакшене - это то, что Packagist предложил в то утро.
Продакшен-установка должна быть эквивалентна команде:
$ composer install --no-dev --optimize-autoloader --no-interaction--no-dev пропускает пакеты из require-dev: профилировщик, maker bundle, PHPUnit, отладочные инструменты. --optimize-autoloader строит карту классов, чтобы PHP не искал их в файловой системе при каждой автозагрузке. В приложениях Symfony в composer.json есть ещё раздел auto-scripts, который рецепт подключает к post-install-cmd, чтобы cache:clear и assets:install public выполнялись после каждой установки. Поэтому установка и шаг с кешем переплетены.
Из этого вытекает классический сбой. Если к моменту завершения Composer APP_ENV не равен prod, а dev-пакетов нет, то cache:clear после установки пытается собрать контейнер dev, не находит Symfony\Bundle\DebugBundle\DebugBundle среди dev-записей config/bundles.php и падает. Задайте APP_ENV=prod в окружении до установки, а не после, и ошибка не появится. Если на staging-сервере вам нужны dev-пакеты, оставьте их и задайте там APP_ENV=dev намеренно.
Платформа не документирует, с какими флагами выполняется установка при старте, поэтому не гадайте, а прочитайте вывод консоли при первом запуске. Консоль показывает вывод установки вживую, без фильтрации. Материал как читать консоль объясняет историю, свёрнутый шум и график памяти, который при первом запуске лучше держать открытым.
Front-end ассеты - это другой шаг сборки. С AssetMapper команда php bin/console asset-map:compile записывает файлы с отпечатками в public/assets/. С Webpack Encore или Vite собирайте на своей машине или в CI и загружайте результат: ставить Node и компилировать большой бандл на тарифе с 1 GB - верный способ упереться в лимит памяти.
Очистка и прогрев кеша#
Продакшен-кеш лежит в var/cache/prod/. В нём скомпилированный контейнер сервисов, таблица маршрутов, скомпилированные шаблоны Twig, метаданные валидатора и сериализатора и прокси-классы Doctrine. Всё это генерируется, ничто из этого не должно попадать в git, а устаревшая копия - причина удивительно большой доли жалоб «я задеплоил, а ничего не изменилось».
Важны две команды:
$ php bin/console cache:clear$ php bin/console cache:warmupВ окружении prod команда cache:clear пересобирает кеш и прогревает его в том же запуске, поэтому обычно хватает одной команды; cache:warmup нужна, чтобы прогреть без очистки, а --no-warmup у cache:clear пропускает прогрев. Причина прогревать кеш в деплое, а не оставлять это первому запросу, проста: первый запрос после холодного кеша компилирует контейнер, может занять много секунд и несколько сотен мегабайт памяти. На маленьком тарифе один такой всплеск способен сам достичь лимита.
Две привычки помогают, чтобы всё проходило без происшествий:
- Собирайте кеш там, где он будет использоваться. Скомпилированный контейнер содержит абсолютные пути. Кеш, собранный на ноутбуке и загруженный на сервер, указывает на каталоги, которых там нет.
- Не смешивайте пользователей. Если команда, запущенная под одним пользователем, создаёт
var/cache/prod/, а веб-процесс работает под другим, второй не сможет туда писать. Консоль панели и серверный процесс работают под одним пользователем контейнера, и это снимает проблему; сессия SFTP под другим пользователем может создать её заново, поэтому после загрузки очищайте кеш из консоли.
Учтите также, что кешированная конфигурация - причина того, что изменённая переменная окружения будто бы ничего не делает. Symfony разрешает значения %env()% во время выполнения, поэтому обычные переменные в кеш не запекаются, но всё, что скомпилировано из контейнера, например параметр, который вы вывели в services.yaml, запекается. Если сомневаетесь, очищайте.
Миграции Doctrine#
Изменения схемы поставляются как классы миграций в migrations/, а продакшен-команда такая:
$ php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration--no-interaction отключает запрос подтверждения, на котором завис бы неинтерактивный деплой. --allow-no-migration заставляет команду завершаться успешно, когда выполнять нечего, и скрипт деплоя не падает в обычном случае релиза без изменений схемы. Перед первым деплоем и в любой момент сомнений doctrine:migrations:status печатает настроенную базу, текущую версию и число ожидающих миграций, а doctrine:schema:validate сравнивает ваш маппинг с живой схемой.
Никогда не используйте doctrine:schema:update --force в продакшене. Он вычисляет разницу и применяет её за один шаг, без проверки и без записи в журнал. Создавайте миграции в разработке командой php bin/console doctrine:migrations:diff, прочитайте SQL, закоммитьте его, а продакшен пусть выполняет только закоммиченные миграции.
Подключение задаёт переменная DATABASE_URL. Схема соответствует движку, а хост, порт, пользователь и пароль берутся оттуда, где создана база:
DATABASE_URL="postgresql://app:secret@db.example.net:5432/app?serverVersion=16&charset=utf8"Для MySQL схема - mysql://, а serverVersion - это реальная версия сервера, а не догадка: Doctrine по ней выбирает возможности SQL-диалекта, и неверное значение даёт миграции, которые падают странным образом. На RE:NODE у сервера приложения есть слоты баз данных, которые создаются в панели с сгенерированными хостом, пользователем и паролем, а отдельная линейка баз данных продаёт PostgreSQL, MySQL 8.4 LTS и другие на собственном хосте и порту. Берите значения из панели и кодируйте пароль в URL, если в нём есть такие символы, как @, / или :, иначе Doctrine разберёт URL в бессмыслицу. Про вторую половину истории, резервные копии, рассказывает материал резервные копии баз данных и восстановление.
Порядок имеет значение. Релиз, в котором код требует новой колонки раньше, чем выполнена миграция, на всё время разрыва отдаёт каждому посетителю ошибку 500. Для всего, что серьёзнее небольшого сайта, пишите обратно совместимые миграции: добавьте колонку как nullable, задеплойте код, который с этим справляется, заполните данные и ужесточите ограничение в следующем релизе. Приём разобран в материале миграции без простоя. Делайте backup перед любой миграцией, которая что-то удаляет или переименовывает; на RE:NODE вкладка Backups делает это по запросу и восстанавливает одной кнопкой.
Воркеры Messenger, транспорт и планировщик#
Всё медленное в запросе - почта, обработка изображений, обращения к сторонним сервисам - должно уходить в транспорт Messenger, а транспорту нужен процесс-потребитель, который живёт дольше запроса. Сначала выберите транспорт в .env:
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0Транспорт Doctrine хранит сообщения в таблице базы и не требует ничего дополнительного. В продакшене задайте auto_setup=0 и создайте таблицу один раз командой php bin/console messenger:setup-transports, чтобы приложение никогда не пыталось менять схему во время работы. Для большей пропускной способности следующий шаг - транспорт на Valkey (redis:// в DSN, так как Valkey говорит на протоколе Redis), см. очереди задач на Valkey.
Потребитель - это долгоживущая команда:
$ php bin/console messenger:consume async --time-limit=3600 --memory-limit=128MВажнее всего два лимита. --time-limit=3600 завершает воркер через час, а --memory-limit=128M завершает его, когда он вырастает сверх этого значения, что защищает от медленной утечки в коде, который вы не контролируете. Предполагается, что супервизор запустит его снова, и при деплое в контейнере на этот вопрос нужно ответить самому.
Сервер RE:NODE - это один контейнер с одним процессом. Вкладки панели построены вокруг этого, поэтому честное решение - считать потребителя отдельным сервером: второй сервер приложения на той же линейке, с тем же репозиторием, теми же переменными окружения и той же базой, запускаемый командой, которая запускает воркер вместо веб-сервера. Проверьте на вкладке Startup вашего тарифа, можно ли менять команду запуска: описание платформы подтверждает там переменные окружения и ничего не говорит о правке команды. Если нельзя, остаются варианты: транспорт Messenger, который обрабатывается небольшой консольной задачей по расписанию, или транспорт sync при небольшой нагрузке, пока вы решаете. Не запускайте воркер фоновым процессом внутри консольной сессии самого веб-сервера в расчёте на то, что он переживёт перезапуск.
После каждого деплоя потребитель держит в памяти старый код. Скажите ему остановиться командой php bin/console messenger:stop-workers: она ставит флаг в пуле кеша, каждый воркер дорабатывает текущее сообщение и завершается, а перезапуск возвращает его уже на новом коде. Неудачные сообщения попадают в транспорт ошибок, если вы его настроили, и дальше:
$ php bin/console messenger:failed:show$ php bin/console messenger:failed:retryТот же долгоживущий механизм запускает планировщик Symfony Scheduler: опишите повторяющиеся сообщения через #[AsPeriodicTask] или поставщика расписания и обрабатывайте транспорт scheduler_default командой messenger:consume scheduler_default. Он заменяет crontab из консольных команд одним воркером. Если вы предпочитаете обычные cron-выражения, cron-выражения простыми словами объясняет пять полей, а фоновые задачи на маленьком сервере сравнивает варианты для машины того размера, на который рассчитано это руководство.
var/ должен быть доступен для записи, и куда уходят логи#
Во время работы Symfony пишет ровно в одно дерево каталогов: var/. В нём var/cache/ хранит скомпилированный кеш, var/log/ - файлы логов, а сессии попадают в var/sessions/, если вы используете файловый обработчик. Веб-процесс должен иметь возможность создавать и записывать там файлы. Больше ничему в проекте доступ на запись не нужен, включая src/, config/ и vendor/.
Симптом недоступного для записи var/ ни с чем не спутать, если увидеть его хоть раз: ошибка 500 и в консоли или в var/log сообщение, начинающееся с Unable to create the "cache" directory или Failed to write file. Лечится это исправлением владельца, а не ослаблением прав до 777:
$ chmod -R ug+rwX varЛогирование требует решения. Конфигурация Monolog из рецепта для prod буферизует записи обработчиком fingers_crossed, который выписывает контекст всего запроса, только когда приходит запись уровня error, и отправляет результат в php://stderr в формате JSON. В контейнере это правильное значение по умолчанию: stderr идёт прямо в консоль, где его можно читать вживую, и ничего не заполняет диск. Если для долгого хранения вы переключите обработчик на поток в var/log/prod.log, настройте ротацию: обработчик Monolog rotating_file со значением max_files не даст логу расти, пока не заполнится диск тарифа. В материале логи, которые стоит хранить обсуждается, что и как долго держать.
Загрузки пользователей - отдельный вопрос. Файлы, которые загружают пользователи, должны лежать там, где их можно сохранить в backup, а не в var/, который по замыслу одноразовый. Положите их по пути вне дерева кеша или в S3-совместимое объектное хранилище и включите этот путь в план резервного копирования. Резервная копия, которую никто не восстанавливал, - всего лишь гипотеза.
За слотом прокси: trusted proxies и X-Forwarded-For#
Почти каждое продакшен-приложение на Symfony стоит за чем-то, что терминирует TLS. На RE:NODE тарифы приложений включают слот прокси: направьте A-запись на адрес, который показывает панель, и сертификат будет выпущен и продлён за вас в пределах окна в 21 день. Настоящий IP клиента приходит в заголовке X-Forwarded-For. Путь объясняет материал что делает обратный прокси, а HTTPS и Let's Encrypt простыми словами - почему выпуск не удаётся, если DNS не готов.
Для Symfony из этого следует, что по умолчанию она не верит пересланным заголовкам. Без настройки $request->getClientIp() возвращает адрес прокси, $request->isSecure() ложно, а UrlGeneratorInterface строит ссылки http:// на странице HTTPS, которые браузеры блокируют как смешанное содержимое. Ограничители частоты и защита входа от перебора тогда считают всех посетителей одним клиентом, а это хуже, чем кажется.
Скажите Symfony, каким прокси доверять и какие заголовки читать, в config/packages/framework.yaml:
framework: trusted_proxies: '%env(TRUSTED_PROXIES)%' trusted_headers: - 'x-forwarded-for' - 'x-forwarded-host' - 'x-forwarded-proto' - 'x-forwarded-port' - 'x-forwarded-prefix'Затем задайте переменную. Symfony принимает список IP-адресов и диапазонов CIDR через запятую и два особых значения: PRIVATE_SUBNETS доверяет loopback и частным диапазонам, а REMOTE_ADDR доверяет тому адресу, с которого пришло соединение.
TRUSTED_PROXIES=PRIVATE_SUBNETSГлавное здесь - компромисс. REMOTE_ADDR корректен, только если прокси - единственный путь к приложению, потому что иначе любой, кто может подключиться, подделает X-Forwarded-For и выдаст себя за кого угодно. Описание платформы не называет адрес самого прокси, поэтому надёжный способ - эмпирический: задеплойте маршрут, который печатает $request->server->get('REMOTE_ADDR'), пока вы авторизованы, прочитайте значение и укажите в TRUSTED_PROXIES этот адрес или диапазон вместо подстановки. Помните, что за доверенным прокси заголовок может содержать цепочку адресов, а Symfony берёт последний недоверенный, поэтому список должен быть точным, а не просто широким.
Задайте также DEFAULT_URI или default_uri маршрутизатора (старое написание - параметры router.request_context.host и scheme), чтобы URL, генерируемые в консольных командах и обработчиках Messenger, где нет запроса, из которого можно взять схему, использовали https:// и ваше настоящее имя хоста.
Маршрут health и за чем следить#
В Symfony нет встроенного health-эндпоинта; это легко исправить, и исправить стоит. Хороший эндпоинт отвечает быстро, не трогает ничего медленного и при желании проверяет базу отдельно:
<?phpnamespace App\Controller;use Doctrine\DBAL\Connection;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\Routing\Attribute\Route;final class HealthController{ #[Route('/healthz', name: 'health', methods: ['GET'])] public function live(): JsonResponse { return new JsonResponse(['status' => 'ok']); } #[Route('/healthz/db', name: 'health_db', methods: ['GET'])] public function db(Connection $connection): JsonResponse { $connection->executeQuery('SELECT 1'); return new JsonResponse(['status' => 'ok']); }}Не обращайтесь к базе в маршруте liveness, чтобы сбой базы не заставил монитор решить, что веб-процесс мёртв. Убедитесь, что настройка файрвола оставляет /healthz публичным (например, ^/healthz в access_control с PUBLIC_ACCESS) и что на него не влияет префикс локали. Что проверять и почему, разбирает материал корректное завершение и health-проверки.
Собственный вид панели не менее важен. График в консоли показывает память, CPU и диск относительно лимитов тарифа, и у RE:NODE есть два поведения, о которых стоит знать для сервиса на PHP. При достижении лимита памяти ядро останавливает контейнер, и он чисто перезапускается вместо подкачки, поэтому утечка или слишком тяжёлый прогрев кеша выглядят как перезапуск без единой записи в логе приложения. Кроме того, наблюдатель каждые две минуты проверяет, не уменьшился ли uptime: три перезапуска за час вызывают предупреждение на странице сервера и автоматический тикет, а шесть приводят к приостановке. Поэтому цикл падений после неудачного деплоя, например из-за пропущенной миграции, нужно оперативно останавливать и исправлять, а не наблюдать.
Подбирайте тариф по измерениям, а не по надежде. Приложение Symfony в режиме prod без режима worker обычно использует десятки мегабайт на запрос, а первый запрос с холодным кешем - в несколько раз больше; как подобрать тариф веб-приложению к дню запуска превращает это в метод, а настройки PHP, которые важны рассказывает про memory_limit и OPcache.
Деплой: git, SFTP и страница первого запуска#
Код попадает на сервер двумя способами, и оба есть в панели.
- Деплой из git. Только GitHub, через GitHub App с короткоживущими токенами, поэтому приватные репозитории работают. Два переключателя: pull при каждом старте и deploy при push, который перезапускает работающий сервер и запускает остановленный. Каждый деплой получает собственную запись. Поскольку Composer устанавливает зависимости при старте, эта комбинация даёт «push, перезапуск, новые зависимости» без сборочного сервера.
- Файловый менеджер или SFTP. Учётные данные SFTP выдаются на сервер, а не на аккаунт. Файловый менеджер распаковывает архивы на месте, что удобно для загрузки релиза в zip. Не загружайте со своей машины
vendor/,var/и любые.env.local.
Новый сервер на этой линейке до деплоя кода показывает по своему адресу небольшую рабочую страницу, и эта страница никогда не перезаписывает загруженные вами файлы, так что первое, что вы увидите по адресу, - заглушка, а не ваше приложение. Когда код на месте, последовательность деплоя, которую можно выполнять одинаково каждый раз, выглядит так:
$ composer install --no-dev --optimize-autoloader --no-interaction$ php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration$ php bin/console cache:clear$ php bin/console messenger:stop-workersПо возможности запускайте её из консоли панели и каждый раз держите шаги в одном порядке: установка, миграция, пересборка кеша, перезапуск воркеров. Сделайте backup заранее, если миграция что-то удаляет. Для деплоя, который невидим для посетителей, а не просто быстр, деплой без простоя на маленьком сервере разбирает схему с каталогами релизов, с оговоркой, что на тарифе с одним контейнером ей нужно больше диска и больше аккуратности.
Устранение неполадок#
Пустая страница или голая 500 в продакшене. Это APP_DEBUG=0 делает свою работу. Сначала прочитайте вывод консоли, так как Monolog отправляет ошибки в stderr, затем var/log/prod.log, если вы меняли обработчик. Если пусто и там и там, PHP не смог их записать: проверьте права на var/.
Появляется панель профилировщика или страница исключения показывает пути к файлам. APP_ENV или APP_DEBUG всё ещё имеют значения для разработки. Задайте APP_ENV=prod и APP_DEBUG=0 как настоящие переменные и проверьте, что их не переопределяет случайный .env.local.php.
«Unable to create the cache directory» или «Failed to write file». var/ недоступен для записи процессу, запускающему PHP, либо более ранняя команда создала var/cache под другим пользователем. Исправьте владельца, затем выполните cache:clear из консоли.
«Class DebugBundle not found» после деплоя. Dev-пакеты были удалены через --no-dev, но контейнер собрался в окружении dev. Задайте APP_ENV=prod до запуска Composer.
Всё работает, кроме ссылок: они http://, а при входе редиректы зацикливаются. Trusted proxies не настроены, поэтому Symfony не видит HTTPS. Задайте TRUSTED_PROXIES и список trusted_headers, показанный выше.
Изменение переменной окружения ничего не даёт. Чтобы настоящая переменная дошла до PHP, серверу нужен перезапуск. Если вы ещё используете composer dump-env, скомпилированный .env.local.php всё ещё хранит старое значение. Выполните dump заново или удалите его.
«An exception occurred in the driver: could not translate host name». Хост в DATABASE_URL неверен, либо в пароле остались незакодированные спецсимволы. Скопируйте хост и порт из панели и закодируйте пароль.
Сообщения лежат в очереди, и никто их не обрабатывает. Потребитель не запущен либо работает на старом коде. Запустите его и сделайте messenger:stop-workers частью каждого деплоя, чтобы долгоживущий воркер заменялся.
Сервер сам перезапускается, а в логе ничего нет. Это лимит памяти. Посмотрите на график памяти около времени перезапуска. Уменьшите --memory-limit у воркера, прогревайте кеш в деплое, а не на первом запросе, и прочитайте Linux swap и OOM killer, чтобы понять, что означает остановка ядром.
FAQ#
Нужен ли режим worker, чтобы запускать Symfony на FrankenPHP?
Нет. FrankenPHP обслуживает Symfony в обычном режиме, с одной загрузкой на запрос, точно так же, как PHP-FPM. Режим worker - это вариант повышения производительности, при котором ядро остаётся в памяти между запросами. Включайте его после того, как приложение стабильно, и проверяйте, нет ли состояния, которое перетекает из одного запроса в следующий.
Где хранить продакшен-секреты: в .env или в настоящих переменных окружения?
В настоящих переменных окружения или в хранилище секретов Symfony. .env коммитится и документирует имена. Настоящая переменная всегда побеждает любой файл, потому что Dotenv не перезаписывает существующие значения. На RE:NODE переменные окружения задаются на вкладке Startup.
Безопасен ли composer install --no-dev для приложения на Symfony?
Да, если ничто в продакшен-коде не зависит от dev-пакета и APP_ENV=prod задан до установки. Если ошибка «class not found» называет пакет, его место в require, а не в require-dev. Maker, профилировщик и PHPUnit должны оставаться в require-dev.
Почему Symfony видит IP прокси, а не посетителя?
Потому что она не доверяет пересланным заголовкам, пока ей не скажут иначе. Задайте framework.trusted_proxies как адрес или диапазон прокси, перечислите заголовки x-forwarded-* в trusted_headers, и getClientIp() вернёт адрес из X-Forwarded-For. Не доверяйте всем подряд, если ваш origin доступен напрямую.
Выполняются ли миграции Doctrine автоматически при деплое?
Сами по себе нет. Нужно запускать doctrine:migrations:migrate отдельным шагом и делать его неинтерактивным через --no-interaction. Запускайте его до того, как новый код начнёт принимать трафик, если изменение не обратно совместимо, и делайте backup перед разрушающими миграциями.
Может ли воркер Messenger работать на том же сервере, что и сайт?
Сервер - это один контейнер с одним процессом, поэтому планируйте воркер как второй сервер с тем же кодом, переменными и базой. Можно ли менять команду запуска на вашем тарифе, подтвердите на его вкладке Startup, прежде чем выбирать такую схему.


