Deployment

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. A Laravel application in production is several processes: the web server and PHP, queue workers, the scheduler and, for Bookshelf, Reverb. They all run the same release of the code and share Redis and the database. The configuration on this page ran in containers against the Bookshelf application: Nginx 1.31.6 with PHP-FPM 8.5.11, and FrankenPHP 1.13.0. How PHP serves requests underneath, with FPM workers versus worker mode, is on PHP Request Model and Servers.

Diagram of Bookshelf in production. Browsers connect over HTTPS and wss to a load balancer that terminates TLS and checks the /up health route. Behind it, two or more web nodes run either Nginx with PHP-FPM (root current/public, $realpath_root, OPcache without timestamp checks, php artisan optimize, APP_DEBUG=false, FPM reloaded on deploy) or FrankenPHP (Caddy with PHP embedded, automatic HTTPS, worker mode through Octane, octane:reload on deploy), with trusted proxies set. Reverb runs under systemd with wss proxied by Nginx and scaling through Redis. Queue workers run Horizon or queue:work under Supervisor or systemd and are terminated on deploy. The scheduler runs schedule:run from cron every minute with onOneServer tasks and is interrupted on deploy. Object storage on S3 holds covers and exports and serves temporary URLs. Redis holds cache, sessions, locks, rate limits, queues and Reverb pub/sub and is shared by every node; PostgreSQL or MySQL is migrated once per deploy with backward-compatible schema changes. A deploy builds the release, migrates, optimizes, switches the current symlink atomically and then reloads FPM or Octane and restarts workers, the scheduler and Reverb.

Nginx and PHP-FPM

Nginx serves the files in public/ and passes everything else to index.php over FastCGI. The configuration below is the one from the Laravel documentation, with the FPM container’s address and $realpath_root:

server {
    listen 80;
    server_name bookshelf.test;
    root /srv/bookshelf/public;

    add_header X-Frame-Options "SAMEORIGIN";
    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 bookshelf-fpm:9000;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_hide_header X-Powered-By;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

PHP-FPM clears the environment of its workers by default, so variables are set in the pool configuration. The first version of this file stopped FPM from starting at all:

; /usr/local/etc/php-fpm.d/zz-bookshelf.conf
[www]
env[APP_ENV] = "production"
env[APP_DEBUG] = "false"          ; unquoted false: "Unable to include …/zz-bookshelf.conf", FPM initialization failed
env[APP_URL] = "http://bookshelf.test"
env[LOG_CHANNEL] = "stderr"

FPM parses pool files as INI, where a bare false is a boolean and env[...] needs a string. Quoting every value fixed it. Real secrets usually come from the environment or a secrets manager rather than a file in the image (Security: CSRF, Encryption and Hashing covers encrypted .env files).

The first requests through the stack:

/up 500       ErrorException: tempnam(): file created in the system's temporary directory
/books 500
/.env 403     the "deny all" location for dotfiles
/storage/../.env 403

Directory permissions. FPM workers run as www-data (uid 33), and storage/ and bootstrap/cache belonged to root. Blade could not write a compiled view and PHP fell back to /tmp, which Laravel turns into an exception. chown -R www-data:www-data storage bootstrap/cache fixed it, as would a chmod for a deploy user in the www-data group. The next failure was filemtime(): stat failed for /tmp/claude-0/…/storage/framework/views/…: compiled views from the development machine referred to its absolute paths. php artisan optimize:clear removed them. Caches are built on the server, or in the image at the final path, never copied from development.

optimize and OPcache

php artisan optimize caches the configuration, routes, events and compiled views. Without caches, every request reads config/*.php, registers every route and scans for listeners. OPcache keeps compiled PHP in shared memory, and with opcache.validate_timestamps=0 it stops checking files for changes. Two hundred requests to /books after 20 warm-up requests, on each setup:

no caches:                                median 13.7 ms, p95 15.7 ms
after php artisan optimize:               median  9.7 ms, p95 13.5 ms
plus opcache.validate_timestamps=0:       median  9.2 ms, p95 11.6 ms
FrankenPHP php-server (no worker mode):   median 10.7 ms, p95 16.3 ms

optimize cut the median by about 30% on a page that runs two queries, and OPcache without timestamp checks took off a little more. config:cache has one consequence that catches people: env() returns null outside config/ files once the cache exists (Directory Structure and Configuration).

Without timestamp checks, nothing changes until PHP-FPM reloads. A /version route was added and php artisan route:cache rebuilt the route file twice, with release v1 and then release v2:

1: <h1>Not on the shelf</h1> …                 the route file from before the change was still in OPcache
2 after route:cache with v2: <h1>Not on the shelf</h1> …
3 after reloading PHP-FPM (kill -USR2): release v2

The new bootstrap/cache/routes-v7.php was on disk, but OPcache served the copy it had compiled at the first request. A graceful reload (systemctl reload php8.5-fpm, or USR2 to the master) is therefore part of every deploy on such a server.

Zero-downtime deploys build each release in its own directory, run composer install --no-dev --optimize-autoloader, npm run build, php artisan migrate --force and php artisan optimize there, and then point a current symlink at it with an atomic rename (ln -sfn releases/2 current.tmp && mv -T current.tmp current). Two Nginx servers in front of the same FPM pool, one passing $document_root (the symlink path) and one $realpath_root (the resolved directory), each served a one-line release:

before switch :8081  release 1 from /srv/app/releases/1/public
before switch :8082  release 1 from /srv/app/releases/1/public
current -> releases/2
after switch  :8081  release 1 from /srv/app/releases/1/public      ($document_root, three requests)
after switch  :8082  release 2 from /srv/app/releases/2/public      ($realpath_root)

With $document_root, PHP received /srv/app/current/public/index.php, and its realpath cache and OPcache had already resolved that to release 1. It kept serving the old release, even with timestamp checks on, until the cache expired or FPM reloaded. With $realpath_root, Nginx resolves the symlink itself, PHP sees the new path, and the switch is immediate. Envoy (laravel/envoy), Deployer and Forge’s zero-downtime deployments all automate this layout. storage/ and .env live in a shared directory linked into each release.

Schema changes must work with both releases during the switch. A column is added in one release and used in the next, and a column is dropped only after no running code reads it. The ordering patterns are on Schema Evolution Best Practices.

Long-running processes

Workers, the scheduler loop, Reverb and Octane keep the old code in memory, so every deploy must restart them. php artisan reload signals all of them at once. In Bookshelf it reported:

$ php artisan reload
 INFO Reloading services.
 queue .. 12.43ms DONE
 schedule .. 2.05ms DONE
 reverb .. 2.09ms DONE

The command only asks the processes to exit after their current work. A process monitor starts them again with the new release. Horizon is restarted with php artisan horizon:terminate. With systemd:

# /etc/systemd/system/bookshelf-horizon.service
[Unit]
Description=Bookshelf Horizon
After=network.target redis.service

[Service]
User=www-data
WorkingDirectory=/srv/bookshelf/current
ExecStart=/usr/bin/php artisan horizon
Restart=always
RestartSec=3
KillSignal=SIGTERM
TimeoutStopSec=3600

[Install]
WantedBy=multi-user.target

The same shape works for php artisan reverb:start and php artisan queue:work, or Supervisor can be used as on Queues and Jobs. WorkingDirectory points at current, so a restarted process picks up the new release. TimeoutStopSec must exceed the longest job. The scheduler is the single cron line from Task Scheduling, on one server or with onOneServer() tasks on all of them.

FrankenPHP

FrankenPHP is Caddy with PHP embedded, so one process replaces Nginx and FPM and handles HTTPS certificates itself. frankenphp php-server --root public served Bookshelf from the official dunglas/frankenphp:php8.5 image:

$ docker run -e APP_ENV=production -e APP_DEBUG=false -v "$PWD":/srv/bookshelf -w /srv/bookshelf \
    dunglas/frankenphp:php8.5 frankenphp php-server --root public --listen :80
$ curl -s -o /dev/null -w '%{http_code}\n' http://$FRANKENPHP/up
400
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: bookshelf.test' http://$FRANKENPHP/up
200

The 400 came from Bookshelf itself. trustHosts(at: ['^bookshelf\.test$']) from Middleware rejects any other Host header in production. That protects password-reset links and redirects from host header injection, but it also refuses a load balancer’s health check that uses the IP address, so the health check must send the real host name. Without worker mode, FrankenPHP was about as fast as Nginx and FPM (10.7 against 9.5 ms). Worker mode through Octane, which keeps the application booted, is where it differs (Octane and Performance).

Production settings

  • APP_DEBUG=false and APP_ENV=production. With debug on, error pages show stack traces and configuration values.

  • The /up health route boots the application and answers 200 or 500. It is the target for the load balancer, the container orchestrator and uptime checks. It does not test the database unless a DiagnosingHealth listener does.

  • trustProxies behind a load balancer, so $request->ip(), HTTPS detection and signed URLs see the client’s values. Rate limits and logs are wrong without it.

  • Logs to stderr or a collector in containers, daily with rotation on servers.

  • Assets built in CI (npm run build), with public/build/manifest.json in the release (Vite and Frontend Assets).

  • Sessions, cache and queues on Redis or the database, never file on more than one node.

Managed platforms

Laravel Cloud runs Laravel applications without servers to manage: it builds from Git and runs web, workers and the scheduler. It also provides managed databases, Redis-compatible caches and object storage, scales automatically and reloads services itself. Laravel Forge provisions and manages servers on a provider such as AWS, DigitalOcean or Hetzner, with Nginx, PHP-FPM, Supervisor, cron and zero-downtime deployments configured as above. Vapor deploys to AWS Lambda. These platforms remove the work on this page, at a price per month. Containers on a cloud platform, such as ECS and Fargate (ECS and Fargate) or Kubernetes (Kubernetes), are the self-managed alternative, with the image from Docker and Sail.

Where older books and tutorials are outdated

  • FTP upload of the whole project into public_html exposes .env, vendor/ and storage/. Only public/ belongs in the web root.

  • php artisan config:cache and route:cache as separate steps are combined in php artisan optimize. Closure routes have been cacheable since Laravel 8.

  • queue:restart plus a list of service-specific commands can be replaced by php artisan reload.

  • Apache with mod_php still works. Nginx with PHP-FPM, or FrankenPHP, is the documented setup.