Running Laravel here — the manifest, where migrations run, and the few lines Laravel needs to read the environment.
Laravel needs very little copied into it. The platform hands every container
its settings as environment variables, under the names Laravel already
reads — DB_CONNECTION, DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME,
DB_PASSWORD, and REDIS_HOST and REDIS_PORT when the stack runs Valkey or
Redis.
Do not commit a .env. Commit .env.example as usual; a committed .env
applies to every environment the code is deployed to, which is exactly what
the platform's per-environment variables exist to avoid.
Two things the platform does not give you, and Laravel will not start without the first:
APP_KEY. Set it on the environment as a secret variable under
Variables, or derive it in config/app.php from
VALLIC_ENTROPY, which is generated once per environment and never changes:
'key' => env('APP_KEY') ?: (getenv('VALLIC_ENTROPY')
? 'base64:' . base64_encode(substr(hash('sha256', getenv('VALLIC_ENTROPY'), true), 0, 32))
: null),
APP_URL. The environment's own address is PROJECT_BASE_URL, so
'url' => env('APP_URL', env('PROJECT_BASE_URL', 'http://localhost')) in
config/app.php saves setting it twice.
bootstrap/app.php needs a few lines before Application::configure(), and
both are consequences of how the platform runs your code rather than
preferences.
// The platform's variables, where env() can see them. Laravel reads $_ENV and
// $_SERVER, and PHP's command line leaves both empty of the environment — so
// without this a deploy step or a cron job sees no DB_HOST at all, and
// `artisan migrate` quietly falls back to Laravel's defaults.
foreach (getenv() as $name => $value) {
$_ENV[$name] ??= $value;
$_SERVER[$name] ??= $value;
}
// Compiled config and routes somewhere writable. The release is mounted
// read-only, so bootstrap/cache cannot be written and `config:cache` fails.
// Per release, so a rollback does not run the configuration of the release
// it rolled back from.
$compiled = (getenv('VALLIC_PRIVATE_DIR') ?: dirname(__DIR__) . '/private')
. '/bootstrap/' . basename(dirname(__DIR__));
if (!is_dir($compiled)) {
@mkdir($compiled, 0775, true);
}
foreach ([
'APP_CONFIG_CACHE' => 'config.php',
'APP_ROUTES_CACHE' => 'routes.php',
'APP_EVENTS_CACHE' => 'events.php',
'APP_SERVICES_CACHE' => 'services.php',
] as $variable => $file) {
$_ENV[$variable] = $_SERVER[$variable] = $compiled . '/' . $file;
putenv($variable . '=' . $compiled . '/' . $file);
}
The packages manifest stays in bootstrap/cache: composer install writes it
during the build, while the release is still writable, and nothing rewrites it
afterwards.
version: 1
type: laravel
runtime:
php: '8.4'
services:
- mariadb: '11.8'
- 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 Vite's own, which is not.
cache:
- node_modules/.vite
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:
# storage/framework is kept outside the release and starts empty, so the
# directories your repository ships there are not the ones Laravel sees.
- 'mkdir -p storage/framework/cache/data storage/framework/sessions storage/framework/views'
- 'php artisan migrate --force'
- 'php artisan config:cache'
- 'php artisan route:cache'
- 'php artisan view:cache'
on_failure: rollback
health:
path: /up
workers:
- name: queue
command: 'php artisan queue:work --sleep=1 --tries=3'
replicas: 2
cron:
- name: scheduler
schedule: '* * * * *'
command: 'php artisan schedule:run'
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.
With no cron declared, the platform runs schedule:run every five minutes.
Laravel's scheduler only runs what is due at the minute it is called, so on
that default an everyMinute() task runs every five minutes and a task at a
minute that is not a multiple of five — dailyAt('13:32') — never runs.
Declare the scheduler every minute, as above, if you need that; declaring any
cron replaces the platform's job.
If you run the scheduler as a process instead — php artisan schedule:work
as a worker — the platform adds no cron for it, so tasks do not run twice.
It is not the queue: schedule:run decides when work happens, and
queue:work, below, processes what it and your application put on the
queue.
The schedule runs on one machine only, however many web servers the environment has, so a task is never started twice.
php artisan migrate --force in deploy.steps. --force because the machine
is non-interactive and Laravel refuses to migrate in production without it.
It runs on the machine, after the release is live — a build has no
database, and the artifact it produces could be deployed to staging or
production. The caching commands go after the migration and in that order:
config:cache first, because the others read configuration. Nothing runs
here unless you list it, and on more than one web server the steps run once,
on one of them.
/up is the health route Laravel ships; with health set, a deploy waits for
it to answer before it counts as done, and on_failure: rollback puts the
previous release back if it never does.
Never cache config locally and commit the result. A cached config file freezes whatever the environment said at the moment it was built, which on this platform is the wrong environment's.
When a backup is restored, the platform runs php artisan optimize:clear
afterwards so nothing cached from the replaced database is served. That clears
the config and route caches too; the next deploy builds them again.
queue:work, not queue:listen, and as a worker rather than as cron. A
worker is a process the platform keeps running and restarts if it stops; cron
would start a new one every minute on top of the last.
Workers are restarted on deploy, which is what queue:restart exists to do
elsewhere — you do not need it here.
Point CACHE_STORE and SESSION_DRIVER at redis; the stack's Valkey answers
on REDIS_HOST and REDIS_PORT, which are the names config/database.php
already reads. Without Valkey, SESSION_DRIVER=database works as well. Set
these on the environment under Variables.
Once an environment has more than one web server this stops being a
preference. Laravel's default, file, keeps sessions in storage/framework,
which the platform keeps outside the release and shares between machines over
the network — so on every machine but one, each request reads and writes its
session over the network.
Valkey is what sessions shared between servers are for.
Four paths inside your code are linked out of the release, so they survive every deploy:
| In your code | Kept as |
|---|---|
public/storage |
VALLIC_PUBLIC_DIR — served |
storage/app |
VALLIC_PRIVATE_DIR — never served |
storage/logs |
a directory under the private one |
storage/framework |
a directory under the private one |
Everything else in the release is read-only while the site runs.
public/storage is already linked, so there is no storage:link to run —
but Laravel's public disk writes to storage/app/public by default, which is
now inside the private directory and not served. Point it at the public one:
// config/filesystems.php
'public' => [
'driver' => 'local',
'root' => env('VALLIC_PUBLIC_DIR', storage_path('app/public')),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
],
Laravel's daily channel writes to storage/logs. That directory survives
deploys, but it is the application's own: nothing collects it, so its lines
never reach the console's logs or a destination you forward to.
Point it at VALLIC_LOG_DIR instead. The variable is already set in every
container, and the directory behind it outlives every release:
// config/logging.php
'daily' => [
'driver' => 'daily',
'path' => env('VALLIC_LOG_DIR', '/var/log/app') . '/laravel.log',
'level' => env('LOG_LEVEL', 'debug'),
// Rotate, and the platform's limit never comes up.
'days' => 7,
],
The fallback in env() is there so the same config works on your laptop,
where the variable does not exist.
Then set LOG_CHANNEL=daily on the environment — that one is yours to set,
under Variables, because the platform does not write it.
Anything in that directory is collected with the rest of your logs, kept on the machine for a week, and forwarded if you have set up a destination. There is a size limit and rotation keeps you well under it — see Logs.