DEPLOYMENT AND PRODUCTION OPERATIONS HIGH-THROUGHPUT RUNTIMES AND ASYNC RESOURCE OPTIMIZATION

Deploy Laravel to Production: The Complete 2026 Guide

🕒 16 Minute Read 📅 Published: March 22, 2026

Knowing how to deploy Laravel to production is one of those milestones that separates developers who build things from developers who ship things. The framework makes local development frictionless: php artisan serve, a .env file, and you’re writing features within minutes. Production runs by different rules, with different failure modes and real consequences when something breaks. This guide is part of Origin Main’s AI deployment operations series, and it covers the full pipeline: server provisioning, Nginx configuration, environment hardening, Composer optimisation, queue workers, scheduled tasks, zero-downtime releases, and monitoring, so you ship with confidence the first time.

Understanding What Production Actually Means

Production isn’t just a server with your code on it. It’s a contract. Every request corresponds to a real user expecting the application to behave correctly, return quickly, and not expose their data. If you’re still weighing whether Laravel is the right framework for that in 2026, our take on choosing Laravel over Node and Python AI stacks is worth reading first. This guide assumes you already have.

Three things define a production environment that a local development setup does not.

Persistence. Your database, uploaded files, and cached state must survive deployments, reboots, and horizontal scaling. Nothing ephemeral belongs on a production container or server filesystem unless you explicitly manage it.

Observability. You cannot dd() your way through a production bug. You need structured logs, error tracking, and performance metrics before your first real user arrives, not after.

Repeatability. Every deploy must be scripted. If you’re running git pull and composer install by hand, you’ve already made a mistake. Not because it won’t work once, but because it will fail at the worst possible moment.

If your local stack doesn’t mirror production PHP version for version, you’re doing exploratory work, not engineering. Setting up a Laravel development stack that mirrors production covers exactly how to close that gap before you run a deploy.

Server Requirements for Laravel 13

Laravel 13 requires PHP 8.3 or higher. That’s a hard requirement, not a recommendation, and it’s worth knowing what’s behind it: Laravel 13’s release changes for AI-oriented development go beyond the version bump and are relevant if your production application uses the framework’s newer AI primitives. PHP 8.3 itself moved into security-only support in late 2025, so PHP 8.4 is the sensible production default rather than the bare minimum.

Beyond the PHP version, your server needs the following extensions enabled:

  • BCMath, Ctype, cURL, DOM, Fileinfo
  • Filter, Hash, Mbstring, OpenSSL
  • PCRE, PDO, Session, Tokenizer, XML

These are almost always present on a standard LEMP stack (Linux, Nginx, MySQL, PHP), but Fileinfo and cURL occasionally get missed on minimal OS installs. Verify them:

php -m | grep -E "curl|fileinfo|mbstring|pdo|xml|tokenizer"

If any are absent, install the missing modules. On Ubuntu:

sudo apt install php8.4-curl php8.4-mbstring php8.4-xml php8.4-fileinfo
ComponentRecommendationNotes
OSUbuntu 26.04 LTSStandard support until 2031; Ubuntu 24.04 LTS remains a safe fallback
PHP8.4Active support; PHP 8.5 is also viable for new projects
Web ServerNginxOr FrankenPHP for Octane
DatabaseMySQL 8.0+ / PostgreSQL 16 or 17
Cache / QueueValkey (Redis protocol-compatible)See the licensing note below
Process SupervisorSupervisor 4For queue workers
DeploymentLaravel Forge / Deployer PHP

Architect’s Note Redis itself changed its license terms in 2024, moving from a permissive license to the Server Side Public License and its own Source Available License. Both restrict offering Redis as a hosted service, which doesn’t affect a typical Laravel deployment where Redis runs internally, but the shift pushed the major cloud providers and most of the open source ecosystem toward Valkey, a BSD-licensed fork that stays protocol-compatible with Redis. Laravel’s REDIS_* environment variables and the phpredis or predis client libraries work against Valkey without any application code changes. Unless you have a specific reason to stay on Redis proper, Valkey is the more future-proof default.

A note on FrankenPHP: Laravel’s documentation lists it alongside Nginx as a first-class server option. If you’re building a high-throughput API or considering Laravel Octane, FrankenPHP is worth evaluating; it supports a persistent worker mode that eliminates PHP bootstrap overhead per request. For a standard application, Nginx with PHP-FPM remains the simpler, more operator-familiar choice, and the trade-offs between the two are covered in depth in our comparison of Octane and PHP-FPM for AI workloads.

For a full breakdown of where each of these components sits in a production-grade Laravel workflow, the current Laravel developer toolchain for 2026 covers the full stack.

Provisioning Your Server: Managed vs. DIY

You have two meaningful paths here. Choose honestly based on your situation.

Laravel Forge provisions and manages your server on any major cloud provider (AWS, DigitalOcean, Hetzner, Linode, Vultr). It handles Nginx configuration, PHP-FPM, SSL certificates via Let’s Encrypt, cache and queue infrastructure (including managed Valkey), Supervisor, deployment scripts, and database management through a UI and CLI.

The honest recommendation: if you’re a solo developer or a small team and your core competency is application code rather than infrastructure, Forge is not laziness. It’s the correct economic decision. The Nginx configurations it generates are production-hardened. The deployment scripts it creates are correct by default. You avoid a class of operational mistakes that cost engineers days.

Manually provisioning servers doesn’t scale, and more to the point, it doesn’t reproduce. If you’re managing more than one environment, our piece on infrastructure as code for Laravel teams explains how to codify your stack before it becomes technical debt.

Option B: Self-Managed VPS (DIY)

If you’re going DIY, valid, especially if you’re learning or have specific compliance requirements, here is the minimal provisioning sequence for Ubuntu 26.04:

# Update system
sudo apt update && sudo apt upgrade -y

# Install Nginx
sudo apt install nginx -y

# Install PHP 8.4 and required extensions
sudo add-apt-repository ppa:ondrej/php
sudo apt update
sudo apt install php8.4-fpm php8.4-cli php8.4-mysql php8.4-redis \
  php8.4-curl php8.4-mbstring php8.4-xml php8.4-zip \
  php8.4-fileinfo php8.4-bcmath php8.4-tokenizer -y

# Install Composer
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer

# Install Valkey (Redis-protocol compatible)
sudo apt install valkey-server -y
sudo systemctl enable valkey-server
sudo systemctl start valkey-server

# Install Supervisor
sudo apt install supervisor -y
sudo systemctl enable supervisor
sudo systemctl start supervisor

Create a dedicated application user. Never run your application as root or as www-data with write access to your entire filesystem:

sudo adduser deployer
sudo usermod -aG www-data deployer

Nginx Configuration for Laravel

This is where more deployments silently break than anywhere else. The most critical rule: Nginx must point to your public/ directory, not the project root. The public/index.php file is the single entry point for your entire application. Exposing the project root would make your .env file publicly accessible, which is a real risk, not a theoretical one.

server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.com www.yourdomain.com;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name yourdomain.com www.yourdomain.com;

    root /var/www/your-app/public;
    index index.php;

    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
    ssl_prefer_server_ciphers off;

    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    charset utf-8;

    gzip on;
    gzip_vary on;
    gzip_types text/plain text/css application/json application/javascript text/xml;

    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 ~ \.php$ {
        fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_hide_header X-Powered-By;
    }

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

Install SSL with Certbot:

sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Certbot auto-renews. Test the renewal process:

sudo certbot renew --dry-run

Environment Configuration and Security Hardening

Your .env file is not a deployment artefact. It is a secret store. It belongs on the server directly and never belongs in your Git repository.

Key Generation

php artisan key:generate

Run this once on initial setup. Never regenerate a production key unless you are intentionally invalidating all existing sessions and encrypted data. Your APP_KEY is used by Laravel’s Encrypter to protect all encrypted model attributes, cookies, and session data.

Critical Production .env Settings

APP_NAME="Your Application"
APP_ENV=production
APP_KEY=base64:GENERATED_KEY_HERE
APP_DEBUG=false
APP_URL=https://yourdomain.com

LOG_CHANNEL=stack
LOG_LEVEL=error

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=your_database
DB_USERNAME=your_db_user
DB_PASSWORD=strong_password_here

CACHE_STORE=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis

REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

MAIL_MAILER=smtp

APP_DEBUG=false is non-negotiable. With debug mode enabled, Laravel renders full stack traces, including environment variables, directly in the browser on unhandled exceptions. On a production server that’s a critical security vulnerability, not an aesthetic concern.

The REDIS_* variable names stay as-is even if the service behind them is Valkey rather than Redis proper. Laravel’s Service Container registers the cache, session, and queue bindings from these values regardless of which protocol-compatible server answers on that port.

File Permissions

sudo chown -R deployer:www-data /var/www/your-app
sudo find /var/www/your-app -type f -exec chmod 644 {} \;
sudo find /var/www/your-app -type d -exec chmod 755 {} \;

sudo chmod -R 775 /var/www/your-app/storage
sudo chmod -R 775 /var/www/your-app/bootstrap/cache

Composer and Application Optimisation

This is the step most deployment tutorials gloss over. The difference between a development Composer install and a production one is significant.

composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist

--no-dev strips development dependencies (PHPUnit, Collision, Faker, and similar packages) that have no place in production. --optimize-autoloader generates a class map instead of relying on PSR-4 filesystem traversal, which is measurably faster on cold boots. --prefer-dist downloads zip archives instead of cloning git repositories, which is faster and more reliable on CI/CD pipelines.

Laravel Optimisation Commands

Run these as part of every deployment. They cache the framework’s configuration, routes, views, and events into single serialised PHP files, eliminating repeated filesystem lookups on every request:

php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache

Clear them during a new deploy before regenerating:

php artisan optimize:clear
php artisan optimize

php artisan optimize is a convenience command that runs all four cache commands in one call. Use it.

Production Pitfall Never run php artisan config:cache in an environment where you have called env() directly in your application code outside of a config file. Once the config is cached, env() calls return null, by design, because the config cache is the only authorised source of environment values once it exists. All env() calls belong in config/*.php files. If you have legacy code with env() scattered through service classes or controllers, your cached deployment will silently misbehave on the values that depend on it. Audit before caching.

If your application handles file uploads, you need the storage symlink. Create it once on initial deploy:

php artisan storage:link

This creates public/storage pointing to storage/app/public. Do not run it on every deploy; it fails if the link already exists. Your deploy script should check for its existence:

[ ! -L public/storage ] && php artisan storage:link

Database Migrations in Production

Running php artisan migrate in production requires care. There are two distinct phases here: initial deploy and subsequent deploys.

Initial Deploy

php artisan migrate --force

The --force flag is required in production. Without it, Artisan prompts for confirmation, which blocks non-interactive deploy scripts.

Subsequent Deploys

Before running migrations against a live database:

  1. Take a database snapshot. Most managed databases (RDS, PlanetScale, Supabase) offer point-in-time recovery. On a self-managed instance, run mysqldump before every migration.
  2. Review the migration for destructive operations (dropColumn, dropTable, change on an indexed column). These can lock tables and cause downtime under load.
  3. Consider squashing migrations periodically to keep your migration history manageable.

Failure Mode On high-traffic MySQL tables, adding a column with a default value used to force a full table rebuild in MySQL versions below 8.0. In MySQL 8.0+ with InnoDB, most ALTER TABLE operations are instant for ADD COLUMN with a default, but not all cases qualify. A non-null default that requires a backfill still rewrites the table. For tables with millions of rows, use pt-online-schema-change or a shadow-table migration pattern to avoid a full table lock mid-deployment.

Queue Workers and Supervisor

Laravel’s queue system is the backbone of any application that does non-trivial background work: sending emails, processing uploads, dispatching webhooks, handling calls to an LLM API. Your queue workers are long-running PHP processes. They need a process manager to restart when they crash, and to restart gracefully when you deploy new code.

Supervisor is the standard solution.

Supervisor Configuration

Create a configuration file at /etc/supervisor/conf.d/laravel-worker.conf:

[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/your-app/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600 --queue=default,high,low
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=deployer
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/your-app/storage/logs/worker.log
stopwaitsecs=3600

Key options: numprocs=4 runs four concurrent worker processes; tune this against your server’s CPU count and queue volume. --max-time=3600 restarts each worker after an hour, preventing memory leaks from accumulating in long-running jobs. --tries=3 retries a failed job up to three times before it moves to the failed jobs table. --queue=default,high,low processes the high queue first, then default, then low, giving you priority-based job routing. stopwaitsecs=3600 tells Supervisor to wait up to an hour for a worker to finish its current job before force-killing it on a server stop, so set this to your longest acceptable job duration.

Reload Supervisor after creating or editing the config:

sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start laravel-worker:*

Deploying with Queue Workers Running

When you deploy new code, your queue workers are still running old code. They need to be restarted gracefully, not killed abruptly, which would abort jobs mid-execution.

php artisan queue:restart

This signals all workers to finish their current job and exit. Supervisor immediately spawns fresh workers that load the new code. Include this in every deploy script.

Failed Jobs

Always configure a failed job table. It’s a five-second setup that saves hours of debugging:

php artisan queue:failed-table
php artisan migrate

Monitor failed jobs in production:

php artisan queue:failed
php artisan queue:retry all
php artisan queue:flush

Architect’s Note Laravel’s ShouldBeUnique and ShouldBeUniqueUntilProcessing contracts on your job classes use an atomic Redis or Valkey lock to prevent duplicate job dispatches. If you’re queuing jobs from event listeners or webhooks that can fire multiple times for the same payload, a Stripe webhook for a payment confirmation is the classic case, implement ShouldBeUnique with a custom uniqueId() method based on the entity identifier rather than the job creation time. That eliminates the entire class of duplicate-processing bugs before they reach the database. Configuring Horizon for AI queue workloads goes further into worker tuning for high-throughput, mixed-priority queues.

Task Scheduling in Production

Laravel’s scheduler is elegantly simple. You define your schedule in routes/console.php, and a single cron entry runs everything.

// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('reports:daily')->dailyAt('06:00')->timezone('Africa/Johannesburg');
Schedule::command('cache:prune-stale-tags')->hourly();
Schedule::job(new SendWeeklyDigest)->weekly()->onOneServer();

Set up the single cron entry on your server as the deployer user:

crontab -e -u deployer

Add:

* * * * * cd /var/www/your-app && php artisan schedule:run >> /dev/null 2>&1

The * * * * * triggers every minute, but the scheduler only executes tasks whose time has come. This is the correct and documented approach.

For clustered or multi-server deployments, ->onOneServer() acquires an atomic lock via the cache driver (Valkey or Redis) to ensure a scheduled task runs on exactly one server even when multiple scheduler instances are ticking simultaneously.

Zero-Downtime Deployment Strategies

AAAWkGp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZqanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTo5ZDM5MzE0YS1mYmI1LTQzY2EtYjRiZC0wZmQ3YTUyZGJiNmMAAAADf2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaB9O++cGPRfIS/UY9+jtdmkAAABwY2JvcqNscmVsYXRpb25zaGlwaHBhcmVudE9maWRjOmZvcm1hdG1pbWFnZS9zdmcreG1samluc3RhbmNlSUR4LHhtcDppaWQ6ODJmZmM5YTQtNzliOC00ZmNmLTljMDEtNTFhYTQ0ODZhZmY5AAABzmp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNoEO+CmWxOtzmfUmeHRM3mJwAAAYVjYm9yoWdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggBArqmydZGukwvqaJ/QqvdLxgxsAtQ5DDdr82t8eF6zSkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRtc29mdHdhcmVBZ2VudKFkbmFtZWZDbGF1ZGVqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMuAAAAxGp1bWIAAABAanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5oYXNoLmRhdGEAAAAAGGMyc2i+tskjoDFBrtC8EI4rtPSBAAAAfGNib3KlamV4Y2x1c2lvbnOBomVzdGFydBieZmxlbmd0aBkeGGRuYW1lbmp1bWJmIG1hbmlmZXN0Y2FsZ2ZzaGEyNTZkaGFzaFggJ+cGphEtiA09GAxVYrv+D95TWXz3Oe7XtmILOFqc4phjcGFkSQAAAAAAAAAAAAAAAmRqdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACNWNib3Kmamluc3RhbmNlSUR4LHhtcDppaWQ6OWYxOTYwYzktNjNkZC00ODg2LWI5MTUtYTI5NGQzYjQzMjkydGNsYWltX2dlbmVyYXRvcl9pbmZvo2RuYW1lc0FudGhyb3BpYyBDbGF1ZGUuYWlndmVyc2lvbmUxLjAuMHdvcmcuY29udGVudGF1dGguYzJwYV9yc2YwLjkwLjBpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjlkMzkzMTRhLWZiYjUtNDNjYS1iNGJkLTBmZDdhNTJkYmI2Yy9jMnBhLnNpZ25hdHVyZXJjcmVhdGVkX2Fzc2VydGlvbnOComN1cmx4KnNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuYWN0aW9ucy52MmRoYXNoWCAamk8KD9nlB7AFiHpTD/4w68uzDE30mZHKbL4m7FlXw6JjdXJseClzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmhhc2guZGF0YWRoYXNoWCCScEz4+elgns0EjnGqiXZwNMLj5YaGSO4GXpDFDQCC2HNnYXRoZXJlZF9hc3NlcnRpb25zgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggBArqmydZGukwvqaJ/QqvdLxgxsAtQ5DDdr82t8eF6zRjYWxnZnNoYTI1NgAAEDhqdW1iAAAAKGp1bWRjMmNzABEAEIAAAKoAOJtxA2MycGEuc2lnbmF0dXJlAAAAEAhjYm9y0oRZAhKiASYYIVkCCjCCAgYwggGNoAMCAQICFEDloAruwjnQvriD+gZCBT1nVRMAMAoGCCqGSM49BAMDMEkxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMS4wLAYDVQQDEyVBbnRocm9waWMgQ29udGVudCBDcmVkZW50aWFscyBSb290IENBMB4XDTI2MDgwNzE4NDM1NloXDTI4MDgwNjE5NDM1NlowRDEXMBUGA1UEChMOQW50aHJvcGljLCBQQkMxKTAnBgNVBAMTIEFudGhyb3BpYyBDbGF1ZGUgQ29udGVudCBTaWduaW5nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEmHoKa8tQGAUU1TS9QqU5W0Tp2N3XsvlK7BfQt6YWKwEzd2R3/dzKPEUDdCjlLjp9fT+KFjRVnuZ9v0oXvTe3k6NYMFYwDgYDVR0PAQH/BAQDAgeAMBUGA1UdJQQOMAwGCisGAQQBg+heAgEwDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBTOUeIEgU5kWyP448TPmj6cwddcwjAKBggqhkjOPQQDAwNnADBkAjAxcx0UngF60stVjs5G4T2eiptsBk5mf9oCtfJPAUBl8qs/PEXa8+gk1/X5QJ2DVcYCMHBfXN31YapiSqYvlIWrDVDJKOvXMl+kkz37Wt0PBI8sw486Mq6JeOhT+lRR4b1HCaFjcGFkWQ2eAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9lhAYsw+boRa/2am0+CwrFxBQRwkh4esW5vU4wvXNz7hgwHOiCmXDgZ0lsrqurD0a5B1rveVYvLcRaqQnBTfIIeCUw== Zero-downtime Laravel release sequence Five sequential stages: pull code into a new release directory, install dependencies and cache configuration, run database migrations against the live database, atomically switch the symlink to the new release, then restart queue workers and reload PHP-FPM. Pull code into releases/N Current release stays untouched Install deps, cache config composer install, optimize Migrate the live database Must finish before the switch Switch symlink to new release Instant, atomic pointer flip Restart workers, reload PHP-FPM Loads new code, clears OPcache Nginx keeps serving the old release until the symlink flips A migration that runs after the switch creates a window of errors

This is where production deployment gets architectural. Zero-downtime means users experience no service interruption during a deploy. The diagram above shows the sequence that makes this possible: pull code into an isolated release directory, install and cache, migrate the live database, flip the symlink atomically, then restart the processes that hold old code in memory. There are three dominant ways to run that sequence for Laravel.

Option A: Laravel Forge Deployment Scripts

Forge generates a deployment script automatically, and you can customise it in the Forge dashboard under your site’s Deployment Script tab. A production-hardened script looks like this:

cd /home/forge/your-app.com

git pull origin main

composer install --no-dev --optimize-autoloader --no-interaction

php artisan optimize:clear
php artisan optimize

php artisan migrate --force

[ ! -L public/storage ] && php artisan storage:link

php artisan queue:restart

( flock -w 10 9 || exit 1; echo 'Restarting FPM...'; sudo -S service php8.4-fpm reload ) 9>/tmp/fpmlock

Trigger it via a Git webhook. Forge watches your repository branch and deploys automatically on push.

Option B: Deployer PHP (Self-Managed, Zero-Downtime)

Deployer (GitHub) is the standard tool for zero-downtime deployments on self-managed servers. It uses a releases directory structure: each deploy creates a numbered releases/N directory, and on success, the current symlink is atomically switched to point at the new release. The switch is instantaneous. Nginx serves the new code the moment the symlink flips.

Install Deployer locally, not on the server:

composer require deployer/deployer --dev

Create a deploy.php in your project root:

<?php

namespace Deployer;

require 'recipe/laravel.php';

set('application', 'your-app');
set('repository', 'git@github.com:your-org/your-app.git');
set('git_tty', false);
set('keep_releases', 5);
set('shared_files', ['.env']);
set('shared_dirs', ['storage']);
set('writable_dirs', ['bootstrap/cache', 'storage', 'storage/app', 'storage/logs', 'storage/framework']);

host('production')
    ->setHostname('your-server-ip')
    ->setRemoteUser('deployer')
    ->setDeployPath('/var/www/your-app');

after('deploy:update_code', 'artisan:optimize:clear');
after('artisan:migrate', 'artisan:optimize');
after('artisan:optimize', 'artisan:queue:restart');

after('deploy:failed', 'deploy:unlock');

Deploy:

./vendor/bin/dep deploy production

Roll back to the previous release if something goes wrong:

./vendor/bin/dep rollback production

That rollback takes seconds. It atomically switches the current symlink back to the previous release directory. The database is the one part that doesn’t roll back with it, schema migrations aren’t reversed automatically, which is why additive migrations, never destructive in the same deploy as a code change, are the correct approach.

Option C: Laravel Cloud

Laravel Cloud is Laravel’s own managed platform, generally available since early 2025 and actively developed since. It handles containerised deployments, auto-scaling that includes scale-to-zero for idle environments, managed databases, managed Valkey caching, and worker processes with no server configuration required. It sits between Forge, where you still manage the server, and a full PaaS offering. For greenfield applications without specific infrastructure requirements, it’s worth serious evaluation, especially given how quickly its feature set has matured.

Field Note Zero-downtime deployment solves the web process problem, but it doesn’t solve the database migration problem. The atomic symlink switch means new code goes live instantly, but if your new code expects a database column that doesn’t exist yet because the migration ran after the switch, you’ll have errors in that gap. The correct sequence is always: run the migration first, then flip the symlink. Better still, keep your migrations backwards compatible for at least one release cycle: add columns before the code references them, drop columns after the code no longer references them. This pattern, expand and contract migrations, is how high-traffic teams change schemas without downtime.

Monitoring, Logging, and Observability

Deploying without observability is flying blind. Here is the minimum viable monitoring stack.

Laravel Pulse

Laravel Pulse is the application performance monitor bundled with the framework. It tracks slow queries and their origin, slow routes, queued job throughput and failures, cache command frequency, and exception rates and spikes.

Enable it with a single package install if it’s not already present:

composer require laravel/pulse

laravel/pulse on GitHub

php artisan vendor:publish --provider="Laravel\Pulse\PulseServiceProvider"
php artisan migrate

Protect the /pulse dashboard in bootstrap/app.php or your routes file:

use Laravel\Pulse\Facades\Pulse;

Route::middleware(['auth', 'can:viewPulse'])->get('/pulse', function () {
    return view('pulse');
});

Structured Logging with Channels

Configure your config/logging.php stack for production. Writing to a single laravel.log file on a multi-process server is a concurrency hazard, log lines from concurrent requests interleave. Use a daily rotating log with proper permissions, or push logs to an external service:

// config/logging.php
'channels' => [
    'stack' => [
        'driver' => 'stack',
        'channels' => ['daily', 'slack'],
        'ignore_exceptions' => false,
    ],
    'daily' => [
        'driver' => 'daily',
        'path' => storage_path('logs/laravel.log'),
        'level' => env('LOG_LEVEL', 'error'),
        'days' => 14,
        'permission' => 0664,
    ],
    'slack' => [
        'driver' => 'slack',
        'url' => env('LOG_SLACK_WEBHOOK_URL'),
        'username' => 'Laravel Log',
        'emoji' => ':boom:',
        'level' => 'critical',
    ],
],

This writes error and above to your daily rotating log file, and separately pipes critical logs to a Slack webhook for immediate notification. Tune LOG_LEVEL in your .env. In production, error is almost always the right threshold; debug in production fills your disk.

Error Tracking

Integrate Sentry for Laravel (GitHub) for exception tracking with full stack traces, user context, and performance monitoring.

composer require sentry/sentry-laravel
php artisan sentry:publish --dsn=https://your-dsn@sentry.io/your-project-id

OPcache Configuration

This is frequently missed. OPcache caches PHP bytecode in memory so PHP doesn’t re-parse source files on every request. It’s enabled by default on most PHP-FPM installs, but the defaults are often under-configured. Add to your php.ini or /etc/php/8.4/fpm/conf.d/10-opcache.ini:

opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.revalidate_freq=0
opcache.validate_timestamps=0
opcache.save_comments=1

Setting validate_timestamps=0 tells OPcache to never check whether source files have changed on disk. This is the highest-performance setting and is correct for production, your deployment process (the symlink switch or PHP-FPM reload) is responsible for invalidating the cache. After every deploy, reload PHP-FPM:

sudo systemctl reload php8.4-fpm

Final Pre-Launch Checklist

Security: APP_DEBUG=false, APP_ENV=production, .env excluded from git, no exposed sensitive directories, security headers present in Nginx, SSL installed with auto-renewal tested, database user scoped to what it needs.

Performance: php artisan optimize run, OPcache enabled with production settings, Valkey or Redis running as cache, session, and queue driver, gzip enabled, static assets compiled and versioned.

Reliability: Supervisor running with the correct worker count, cron entry configured for the scheduler, failed jobs table migrated, migrations run with --force, storage symlink created, backup strategy in place for both database and uploaded files.

Observability: Laravel Pulse installed and its dashboard protected, error tracking configured, log channel set to a production-appropriate level, /up health check responding.

Deployment process: Deployment scripted rather than manual, rollback procedure documented and tested, queue:restart runs on every deploy, PHP-FPM reloaded after every deploy.

Summary

Deploying Laravel to production comes down to a known set of problems with known solutions: correct Nginx configuration, hardened environment settings, Composer and framework optimisation, disciplined database migrations, Supervisor-managed queue workers, atomic zero-downtime releases, and an observability layer that makes all of it debuggable. The trade-off worth weighing early is Forge versus a self-managed Deployer setup: Forge trades a modest monthly cost for eliminated operational risk, while Deployer gives you full control at the cost of owning every configuration decision yourself. Neither is wrong. What matters is that whichever path you pick sits on top of the fundamentals covered here: PHP 8.4 on Laravel 13, a licensing-aware cache and queue layer, and a deploy script that migrates before it switches.

Which path are you running in production, Forge, Deployer, or Laravel Cloud, and what made you pick it over the alternatives? If you’ve hit a migration timing issue or a queue worker problem that isn’t covered here, I’d like to hear how you solved it.


Frequently Asked Questions

Do I need Laravel Forge to deploy a Laravel application to production?

No. Laravel Forge is a server management tool, not a deployment requirement. You can deploy Laravel to any VPS — DigitalOcean, Hetzner, AWS, Linode — by provisioning Nginx, PHP-FPM, and Supervisor manually. Forge is a time investment decision, not a technical one. If you’re managing more than one server or you don’t want to own Nginx configuration and SSL renewal as ongoing responsibilities, Forge pays for itself quickly. If you’re running a single server and you’re comfortable on the command line, a self-managed stack is entirely viable.

What PHP version does Laravel 12 require?

Laravel 12 requires PHP 8.2 as a minimum. PHP 8.3 is the recommended version for new production deployments — it includes JIT improvements and additional performance gains over 8.2. You should never deploy on a PHP version below the framework minimum, and you should avoid running a PHP version that has reached end-of-life, as it will no longer receive security patches.

Why does env() return null after I run php artisan config:cache?

Once the configuration cache is built, Laravel stops reading the .env file on each request and serves all values from the cached file instead. Any env() call made directly in application code — outside of a config/*.php file — will return null because the environment file is no longer loaded at runtime. The fix is to move all env() calls into their appropriate config files and reference them via config('your-key') throughout your application. This is the correct pattern regardless of caching — it’s just invisible in development because the cache isn’t active.

Should I run php artisan migrate automatically as part of my deployment script?

Yes, with --force to suppress the production confirmation prompt, and with one important precaution: run the migration before the new code goes live, not after. If your new code references a column that doesn’t exist yet because the migration ran after the symlink switch, you will have a window of errors in production. The correct sequence is migrate first, then deploy code. Additionally, always take a database snapshot before running migrations against a production database — automated or not.

How many Supervisor queue worker processes should I run?

Start with one worker per CPU core as a baseline. A 2-core server running a mixed workload typically handles 2–4 workers comfortably. Monitor your queue depth using php artisan queue:monitor or Laravel Pulse and scale up if jobs are backing up. Be aware that each worker is a persistent PHP process consuming memory — on a memory-constrained server, worker count is bounded by RAM as much as CPU. The --max-time=3600 flag is also important: it restarts workers hourly to prevent memory leaks from compounding in long-running processes.

What is the safest way to handle database schema changes without downtime?

The pattern is called expand/contract migrations. When adding a column, add it as nullable first (expand), deploy the code that writes to it, then in a subsequent deploy make it non-nullable if required (contract). When removing a column, stop writing to it in code first, deploy that code change, then drop the column in the next deploy. This ensures that at no point does your live code expect a schema state that the database hasn’t reached yet. It’s a discipline issue, not a technical limitation — most downtime during deploys traces back to migrations and code changes being coupled in the same release.

How do I make sure my scheduled tasks only run on one server in a multi-server setup?

Add ->onOneServer() to any scheduled task that must not run concurrently across multiple instances. Laravel acquires an atomic lock via your configured cache driver — Redis is the correct driver for this — to guarantee only one server executes the task for a given scheduled window. Every server still needs the cron entry (* * * * * php artisan schedule:run) configured; onOneServer() is the lock mechanism, not a cron replacement. Without it, every server in your cluster will run every scheduled command independently.

What is the /up health check endpoint in Laravel and should I use it?

The /up route was introduced in Laravel 11 as a built-in application health endpoint. It returns an HTTP 200 response when the application is running correctly and throws an exception — triggering a non-200 response — if something is critically wrong. You should use it. Point your load balancer, uptime monitor (Better Uptime, UptimeRobot), or container orchestration health check at /up rather than your homepage or a custom route. It is intentionally lightweight and does not require authentication. It also fires a DiagnosingHealth event, which you can hook into to run custom health checks such as verifying your database connection or Redis availability before reporting healthy.

Dewald Hugo

A software architect with 15+ years of experience in the PHP and Laravel ecosystem. Dewald created Origin Main to provide the engineering rigour required to integrate AI into professional, high-concurrency production systems. He writes for developers who care less about "getting it to work" and more about "getting it to last".

Subscribe
Notify of
0 Comments
Oldest
Newest Most Voted
Scroll to Top