Deploy a Laravel application
A Laravel deployment does more work on your behalf than most. Composer and npm both run, the framework caches get warmed, migrations run, and a web server is configured and started. This guide walks through that lifecycle so you know which parts are yours to control.
Start from a fresh app
composer create-project laravel/laravel my-app
Push it to GitHub and connect it as in the quickstart. A stock Laravel app needs no changes to build, though it will not boot until it has an APP_KEY, which is the first variable below.
During the build
The build recognizes the project from composer.json and runs, in order:
composer install --optimize-autoloader, with the Composer cache reused between builds.- Your frontend build, if there is a
package.json. Laravel's default Vite setup is picked up without configuration. mkdirfor thestorage/andbootstrap/cachedirectories, so a clean checkout has somewhere to write.php artisan config:cache,event:cache,route:cacheandview:cache.
Anything requiring PHP extensions beyond the defaults belongs in composer.json, where the builder reads them.
When the container starts
The image starts a script rather than a bare server, and that script does three things before serving traffic:
php artisan migrate --force
php artisan storage:link
php artisan optimize:clear && php artisan optimize
Migrations run on every start, not just on deploy. Repeat runs are harmless, but a failing migration keeps the container from serving. Set RAILPACK_SKIP_MIGRATIONS to true as a variable if you would rather run them yourself.
The caches are rebuilt at boot, which is the detail that saves you. Config was cached during the build, when your runtime variables were not necessarily final. Because the start script clears and re-optimizes, the values that end up in the running app are the ones set on the service.
Then FrankenPHP serves the application on the port Lucity provides. There is no nginx config, no php-fpm pool and no Procfile to write.
The variables it needs
| Variable | Value |
|---|---|
APP_KEY | base64:... from php artisan key:generate --show. Set it once and keep it. |
APP_URL | Your generated or custom domain. |
APP_ENV / APP_DEBUG | production and false. |
DB_CONNECTION | pgsql |
DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD | Linked to the matching values on your database. |
APP_KEY deserves two warnings. It encrypts sessions and anything you have encrypted at rest, so generating a fresh one on each deploy logs everyone out and makes old ciphertext unreadable. Generate it once, store it as a variable, leave it alone.
The second warning is about how its absence looks. On this stack a missing APP_KEY does not produce Laravel's familiar key screen. The request returns a 500 and the log shows Cannot modify header information: headers already sent from Symfony's response handling, because the exception page fails while rendering itself. If you see that error on a fresh deployment, check the variable before you go looking for a header bug.
The database values map one to one onto the values a Lucity database exposes: host, port, dbname, user, password. Link each rather than pasting, and credential rotation stops being an incident.
Files go in a bucket, not on disk
storage:link runs at boot, which makes public/storage work, but that disk is part of the container and disappears with it. Uploads that need to survive a deploy belong in a bucket:
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID -> accessKeyId
AWS_SECRET_ACCESS_KEY -> secretAccessKey
AWS_BUCKET -> bucket
AWS_ENDPOINT -> endpoint
AWS_DEFAULT_REGION -> region
AWS_USE_PATH_STYLE_ENDPOINT=true
Install league/flysystem-aws-s3-v3 and Storage::disk('s3') behaves as usual.
Queues get their own service
A queue worker is a long-running process with a different command, so it is a second service in the same project, pointed at the same repository, with its start command set to:
php artisan queue:work --tries=3 --max-time=3600
Give it the same variables as the web service so it reaches the same database and the same key-value store, and leave its port unset: it serves no traffic and needs no domain. Scale it by raising replicas on that service alone.
The scheduler works the same way, with php artisan schedule:work as its command.
Next steps
- Key-value store for queues, cache and sessions
- Object storage for uploads
- Deployments for rollout behavior and rollbacks