Deploying Laravel to Production: A Practical Checklist

Deploying Laravel to Production: A Practical Checklist


A Laravel application that works perfectly on your laptop can still fall over on its first day in production. The error page shows a stack trace to visitors, uploaded images return 404, emails never send because the queue isn’t running, and the nightly report doesn’t run because nobody set up the scheduler. None of these are code bugs. They’re deployment gaps, and they’re entirely avoidable.

This is the checklist I use when taking a Laravel project live, whether it’s going onto a VPS, a managed platform or a server set up through a tool like Laravel Forge. I’ll go through the deployment pipeline step by step, the environment settings that matter, the background processes most people forget, how to deploy without downtime, and what to monitor once you’re live.

What a production Laravel server runs

Locally, php artisan serve hides a lot. In production, several separate pieces work together, and each one needs to be configured. It helps to see them all at once:

Production Laravel server. Visitors reach the web server, Nginx or Apache, which serves the public folder and passes PHP requests to PHP-FPM running the Laravel application. Supervisor keeps queue workers running, cron runs the scheduler every minute, and the application uses MySQL, an optional cache such as Redis, and the storage folder for logs and uploads.VisitorsNginx / ApacheHTTPS, serves public/static files directlyPHP-FPM + your Laravel appHandles every web request (OPcache enabled)RoutesControllersViews.envcached configand routesstorage/logs, uploads(writable)Supervisorkeeps queue:work running,restarts it if it stopsCronruns schedule:runevery minuteMySQL+ optional Redis forcache, sessions, queuesForgetting Supervisor or cron is the most common Laravel deployment gap.The site loads fine, but queued emails and scheduled tasks silently never run.
Figure 1: The moving parts of a production Laravel setup. Web requests are only one of them.

The most important setting on the web server is the document root: it must point to Laravel’s public/ folder, never to the project root. Otherwise files like .env could be downloadable from the browser.

# Nginx: the essentials of a Laravel server block
server {
    server_name app.example.com;
    root /var/www/app/current/public;   # public/, not the project root
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }

    location ~ /\.(?!well-known).* {
        deny all;                      # never serve dotfiles such as .env
    }
}

Environment: the .env file

The .env file holds everything that differs between your laptop and the server. It must never be committed to Git; create it on the server and keep it outside your deploy folders. These are the values I always double-check before launch:

APP_NAME="Acme Portal"
APP_ENV=production
APP_KEY=base64:...            # generated once with: php artisan key:generate
APP_DEBUG=false               # never true in production
APP_URL=https://app.example.com

LOG_CHANNEL=daily             # one log file per day, old files cleaned up
LOG_LEVEL=warning

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_DATABASE=acme
DB_USERNAME=acme_app          # a dedicated user, not root
DB_PASSWORD=...

QUEUE_CONNECTION=database     # or redis; not "sync" if you rely on queues
CACHE_STORE=database          # or redis
SESSION_DRIVER=database
SESSION_SECURE_COOKIE=true

MAIL_MAILER=smtp              # real mail settings, tested before launch

APP_DEBUG=false is not optional

With debug mode on, an error page can reveal file paths, queries and environment details to anyone who triggers it. In production, errors should go to your logs, and visitors should see a plain error page.

One gotcha catches many developers: once you run php artisan config:cache, Laravel stops reading .env on each request. Any env('SOMETHING') call outside the files in config/ will then return null. Read environment values only inside config files, and use config('services.stripe.key') everywhere else.

The deployment pipeline, step by step

Every deployment should run the same steps in the same order. Doing them by hand invites mistakes, so I script them, either as a deploy script on the server or in whichever deployment tool the project uses.

Deployment pipeline in six steps: push code to Git, install dependencies with Composer and build assets, run database migrations, cache configuration routes and views, reload PHP-FPM and restart queue workers, then check the health endpoint.1Pushgit push tomain branch2Buildcomposer installnpm run build3Migratemigrate --force(after a backup)4Cachephp artisanoptimize5ReloadPHP-FPM reloadqueue:restartVerifycheck /up andkey pagesIf any step fails, the script stops. Never continue a deploy after a failed build or migration.
Figure 2: The six steps of a Laravel deployment. Scripting them keeps every release identical.

Here’s a simple deploy script that follows those steps. It’s a good starting point for a single server; set -e makes it stop at the first failing command:

#!/usr/bin/env bash
set -e

cd /var/www/app

php artisan down --retry=60          # maintenance page while we update

git pull origin main

composer install --no-dev --optimize-autoloader --no-interaction
npm ci
npm run build                        # Vite production assets

php artisan migrate --force          # --force is required in production

php artisan optimize                 # caches config, routes, views and events
php artisan storage:link || true     # public/storage symlink (first deploy)

php artisan queue:restart            # workers finish their job, then reload new code
sudo systemctl reload php8.3-fpm     # clears OPcache so the new code is used

php artisan up
curl -fsS https://app.example.com/up > /dev/null && echo "Deploy OK"

A few notes on these commands:

  • --no-dev skips development packages such as test tools and debug bars, which have no place on a live server.
  • php artisan optimize caches configuration, routes and views (and events) in one go. Run php artisan optimize:clear if you ever need to undo it.
  • Migrations should be safe to run on live data. Take a database backup before any deploy that changes the schema, and avoid destructive changes (dropping columns) in the same release that stops using them.
  • /up is the health-check route that new Laravel applications register in bootstrap/app.php. It returns 200 when the app boots correctly.
  • Permissions: the web server user must be able to write to storage/ and bootstrap/cache/, and nothing else needs to be writable.

Queues and the scheduler

If your app sends emails, processes uploads or talks to external APIs in the background, it relies on queue workers. A worker is a long-running PHP process, and long-running processes need a supervisor to start them on boot and restart them if they crash. Supervisor is the standard choice on Linux servers:

; /etc/supervisor/conf.d/acme-worker.conf
[program:acme-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/current/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/app/shared/storage/logs/worker.log
stopwaitsecs=3600

Workers keep your code in memory, which is why the deploy script runs php artisan queue:restart: it tells each worker to finish its current job and exit, and Supervisor starts a fresh one with the new code.

Scheduled tasks (daily reports, cleanups, reminders) need one cron entry that runs Laravel’s scheduler every minute. Laravel then decides which tasks are due:

# crontab -e (as the web server user)
* * * * * cd /var/www/app/current && php artisan schedule:run >> /dev/null 2>&1

Deploying without downtime

The simple script above puts the site into maintenance mode for the length of the deploy, usually under a minute. For many projects that’s fine. When it isn’t, the standard technique is release folders and a symlink:

Zero-downtime deployment layout. A releases folder holds timestamped release folders. The newest release is fully built in the background while the current symlink still points to the previous release. When the build is ready, the current symlink is switched to the new release in one step. A shared folder holds the env file and storage, linked into every release. Rolling back means pointing the symlink at the previous release./var/www/app/releases/20261019-0930older release20261026-1015live now20261102-1100being builtcurrent →symlink the web server usesswitch when ready(one instant step)shared/.env and storage/, linkedVisitors keep using the live release while the new one installs, builds and caches.Rollback = point current back at the previous release.
Figure 3: Release folders and a symlink switch: the standard way to deploy Laravel without downtime.

Each deploy builds a complete new release in its own folder while the old one keeps serving traffic. The .env file and storage/ live in a shared/ folder and are linked into every release, so uploads and logs aren’t lost between releases. Once the new release is built and cached, the current symlink switches over in a single step. If something goes wrong, rolling back is just pointing the symlink at the previous folder.

You can script this yourself, but you don’t have to: PHP deployment tools such as Deployer, and managed services such as Laravel Forge’s zero-downtime deployments and Envoyer, implement this pattern for you. Pick whichever fits the project’s budget and hosting.

Backups, logs and monitoring

A deployment isn’t finished when the site loads. You also need to know when something breaks, and be able to recover when it does:

  • Database backups on a schedule, stored off the server and tested by actually restoring one. The popular spatie/laravel-backup package can back up the database and files and run from Laravel’s scheduler.
  • Logs with the daily channel so they rotate instead of growing forever. Check them after every deploy.
  • Error alerts sent to you (by email, Slack or an error-tracking service) so you hear about exceptions before customers report them.
  • Uptime monitoring on the /up route and on one or two key pages.
  • Failed jobs: check php artisan queue:failed regularly, or alert on it, so a broken integration doesn’t fail silently.
  • Database performance: slow pages are often slow queries. My guide to MySQL indexes and query optimization covers how to find and fix them.

Common deployment mistakes (and their fixes)

Most production problems I’m asked to fix in Laravel projects come down to the same handful of causes. If something behaves differently on the server than on your machine, check these first:

“I changed .env but nothing happened”

The configuration is cached. After editing .env on the server, run php artisan config:cache again (or your full deploy). The same applies to routes: a new route won’t appear until the route cache is rebuilt.

Uploaded images return 404

The public/storage symlink is missing, or it points at the wrong release folder. Run php artisan storage:link, and in a release-folder setup make sure storage/ is linked from shared/ so uploads survive every deploy.

“Permission denied” in storage/logs

Files were created by a different user than the one PHP runs as, often because a command was run with sudo or as root. Run Artisan commands as the web server user, and give that user ownership of storage/ and bootstrap/cache/. Avoid the tempting chmod 777: it makes every file writable by everyone.

Old code keeps running after a deploy

Two usual suspects: queue workers that were never restarted (they still hold the previous code in memory), and OPcache serving cached bytecode. php artisan queue:restart and a PHP-FPM reload in the deploy script solve both.

A migration locks a big table

Adding an index or column to a table with millions of rows can take a while and slow the site during the deploy. Test heavy migrations on a copy of production data first, schedule them for quiet hours, and split risky changes across releases: add the new column in one deploy, start using it in the next, and remove the old one later.

Assets look broken or outdated

The Vite build didn’t run on the server, or the public/build folder is excluded from the deploy. Either build assets on the server (as in the script above) or build them in CI and ship the compiled files; just be consistent.

The complete checklist

AreaCheck before launch
ServerSupported PHP version and extensions; document root is public/; HTTPS enabled; dotfiles blocked
EnvironmentAPP_ENV=production, APP_DEBUG=false, APP_KEY set, correct APP_URL; .env not in Git
DatabaseDedicated user with limited rights; backup taken; migrations run with --force
Dependenciescomposer install --no-dev --optimize-autoloader; assets built with npm run build
Performancephp artisan optimize; OPcache enabled; no env() calls outside config/
Filesstorage/ and bootstrap/cache/ writable; php artisan storage:link run
Background workQueue workers under Supervisor; scheduler cron entry; queue:restart in the deploy
EmailReal mail driver configured and a test email received
MonitoringDaily logs; error alerts; uptime check on /up; failed jobs watched
RecoveryAutomated off-site backups, and a tested restore and rollback plan
A Laravel production launch checklist. Go through it on every new server.

Key takeaways

  • Production Laravel is more than web requests: queue workers, the scheduler, storage and caches all need setting up.
  • Point the web server at public/, set APP_DEBUG=false, and keep .env out of Git.
  • Script every deploy: build, migrate, optimize, reload, verify, and stop on the first error.
  • Run workers under Supervisor and restart them on every deploy; add the scheduler cron entry.
  • Use release folders and a symlink switch when you need zero downtime and instant rollbacks.

Deployment is where a lot of good Laravel work gets undone, and where a bit of discipline pays off for the whole life of the application. If you’re launching a Laravel project, or your current deploys feel risky, I can help set up a reliable process. Get in touch, and take a look at Laravel vs WordPress if you’re still choosing a platform.

Code examples in this post are free to use under the MIT licence. Article text © 2026 Farooq Bin Munir.