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,FileinfoFilter,Hash,Mbstring,OpenSSLPCRE,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
Recommended stack for 2026:
| Component | Recommendation | Notes |
|---|---|---|
| OS | Ubuntu 26.04 LTS | Standard support until 2031; Ubuntu 24.04 LTS remains a safe fallback |
| PHP | 8.4 | Active support; PHP 8.5 is also viable for new projects |
| Web Server | Nginx | Or FrankenPHP for Octane |
| Database | MySQL 8.0+ / PostgreSQL 16 or 17 | |
| Cache / Queue | Valkey (Redis protocol-compatible) | See the licensing note below |
| Process Supervisor | Supervisor 4 | For queue workers |
| Deployment | Laravel 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 thephpredisorpredisclient 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.
Option A: Laravel Forge (Recommended for Most Teams)
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:cachein an environment where you have calledenv()directly in your application code outside of a config file. Once the config is cached,env()calls returnnull, by design, because the config cache is the only authorised source of environment values once it exists. Allenv()calls belong inconfig/*.phpfiles. If you have legacy code withenv()scattered through service classes or controllers, your cached deployment will silently misbehave on the values that depend on it. Audit before caching.
Storage Link
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:
- Take a database snapshot. Most managed databases (RDS, PlanetScale, Supabase) offer point-in-time recovery. On a self-managed instance, run
mysqldumpbefore every migration. - Review the migration for destructive operations (
dropColumn,dropTable,changeon an indexed column). These can lock tables and cause downtime under load. - 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 TABLEoperations are instant forADD COLUMNwith 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, usept-online-schema-changeor 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
ShouldBeUniqueandShouldBeUniqueUntilProcessingcontracts 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, implementShouldBeUniquewith a customuniqueId()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
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
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.
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".
