Deployment and production hardening

Environment configuration, config caching done safely, Supervisor-managed queues, zero-downtime releases, health checks and log handling.

Configuration and caching

composer install --no-dev --optimize-autoloader --classmap-authoritative
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
php artisan queue:restart
php artisan horizon:terminate
php artisan storage:link
  • config:cache reads .env once, so any env() call inside application code returns null afterwards. Only config/*.php files may call env().
  • APP_DEBUG=false and APP_ENV=production in production, always - debug mode exposes the environment on any exception page.
  • migrate --force is required non-interactively; run it before switching traffic so a failure does not leave a half-released app.
  • queue:restart signals workers to exit after the current job, letting the supervisor start them with the new code.
⚠️
Cache the config, then verify it. A single env() call in a controller is a production-only bug that works perfectly in local development, because local never caches config.

Zero-downtime releases

# atomic release with a symlink switch
releases/20260918120000/   <- new code, composer install, caches built
shared/.env  storage/  uploads/
current -> releases/20260918120000

# switch
ln -sfn releases/20260918120000 current
# then reload php-fpm so OPcache picks up the new paths
StepWhy it matters
Build caches in the new release directoryThe current symlink serves the old, working version until the switch
Keep storage/ in sharedLogs and uploads survive the switch
Reload PHP-FPM after the switchOPcache is keyed by path; a reload avoids serving mixed code
Health check before switchingCatches a broken build before users see it
Keep the last two releasesRollback is another symlink switch
// a meaningful health endpoint
Route::get('/health', function () {
    DB::select('select 1');
    Cache::store('redis')->get('health-probe');
    return response()->json(['status' => 'ok', 'release' => config('app.release')]);
});

Workers, logs and hardening

; supervisor queue worker
[program:laravel-worker]
command=php /var/www/app/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
numprocs=4
autostart=true
autorestart=true
stopwaitsecs=3600
user=www-data
  • stopwaitsecs must exceed the longest job timeout, or a deploy kills work mid-flight.
  • Log to stderr in containers and let the platform collect it; on a VM, rotate with logrotate because daily logs still fill a disk.
  • Set SESSION_SECURE_COOKIE=true, SESSION_SAME_SITE=lax, and serve only over TLS.
  • TrustProxies must be configured, or rate limiting and secure cookie detection see the load balancer's IP.
  • Add php artisan schedule:run to cron - exactly one cron entry, and use onOneServer() when several instances exist.

FAQ

Why did my configuration stop being read after caching?
Because env() is called somewhere outside the config directory. Move every environment lookup into a config/ file, reference it with config('services.x.key'), and clear the cache before re-testing.
How do I deploy without dropping queued work?
Deploy the code first, let in-flight jobs finish against the old payloads, then restart workers. Never change a job's constructor signature in the same release that you deploy, unless the old signature still deserialises.

Queues, jobs and scheduled tasks Caching, performance and debugging

Last refreshed 2026-09-18.