# A Laravel production-deployment checklist

Deploy Laravel to production in a fixed order. Build each release in a new folder with composer install --no-dev --optimize-autoloader, run migrate --force and optimize, then switch traffic and restart workers and PHP. Keep APP_DEBUG=false, a shared .env and storage, one cron line for the scheduler, and the last few releases for a fast rollback.

## Key takeaways

- Laravel 13 supports PHP 8.3 to 8.5 and gets security fixes until March 17, 2028. Laravel 12 security fixes end February 24, 2027.
- After config:cache, env() returned null outside config files in our test. Read settings with config() instead.
- Without --force, migrate refused to run in production and exited with code 1. Add the flag and check the exit code.
- The /up route kept answering 200 in maintenance mode while the homepage returned 503, so monitor a real page too.
- After the release link switched, the running PHP server kept serving the old release until it restarted. Reload PHP on every deploy and rollback.

## 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](/services/laravel-development/) ships every release with this checklist. Building a new product from scratch? Start with our [web application development](/services/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.

## Tested on

PHP 8.5.12 NTS x64 (CLI and built-in server), Composer 2.11.2, Laravel Framework 13.42.0 with SQLite, Windows 10 Pro, November 11, 2026. Nginx, PHP-FPM, Supervisor and cron were not available and are marked as documented only.

## 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.sqlite
```

Generate 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'
0
```

The 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.520s
```

On 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 DONE
```

Use `--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.php
```

There 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=no
```

If 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 signal
```

The 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 DONE
```

## How 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-1
```

If 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/
503
```

That 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 -> 200
```

The 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 --force` from release 2. Only release 2 has the migration file with its `down()` 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 and `migrate: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 -> 200
```

Keep 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/null
```

## Which 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=3600
```

```
sudo 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>&1
```

### Zero-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 reload` is 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](/blog/website-migration-checklist/).

## Want releases you can roll back?

We build and run Laravel apps with scripted deploys, health checks and a tested rollback. Tell us about your app.

[Talk to our Laravel team](/services/laravel-development/)

[Explore the service](https://w3experts.com/services/laravel-development/)

- [Laravel 13.x docs: Deployment](https://laravel.com/docs/13.x/deployment) — accessed 2026-11-11T00:00:00+00:00
- [Laravel 13.x docs: Release notes and support policy](https://laravel.com/docs/13.x/releases) — accessed 2026-11-11T00:00:00+00:00
- [Laravel 13.x docs: Queues (Supervisor configuration)](https://laravel.com/docs/13.x/queues) — accessed 2026-11-11T00:00:00+00:00
- [Laravel 13.x docs: Task scheduling](https://laravel.com/docs/13.x/scheduling) — accessed 2026-11-11T00:00:00+00:00
- [PHP: Supported versions](https://www.php.net/supported-versions.php) — accessed 2026-11-11T00:00:00+00:00

Canonical: https://w3experts.com/blog/laravel-deployment-checklist/
