A Laravel deploy should run the tests, build each release in its own folder, switch over in one step, and keep the previous release ready to roll back to. This guide covers environments, zero-downtime releases, Forge and Envoy, CI pipelines and health checks.
Environments and configuration
Use .env for secrets and settings that change between environments. Never commit it to Git. Each server keeps its own .env, or you commit an encrypted copy made with php artisan env:encrypt and decrypt it during the deploy.
In config/*.php, read the environment:
'api_key' => env('API_KEY'),
Everywhere else, read config('services.example.api_key'), never env(). Once config is cached, env() returns null outside the config files.
Build the caches on the server as part of each deploy:
php artisan optimize
Zero-downtime deployment
A zero-downtime deploy builds each release in its own folder, then switches a symlink once the release is ready. Laravel Envoyer does this, and so does a custom script:
/var/www/app/current -> /var/www/app/releases/20250626_123456
The web server points at current/public. Switching the symlink is atomic, so visitors get either the old release or the new one, never half of each. Rolling back means pointing current at the previous release.
Maintenance mode is for the deploys that can’t be zero-downtime, such as a migration that rewrites a large table. Use --secret so you can still check the site:
php artisan down --secret="super-secret-code"
Then deploy, then:
php artisan up
Deploy with Laravel Forge
- Provision the server
- Connect the repository on GitHub or Bitbucket
- Configure the deploy script
- Add environment variables
- Run queue workers, and Horizon if you use Redis queues
Custom deployment with Envoy
composer require laravel/envoy --dev
Create Envoy.blade.php:
@servers(['prod' => 'user@yourserver'])
@task('deploy', ['on' => 'prod'])
cd /var/www/app
git pull origin main
composer install --no-dev --optimize-autoloader --no-interaction
php artisan migrate --force
php artisan optimize
php artisan queue:restart
@endtask
This simple version updates the code in place, so requests during the deploy can hit a half-updated app. Use the release folder approach above when that matters.
Run:
vendor/bin/envoy run deploy
CI/CD pipelines
Use GitHub Actions, GitLab CI or Bitbucket Pipelines to run the tests, and only deploy when they pass. The server builds its own caches. Anything cached on the CI runner is thrown away when the job ends.
Example .github/workflows/deploy.yml:
name: Test and deploy
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- run: composer install --no-interaction --prefer-dist
- run: cp .env.example .env && php artisan key:generate
- run: php artisan test
- name: Trigger the Forge deploy
run: curl -fsS -X POST "${{ secrets.FORGE_DEPLOY_WEBHOOK }}"
Keep the Forge deploy URL in a repository secret, because anyone with it can trigger a deploy.
Health checks and rollbacks
- Laravel 11 and later include a
/uphealth route. Point an uptime monitor such as Oh Dear or Pingdom at it - Keep previous releases ready for rollback
- Use Supervisor to restart failed queue workers
- Make migrations backwards compatible, so the previous release still works if you roll back
A real example
On a Laravel trading platform, releases were built around queue restarts and backwards-compatible migrations, because the exchange couldn’t simply be taken offline for a deploy.
The checklist
- Automate what’s repeatable
- Run the tests before every deploy
- Document every
.envvariable in.env.example - Build caches on the server with
php artisan optimize - Check the health route after each deploy
Related posts
If your releases still need a quiet hour and a maintenance page, this is the setup I’d put in place as your Laravel developer.
Published , updated .
Part 6 of Laravel Mastery.