Prepare the server once: PHP-FPM (PHP-FPM via proxy_fcgi), a virtual host (Virtual Hosts), TLS (Let's Encrypt), a MySQL 524 database, and a deploy user who owns the code while www-data only reads it (Web Server Permissions). The shared .env sets APP_ENV=production, APP_DEBUG=false and an APP_KEY. With debug on, one error page shows your configuration to anyone. Each release gets a directory:

FallbackResource replaces Laravel 2,157 's .htaccess, so AllowOverride None avoids the directory walk (How .htaccess Works), and CGIPassAuth On forwards the Authorization header that Sanctum 2,988 needs. PHP writes only to shared/storage (2770 deploy:www-data). bootstrap/cache is written at deploy time, so the server only reads it:
<VirtualHost *:80>
ServerName shop.example.com
DocumentRoot /srv/shop/current/public
<Directory /srv/shop/current/public>
AllowOverride None
Require all granted
FallbackResource /index.php
CGIPassAuth On
</Directory>
<FilesMatch "\.php$">
SetHandler "proxy:unix:/run/php/php8.5-fpm.sock|fcgi://localhost"
</FilesMatch>
</VirtualHost>#!/usr/bin/env bash
set -euo pipefail
APP=${APP:-/srv/shop}
REPO=${REPO:-git@github.com:example/shop.git}
REL=$APP/releases/$(date +%Y%m%d%H%M%S)
git clone --quiet --depth 1 "$REPO" "$REL"
cd "$REL"
rm -rf storage && ln -s "$APP/shared/storage" storage # logs, sessions, uploads
ln -s "$APP/shared/.env" .env
composer install --no-dev --optimize-autoloader --no-interaction --quiet
php artisan migrate --force # schema first: old code must tolerate it
php artisan storage:link
php artisan optimize # config, events, routes and views cached
ln -sfn "$REL" "$APP/current.next" # build the new link beside the old one...
mv -T "$APP/current.next" "$APP/current" # ...and rename it: one atomic step
ls -1d "$APP"/releases/* | head -n -3 | xargs -r rm -rf # keep three releases
echo "live: $(readlink "$APP/current")"mv -T renames the link with rename(2), so a request sees the old release or the new one, never half of each. Release 1.0 went live on an empty database. Release 1.1, which added an isbn column, went out while a loop requested /about every 20 ms. All 731 requests succeeded, but the old release answered every one of them, and it was still answering six minutes later. The FPM workers had resolved current once and kept running the old files from OPcache, which is why Laravel's nginx 75 example passes $realpath_root. On Apache, reload FPM after the swap. The FPM here is shared, so release 1.2 was tested on a private FPM master and reloaded with kill -USR2, the signal systemctl reload php8.5-fpm sends (see its ExecReload=):
$ curl -s http://localhost/about
{"release":"20260923170407","version":"1.0","products":0}
$ sed -E 's/"products":[0-9]+//' load.log | sort | uniq -c
731 {"release":"20260923170407","version":"1.0",} 200
$ ./deploy.sh | tail -1; curl -s http://localhost/about
live: /srv/shop/releases/20260923171054
{"release":"20260923170441","version":"1.1","products":3}
$ kill -USR2 $(cat fpm.pid); curl -s http://localhost/about
{"release":"20260923171054","version":"1.2","products":3}End deploy.sh with that reload and php artisan reload, which restarts queue workers, Reverb 2,157 and Octane 4,043 for systemd 142,543 to bring back (systemd Services and Queue Workers). To roll back, repoint current and reload. Migrations stay applied, so every schema change must also work with the previous code.