What does a Laravel production deployment need?
A Laravel app is ready for production when nine things are true. It runs on a supported PHP and Laravel release. Its environment file says production with debug off. Dependencies are installed without dev packages. Migrations have run. Config, routes, views and events are cached. Queue workers restart on each release. The scheduler runs every minute. A health route answers. And you can roll back in minutes.
We ran each of those steps on a fresh Laravel 13 app on November 11, 2026, and we show the real output below. Where a step needs Linux, we say so and quote the official docs instead. If you want a team to own this for your app, our Laravel development service ships every release with this checklist. Building a new product from scratch? Start with our web application development page.
Which Laravel and PHP versions are supported right now?
Laravel gives each major release 18 months of bug fixes and 2 years of security fixes. On November 11, 2026, two majors were still covered:
| Laravel | PHP | Released | Bug fixes until | Security fixes until |
|---|---|---|---|---|
| 13 | 8.3 to 8.5 | March 17, 2026 | Q3 2027 | March 17, 2028 |
| 12 | 8.2 to 8.5 | February 24, 2025 | August 13, 2026 (ended) | February 24, 2027 |
| 11 | 8.2 to 8.4 | March 12, 2024 | Ended | Ended March 12, 2026 |
Laravel support policy, from the Laravel 13.x release notes
PHP has its own clock. PHP 8.2 gets security fixes until December 31, 2026. PHP 8.3 is covered until December 31, 2027, 8.4 until December 31, 2028, and 8.5 until December 31, 2029. A new app should start on Laravel 13 with PHP 8.4 or 8.5. A Laravel 12 app should plan its upgrade before February 2027.
How did we test this checklist?
We set up a clean lab folder on a Windows 10 Pro machine, outside our own projects:
- Downloaded the PHP 8.5.12 non-thread-safe x64 zip from windows.php.net: 36,310,917 bytes in 4.8 seconds. Its SHA-256 matched the published value.
- Downloaded Composer 2.11.2 as a phar (3.8 MB) and checked it against the published SHA-256.
- Enabled the extensions Laravel needs in
php.ini: curl, fileinfo, mbstring, openssl, pdo_sqlite and zip. The rest are built in. - Ran
composer create-project laravel/laravel. It installed the v13.11.0 app skeleton with framework 13.42.0 and 110 packages in 1 minute 27 seconds.
To copy a real server layout, we split the app into three folders. The releases/ folder holds each build. The shared/ folder holds the .env file, the SQLite database and storage. The current folder is a link that the web server uses. Windows has no cheap symlinks for this, so we used directory junctions (mklink /J). We added three small probe commands, a queued job and two test routes so each step had something to prove.
How should the production .env file look?
Four lines matter most. Keep the file in the shared folder, outside any release, and copy or link it into each new release.
APP_ENV=production
APP_DEBUG=false
APP_URL=http://127.0.0.1:8765
LOG_LEVEL=warning
DB_CONNECTION=sqlite
DB_DATABASE=C:/sites/app/shared/database/database.sqliteGenerate the key once, on the first deploy. With an empty key, php artisan key:generate ran in production without a prompt. Laravel only asks for confirmation when a key already exists, because replacing it logs everyone out and breaks encrypted data. So --force on this command means “rotate the key”. Never put it in a routine deploy script.
$ php artisan key:generate --no-interaction
INFO Application key set successfully.Then prove debug mode is really off. We added a route that throws an exception containing the text “probe secret detail”:
$ curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8765/boom
500
$ curl -s http://127.0.0.1:8765/boom | grep -o '<title>[^<]*</title>'
<title>Server Error</title>
$ curl -s http://127.0.0.1:8765/boom | grep -c 'probe secret detail'
0The visitor saw a plain “Server Error” page, and the exception went to storage/logs/laravel.log instead. With APP_DEBUG=true, that page would show the stack trace and can expose configuration values.
How do you install dependencies for production?
Run Composer without dev packages and with an optimized class map:
$ composer install --no-dev --optimize-autoloader --no-interaction
Installing dependencies from lock file
- Removing theseer/tokenizer (2.0.1)
- Removing staabm/side-effects-detector (1.0.5)
- Removing sebastian/version (6.0.2)
...
Generating optimized autoload files
real 0m6.520sOn our app this removed 33 of 110 packages, the test and debug tools. The vendor folder shrank from 66 MB to 41 MB. A fresh install of the 77 production packages into a new release folder took 1 minute 45 seconds on this machine. Build in a new folder, never in the one serving traffic, so a slow or failed install cannot break the live site.
How do you run migrations without a prompt?
In production, Laravel refuses to migrate without confirmation. In a script there is nobody to confirm, so the command stops:
$ php artisan migrate --no-interaction
APPLICATION IN PRODUCTION.
WARN Command cancelled.
exit=1
$ php artisan migrate --force
INFO Running migrations.
2026_11_11_000000_create_deploy_notes_table .......... 6.43ms DONEUse --force, and check the exit code so a failed migration stops the deploy. Write migrations that the previous release can live with, such as adding a column before you use it. Then a rollback rarely needs the database to move back.
How do you cache config, routes, views and events?
One command builds all four caches:
$ php artisan optimize
INFO Caching framework bootstrap, configuration, and metadata.
config ............. 31.25ms DONE
events .............. 2.45ms DONE
routes ............. 19.09ms DONE
views .............. 66.63ms DONE
$ ls bootstrap/cache
config.php events.php packages.php routes-v7.php services.phpThere is one trap. Once config is cached, Laravel stops reading .env, and env() returns null everywhere except inside config files. Our probe command printed both values before and after caching:
$ php artisan probe:env # config cached
env(APP_ENV)=NULL config(app.env)='production' config cached=yes
$ php artisan config:clear && php artisan probe:env
env(APP_ENV)='production' config(app.env)='production' config cached=noIf your code calls env() in a controller or a job, it works on your laptop and returns null in production. Move every env() call into a config file and read it with config(). Run php artisan optimize in each new release folder before you switch traffic to it.
How do you link public storage?
$ php artisan storage:link
INFO The [...\release-1\public\storage] link has been connected to [...\release-1\storage\app/public].On Windows, Laravel creates a junction; on Linux it creates a symlink. Run it in every new release, because public/storage lives inside the release folder. With a shared storage folder, uploads survive each deploy.
How do you restart queue workers on each release?
Workers are long-running processes that keep the old code in memory. After a deploy, tell them to finish their current job and exit. We started a worker, then sent the signal:
$ php artisan queue:work --sleep=1 &
$ php artisan queue:restart
INFO Broadcasting queue restart signal.
# worker log
2026-11-11 03:21:22 Worker STOPPED Received restart signalThe worker stopped within 2 seconds. Something must then start it again with the new code, which is the process manager’s job (see the Supervisor section below). The restart signal is stored in the cache, so the cache store must be shared by the web server and the workers.
Laravel 13 also has php artisan reload, which signals every long-running service in one step. On our app it handled the queue and the scheduler:
$ php artisan reload
INFO Reloading services.
queue ................ 18.82ms DONE
schedule .............. 4.60ms DONEHow do you run the scheduler?
The scheduler needs one cron entry that calls schedule:run every minute. We ran that call by hand against a task that writes a heartbeat line:
$ php artisan schedule:list
* * * * * php artisan probe:heartbeat ..... Next Due: 46 seconds from now
$ php artisan schedule:run
2026-11-11 03:21:14 Running ["artisan" probe:heartbeat] ..... 432.46ms DONE
$ tail -1 storage/logs/heartbeat.log
2026-11-11 03:21:14 scheduler release-1If you use sub-minute tasks, schedule:run keeps running for the whole minute with the old code. Add php artisan schedule:interrupt after the switch. Use withoutOverlapping() on tasks that can run long, and onOneServer() when more than one server runs the scheduler.
What does the /up health route prove, and what not?
New Laravel apps register /up in bootstrap/app.php. It returns 200 when the app boots without an exception and 500 when it does not. Point your uptime monitor and load balancer at it.
We found one gap worth knowing. During maintenance mode, the homepage returned 503 but /up kept returning 200:
$ php artisan down --retry=60
$ curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8765/up
200
$ curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8765/
503That is deliberate: the framework excludes the health route from maintenance mode, so load balancers keep the server in rotation. It also means a monitor that only checks /up will not notice a site left in maintenance mode by a failed deploy. Monitor the homepage or a key page too. To make /up check the database or cache, listen for the DiagnosingHealth event and throw an exception when a check fails.
How do you switch releases and roll back?
We built release 2 beside release 1, with one new migration. We installed dependencies, linked storage, migrated and cached it, then pointed current at it while the PHP server kept running.
switch took 107 ms
/release -> release-1 laravel 13.42.0
/release -> release-1 laravel 13.42.0
/release -> release-1 laravel 13.42.0
# restart the PHP server
/release -> release-2 laravel 13.42.0
/up -> 200The link switched in about a tenth of a second, but the running PHP process kept serving release 1 until we restarted it. It had resolved the old path when it started. PHP-FPM can hold on to resolved paths the same way, through PHP’s realpath cache. That is why Laravel’s Nginx example uses $realpath_root, and why deploy tools reload PHP-FPM after the switch. Always check which release answers after a switch; do not assume it.
The rollback ran in the reverse order:
Steps
-
Roll back the database from the new release
Run
php artisan migrate:rollback --step=1 --forcefrom release 2. Only release 2 has the migration file with itsdown()method. -
Point current back at the old release
Switch the link to release 1. Its caches were built at deploy time, so nothing needs rebuilding.
-
Restart workers and PHP
Send
queue:restart, then reload PHP. In our run, release 2 kept answering until the PHP server restarted. -
Verify
Check
/up, a key page andmigrate:status.
$ php artisan migrate:rollback --step=1 --force
INFO Rolling back migrations.
2026_11_11_000000_create_deploy_notes_table .......... 8.06ms DONE
before restart /release -> release-2 laravel 13.42.0
after restart /release -> release-1 laravel 13.42.0
/up -> 200Keep the last three to five release folders, so a rollback is a link switch and not a rebuild. A rollback that drops a table also drops the rows written since the deploy. Back up the database before any migration that removes data.
What is the full deploy order?
Put together, one release looks like this on a Linux server. The Composer and Artisan commands are the ones we ran. The clone, symlink, systemctl and final curl lines are their Linux equivalents, and we did not run those here.
set -e
NEW=/srv/app/releases/$(date +%Y%m%d%H%M%S)
git clone --depth 1 --branch main "$REPO" "$NEW" # or unpack a build artifact
ln -s /srv/app/shared/.env "$NEW/.env"
rm -rf "$NEW/storage" && ln -s /srv/app/shared/storage "$NEW/storage"
cd "$NEW"
composer install --no-dev --optimize-autoloader --no-interaction
php artisan storage:link
php artisan migrate --force
php artisan optimize
ln -sfn "$NEW" /srv/app/current-tmp && mv -T /srv/app/current-tmp /srv/app/current
sudo systemctl reload php8.5-fpm
php artisan reload # queue workers and scheduler
curl -fsS https://example.com/up > /dev/nullWhich parts come from the documentation only?
Four pieces of a Linux production setup cannot run on Windows. We give them as the Laravel documentation shows them, labeled as documented, not tested.
Nginx: point the web root at public
Documented, not tested. Laravel’s deployment page gives this starting point. The key lines are root, which must end in /public, and $realpath_root, which matters for symlinked releases.
server {
listen 80;
listen [::]:80;
server_name example.com;
root /srv/example.com/public;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
location ~ ^/index\.php(/|$) {
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_buffer_size 32k;
fastcgi_buffers 8 32k;
fastcgi_busy_buffers_size 64k;
fastcgi_hide_header X-Powered-By;
}
location ~ \.php$ {
return 404;
}
location ~ /\.(?!well-known).* {
deny all;
}
}Change the socket path to match your PHP version, for example php8.5-fpm.sock. Never move index.php to the project root: that exposes .env and the rest of the code to the web.
Supervisor: keep the queue workers alive
Documented, not tested. Workers exit on queue:restart, on timeouts and on --max-time. A process manager starts them again. The Laravel queue docs give this Supervisor file:
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /home/forge/app.com/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=forge
numprocs=8
redirect_stderr=true
stdout_logfile=/home/forge/app.com/worker.log
stopwaitsecs=3600sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start "laravel-worker:*"Set stopwaitsecs higher than your longest job, or Supervisor kills jobs mid-run. With symlinked releases, point command at the current path, not at a release folder. The Laravel docs only cover Supervisor. A systemd service can do the same job, but the docs give no unit file and we did not write or test one.
Cron: one line for the scheduler
Documented, not tested as a cron job. We ran schedule:run by hand, which is what cron calls every minute:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1Zero-downtime releases with a symlink
Partly tested. On Linux the pattern is a releases/ folder, a shared/ folder for .env and storage, and a current symlink that the web server and workers use. You build and migrate the new release beside the old one, then switch the symlink in one step. We ran the same pattern with Windows directory junctions in place of symlinks. On Linux, switch atomically with ln -sfn to a temporary name and mv -T over current, then reload PHP-FPM so its cached paths follow the new target.
Limits: what this guide does not cover
- Windows, not Linux. We served the app with PHP’s built-in server and switched releases with directory junctions. Nginx, PHP-FPM, Supervisor, systemd and cron were not run; their sections quote the Laravel docs.
- No traffic during the switch. We did not measure dropped or failed requests while the link changed. Our only finding is that the running PHP process kept the old release until it restarted.
- SQLite only. Migrations on MySQL or PostgreSQL can lock large tables. Test long migrations on a copy of production data before you run them live.
- No Redis, Horizon, Octane or Reverb. Our queue and cache used the database driver. Octane and Reverb need their own reload step, which
php artisan reloadis meant to cover; we did not test it with them. - One server. Multi-server deploys need a shared cache for restart signals and
onOneServer()locks. We did not test that. - Hosting moves. If the release also changes host or domain, the DNS, SSL and redirect steps are in our website migration checklist.