Skip to main content

Symfony

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.

#What you set yourself

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)%'

#The manifest

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.

#Where migrations run

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.

#Cron

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

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.

#Var and cache directories

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

#Sessions

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)%'

Next

  • Variables — everything available to read
  • Storage — what survives a deploy, and what does not
  • Logs — where your output goes and how to send a copy on