SuperAPI AI Directory

symfony-yoandev-deployment

Deployment & operations

View repository

Updated 10-16 07:07

About this skill

Deployment

Tier: on demand, then not optional — nothing here applies before there is a production target. Once there is one, the sequence and its order are not negotiable.

One artefact, one sequence, one way to undo it.

The target is a Docker image running FrankenPHP, deployed by replacing containers. Everything below assumes that model, because it is what makes the rest simple: the image is immutable, so "what is running" is a question with an exact answer, and rollback is redeploying a tag.

For running the app locally — including the production-like FrankenPHP check you should do before shipping — see symfony-yoandev-local-dev. For the CI pipeline that must pass first, see symfony-yoandev-quality. This skill starts once the image is about to be built, and stops at where logs and metrics go: what the application emits — channels, log levels, redaction, alerting, health checks — is symfony-yoandev-observability's. This skill owns the prod handler configuration; that one owns everything written into it.

Do not write the Dockerfile

Use dunglas/symfony-docker. It is maintained by the FrankenPHP author and tracks Symfony's recipes. A hand-rolled Dockerfile reaches production faster and then quietly lacks:

What it gives you Why a hand-rolled file usually misses it
Multi-stage build The final image is debian:13-slim plus the binaries and /app — no Composer, no git, no build toolchain to attack
Non-root user Runs as www-data, setuid/setgid bits stripped, var/ owned group 0 with g=u so it also works on arbitrary-UID platforms like OpenShift
Healthcheck Probes Caddy's metrics endpoint on :2019, so the orchestrator knows the difference between "container started" and "app answers"
Worker mode The Caddyfile declares worker { file ./public/index.php } — the kernel is booted once and reused, which is most of FrankenPHP's speed
TLS Automatic Let's Encrypt certificates from SERVER_NAME, HTTP/2 and HTTP/3. AssetMapper needs HTTP/2 to not be slower than a bundle
###> recipes ### markers Flex writes into the Dockerfile itself, so installing a bundle that needs a PHP extension updates the image on its own

Copying that repository into a skill would guarantee it rots. Point at it, and read its Dockerfile when you need to know what actually runs — it is the source of truth over this page.

The sequence

Every step has a job. The list is worth little; what breaks when you skip it is what stops you skipping it.

Step Its job Skipped →
composer install --no-dev --optimize-autoloader Production dependencies, class map instead of filesystem probing Dev bundles in prod (profiler, maker), and every class load hits the disk
composer dump-env prod Compiles the .env files into .env.local.php .env is parsed on every request, and Dotenv needs the files to still be there
cache:clear or cache:warmup Builds the container, routes, Twig, validator metadata, and the .preload.php file First request of every worker pays the full build; opcache.preload silently does nothing
asset-map:compile Writes hashed asset files under public/assets/ Every asset is generated by PHP at runtime, on every request. This is why people think AssetMapper is slow
doctrine:migrations:migrate --no-interaction Brings the schema to the code's expectations The new code queries columns that do not exist
messenger:stop-workers Asks running workers to stop after their current message Workers keep executing the old code against the new schema — the worst failure in this list, because nothing looks broken
restart workers Brings consumers back on the new code The queue fills up silently

Two of these are commonly written wrong:

  • cache:clear already warms up unless you pass --no-warmup, and it runs the optional warmers that produce the preload file. Running cache:clear and cache:warmup warms the cache twice. In an image built from a clean checkout there is nothing to clear — cache:warmup alone is the honest call. (composer run-script post-install-cmd runs cache:clear through Flex's auto-scripts, so most builds have already done it.)
  • messenger:stop-workers does not signal a process. It writes a timestamp into the cache.app pool; workers read it between messages and exit. Two consequences: the deploy job must write to the same pool the workers read, and on Symfony < 7.4 (where that pool lives under var/cache/) cache:clear after it erases the signal. Details and the fix: references/deploy-sequence.md.

Where each step runs

With an image, the sequence splits in two and the split is the whole point.

At build time, baked into the image: composer install, dump-env, cache warmup, asset-map:compile. They are deterministic, they need no database, and doing them here means the container starts in milliseconds and every replica is byte-identical.

At release time, once per deploy, not once per container: migrations, stopping workers, restarting them. symfony-docker's entrypoint runs the migration for you when migrations/ is non-empty — convenient for one container, a race when you scale. references/deploy-sequence.md covers running it as a separate job.

Migrations run automatically, and that is a choice

Deliberate, with a stated price: migrations are not written to be backward-compatible with the code currently running. A short interruption is acceptable if a migration is incompatible with the version being replaced.

That buys a migration you can read as one change — rename the column, done — instead of a three-deploy dance for every schema edit, and no half-applied intermediate state living in production for a week.

What it costs, stated plainly so you can deviate on purpose: there is a window, usually seconds, where old containers run against the new schema. If your requirement is zero downtime, this rule does not apply to you and the pattern you need is expand/contract: add the new column nullable → deploy code that writes both → backfill → make it non-null → deploy code that reads only the new one → drop the old. Five deploys instead of one. Write it in references/deploy-sequence.md's terms, and know you are opting into a slower cadence for a real guarantee.

Writing the migration itself — reading and correcting what make:migration generated — belongs to symfony-yoandev-doctrine. This skill only says when it runs.

Production settings

APP_ENV=prod and APP_DEBUG=0, then the php.ini that makes the difference between "it runs" and "it is fast":

opcache.preload = /app/config/preload.php
opcache.preload_user = www-data
opcache.validate_timestamps = 0
realpath_cache_size = 4096K
realpath_cache_ttl = 600
opcache.memory_consumption = 256
opcache.max_accelerated_files = 32531
opcache.interned_strings_buffer = 16

opcache.validate_timestamps = 0 means PHP never re-reads a file it has already compiled. New code is invisible until the OPcache is dropped. Replacing the container does that for free — which is precisely why the container model is worth its constraints. Deploy any other way (rsync, a releases/ symlink) and you owe a restart or an opcache_reset() after every deploy, or you will ship code that never runs.

opcache.preload needs config/preload.php, which the Symfony recipe already ships and which does nothing unless the prod cache has been warmed — it requires var/cache/prod/App_KernelProdContainer.preload.php. That is the second reason warmup belongs in the build.

Also worth setting, both in config/: .container.dumper.inline_factories (fewer files to load per request) and framework.enabled_locales (stop compiling translation catalogues for languages you do not serve). Both, with the exact syntax and the version caveats, in references/production-config.md.

Environment variables and secrets

composer dump-env prod compiles the .env files into .env.local.php. Real environment variables still win over it — that is how you inject a DSN per environment without rebuilding.

Secrets come from the platform's secret store, injected as environment variables. Every orchestrator has one, it is where the DSN already lives, and it is the one place an operator looks. The Symfony secrets vault is not layered on top of it here, for one reason only: its decryption key would itself be a platform secret, so the vault would be a second store fed by the first. It is the right tool, not a rejected one, the moment the platform store is missing — a bare server, an rsync deploy — or the moment secrets must be versioned and reviewed in the repository; then adopt it wholesale (secrets:set, config/secrets/prod/, the key injected as SYMFONY_DECRYPTION_SECRET) and drop the platform variables for those values. Two mechanisms on one project is how a secret ends up in neither. What is never acceptable: a .env.local on a production host, which is a plaintext file nobody rotates.

.env.local is not committed, so it is not in the image either. On a container platform, inject through the platform. references/production-config.md has the precedence rules and the trap where dump-env is silently bypassed.

Verify the deploy

A deploy nobody checks is a deploy whose failure is reported by users, hours later, without a stack trace. Four commands, ten seconds:

php bin/console about                          # env prod, debug off, right version
php bin/console doctrine:migrations:up-to-date # schema matches the code
php bin/console lint:container                 # every service can be built
php bin/console messenger:stats                # queues draining, not growing

Then one real request: check the status code, that an asset comes from /assets/…-<hash>.css and not through PHP, and that the error log is quiet. The full checklist, including what a healthy messenger:stats looks like: references/deploy-sequence.md.

Rollback

Rollback works when two things are true, and they are decided before the incident:

  • Image tags are immutable. Deploy app:a1b2c3d, never app:latest. You cannot roll back to a tag that has been overwritten.
  • The release's migrations did not destroy data. A migration that dropped a column can be rolled back in code and not in reality.

The second point is not a backward-compatibility rule, and it does not reopen the one this standard rejects. Migrations still run at deploy, still rename a column in one statement, and old containers still meet the new schema for a few seconds. What it says is narrower: a release that carries a destructive migration has no rollback, and that is stated before deploying, not discovered during the incident. Most releases carry none — an added column, a new table, a new index — and roll back for free.

When a particular release must stay reversible and drops something, split that one change: stop writing to the column in this release, drop it in a later one. That is a choice made per release, for a checkout or a payment table, and the price of it is one extra deploy for that change — not expand/contract as a standing policy for every schema edit.

What makes rollback impossible: a mutable tag, a down() method nobody has ever run, and building the image on the production host so there is no previous artefact to return to.

When production misbehaves

Symptom Cause
Blank page, 500, nothing in the log APP_DEBUG=0 hides the error but the log handler is misconfigured — check logs go to stderr. If the handler is right and the log is still silent, that is fingers_crossed doing its job, and the question belongs to symfony-yoandev-observability
CSS and JS 404 or are slow asset-map:compile did not run in the build
New code deployed, old behaviour opcache.validate_timestamps=0 and the process was not replaced
Workers process messages with old code messenger:stop-workers skipped, or the signal was written to a pool the workers do not read
Restart signal ignored cache:clear ran after messenger:stop-workers and deleted it (Symfony < 7.4)
Unable to write in the cache directory var/ not writable, or the image runs as a user that does not own it
Works on one replica, fails on another State written to the local filesystem — sessions, uploads, the cache.app pool
Migration hangs the deploy Several replicas ran doctrine:migrations:migrate at once and are blocked on the lock
An env var is set on the server but ignored It is set for the shell, not for the process the supervisor starts

Reference files

File When to read it
references/deploy-sequence.md Writing or fixing a deploy script; migrations at release; workers, stop signal, supervisor and systemd; the verification checklist; rollback
references/production-config.md php.ini and OPcache, preloading, container and locale settings, environment variables and secrets, filesystem and logs, FrankenPHP worker mode
assets/README.md Before copying the worker unit files — both need project-specific values