Running Symfony here — the manifest, where migrations run, and the DATABASE_URL Doctrine wants.
A Symfony application is a PHP project here — the console's choice for
"Symfony, or any other PHP application". There is no Symfony type, and it would
change nothing: the image is the same plain PHP image, served from public/.
In vallic.yaml, type: symfony and type: php are both accepted.
The platform hands every container DATABASE_URL as an environment
variable — the single value Doctrine reads, assembled from the same
credentials as everything else.
It is one string rather than parts on purpose: Doctrine will not build a URL from a host and a database name, so a platform that emitted only the parts would leave every Symfony project writing the same three lines of glue.
Keep committing .env as Symfony expects — real environment variables win
over it — but leave DATABASE_URL out of it, or put your laptop's value in an
uncommitted .env.local. A value in the committed file is one more place the
database can come from, and on the command line, where deploy steps run, it is
not guaranteed to lose.
APP_ENV. The platform does not set it, and Symfony's own .env says
dev. Pin APP_ENV=prod in the committed .env, or set it per environment
under Variables if you want staging to behave differently from
production. Left at dev, production runs in debug mode.
The secret. VALLIC_ENTROPY is generated once per environment and never
changes, so it can be the secret without anything being committed:
# config/packages/framework.yaml
framework:
secret: '%env(VALLIC_ENTROPY)%'
version: 1
type: symfony
runtime:
php: '8.4'
services:
- postgres: '18'
- valkey: '8'
# Only for the asset build below; nothing of it runs beside the site.
- node: '24'
build:
# Composer and npm are cached for you. This is Webpack Encore's own.
cache:
- node_modules/.cache
steps:
- composer install --no-dev --optimize-autoloader
# The PHP image has no npm, so the assets build in the Node image.
- name: Assets
image: node
run: npm ci && npm run build
deploy:
steps:
- 'php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration'
- 'php bin/console cache:clear --no-warmup'
- 'php bin/console cache:warmup'
on_failure: rollback
workers:
- name: messenger
command: 'php bin/console messenger:consume async --time-limit=3600'
replicas: 2
services is checked, not obeyed, for the database. A service such as
Valkey or Node is started by the next deploy if the environment does not run it
yet, but a database cannot be added or changed from a commit — moving means
migrating everything in the old one. The database named here has to be the one
the environment was created with, or the deploy refuses. Each name needs a
version after it, and that version is what the environment runs from the next
deploy — see Service upgrade before you
change one that keeps data.
The cache steps only work once the cache has somewhere to go — see Var and cache directories below.
doctrine:migrations:migrate in deploy.steps, on the machine and after the
release is live. --no-interaction because nothing is there to answer the
prompt, and --allow-no-migration so a deploy with nothing to migrate is a
success rather than a non-zero exit that rolls the release back.
Nothing runs here unless you list it, and on more than one web server the steps run once, on one of them.
A PHP project has no scheduler the platform knows how to name, so nothing is scheduled for you. If you use Symfony Scheduler, run its transport as a worker; for anything else, declare it:
cron:
- name: cleanup
schedule: '0 3 * * *'
command: 'php bin/console app:cleanup'
Cron runs on one machine only, however many web servers the environment has.
For the same reason, restoring a backup clears no cache for you, as it does for
Drupal, Laravel and WordPress. Run cache:pool:clear yourself if your pools
hold anything the restore replaced.
messenger:consume as a worker, with --time-limit set. A consumer that
runs forever slowly leaks the memory of everything it has handled; giving it an
hour and letting the platform restart it is the usual answer, and restarts are
free here because the worker is supervised.
--time-limit=3600 means it exits cleanly every hour and is started again. It
is not a failure and does not appear as one.
The release is mounted read-only while the site runs, so var/ cannot be
written — not by a request, and not by cache:clear in a deploy step. The one
writable directory a PHP project is given is VALLIC_PRIVATE_DIR (private/
at the root of the release), which is never served and outlives every release.
Move the cache and the logs there in src/Kernel.php:
public function getCacheDir(): string
{
return $this->writableDir('cache');
}
public function getLogDir(): string
{
return $this->writableDir('log');
}
private function writableDir(string $name): string
{
$base = getenv('VALLIC_PRIVATE_DIR') ?: $this->getProjectDir() . '/private';
$path = sprintf('%s/symfony/%s/%s', $base, $this->environment, $name);
if (!is_dir($path)) {
@mkdir($path, 0775, true);
}
return $path;
}
Logs you want collected belong in VALLIC_LOG_DIR rather than there: point
Monolog's file handler at %env(VALLIC_LOG_DIR)%/symfony.log and its lines
reach the console's logs with everything else — see Logs.
Uploads have no directory of their own on a PHP project: there is no
VALLIC_PUBLIC_DIR, because there is no framework convention for where one
would be. Declare the directory you serve them from as a mount, and it is kept
outside the release and linked back into every one:
mounts:
- public/uploads
Symfony's default keeps sessions in files on the machine that answered the
request. With one web server that is fine; with more than one, a visitor is
logged out whenever the next request lands on another machine. Keep them in
Valkey, which answers on REDIS_HOST and REDIS_PORT:
# config/packages/framework.yaml
framework:
session:
handler_id: 'redis://%env(REDIS_HOST)%:%env(REDIS_PORT)%'