RE:NODE
ჰოსტინგი

ვებ ჰოსტინგი14 წუთის საკითხავი

Deploy Laravel to production: env, caches, queues and cron

What a Laravel app needs on a real server: the .env and app key, writable storage, config and route caches, migrations, queue workers and the scheduler.

ეს სტატია ჯერ ინგლისურადაა. ვთარგმნით.

0 მკითხველი

A Laravel deploy is six things in a fixed order: get the code on the server, install the production dependencies, put a real .env next to it with an application key, make storage and bootstrap/cache writable, build the caches, run the migrations. Everything that goes wrong afterwards is a variation on one of those six being skipped, done in the wrong order, or done on a machine with different paths from the one serving traffic. This guide is the whole sequence, the settings that decide whether the site is fast or merely working, and the errors that will otherwise cost you an evening.

It assumes a Linux server with nginx and PHP-FPM, which is what most shared and panel hosting is. The artisan commands are the same on any host, including one where you only have SFTP.

Before the code: PHP, extensions and the document root#

Laravel 10 needs PHP 8.1 or newer. Laravel 11 and 12 need PHP 8.2 or newer. If you are choosing now, take the newest PHP your dependencies support, because the jump from 7.4 to 8.x is the single largest free speed-up available to a PHP application, and OPcache in 8.x is better at keeping compiled code in memory.

The extensions Laravel documents as required are ctype, curl, dom, fileinfo, filter, hash, mbstring, openssl, pcre, pdo, session, tokenizer and xml. Almost every real application also wants intl for dates and number formatting, zip for archive handling, and gd or imagick if it touches images. Check what you have before you upload anything:

bash
$ php -v$ php -m$ php -i | grep -E "opcache.enable|memory_limit|upload_max_filesize"

The document root is the setting that catches everyone arriving from a CMS. It must point at the public/ directory inside your project, not at the project root. Everything above public/ - your .env, vendor, storage, app - is meant to be unreachable over HTTP. If the root is wrong you get one of two symptoms: a directory listing of your source code, or a page that loads but 404s on every route except /. Neither is subtle once you know to look for it.

If your host fixes the document root and will not move it, you have two honest options: put the contents of public/ in the served directory and edit index.php to point require at the real paths one level up, or pick a host where you control the root. The first works and is ugly; plenty of production sites run that way.

The .env file and the application key#

.env is not deployed with your code. It is created once on the server and never committed. The minimum for production:

.env
APP_NAME="Example"APP_ENV=productionAPP_KEY=base64:...APP_DEBUG=falseAPP_URL=https://example.comLOG_CHANNEL=stackLOG_LEVEL=warningDB_CONNECTION=pgsqlDB_HOST=db.example.netDB_PORT=5432DB_DATABASE=appDB_USERNAME=appDB_PASSWORD=...SESSION_DRIVER=databaseCACHE_STORE=databaseQUEUE_CONNECTION=database

APP_DEBUG=false is not optional. With it on, any uncaught exception renders a page containing your file paths, your query, and a good part of your environment. Set it, then confirm it by causing a deliberate 500 and checking you get the plain error page.

APP_KEY is a 32-byte key used for every encrypted cookie and every Crypt:: call. Generate it on the server once:

bash
$ php artisan key:generate --force

Changing it later invalidates all existing sessions and makes previously encrypted columns unreadable, so generate it before launch and then leave it alone. Back it up somewhere other than the server.

Laravel ships database drivers named mysql, mariadb, pgsql, sqlite and sqlsrv. Use whichever matches the database you actually have, and take the host, port, name, user and password from wherever your host generated them rather than guessing. On RE:NODE, web plans include database slots created from the panel with the host, user and password generated for you, and an "Open in phpMyAdmin" button that signs in with a token good for 60 seconds. The separate database line is PostgreSQL or MongoDB, reached on its own host and port.

The queue, cache and session drivers deserve a decision rather than a default. database for all three is the right starting point on a single small server: it needs nothing extra, survives restarts, and is easy to inspect. file sessions work but leave thousands of files in storage/framework/sessions, and a shared cache stops being shared the moment you run two processes. Note that Laravel 11 renamed the cache key from CACHE_DRIVER to CACHE_STORE; on 10 and earlier it is still CACHE_DRIVER, and setting the wrong one silently leaves you on the default.

Two directories have to be writable by the user PHP-FPM runs as, and only those two: storage and bootstrap/cache. Everything else can be read-only.

bash
$ chmod -R ug+rwX storage bootstrap/cache$ chown -R www-data:www-data storage bootstrap/cache

The user name varies by host. On a container it is often not www-data at all, and on shared hosting your SFTP user and the PHP user are usually the same, which makes this step a no-op. 777 is the answer people find on forums and it is the wrong one: it makes every uploaded file executable by anybody on the machine.

Files uploaded by the application go to storage/app/public by default, which is not inside the document root. The symlink is what exposes them:

bash
$ php artisan storage:link

That creates public/storage pointing at storage/app/public. It is a real symlink, so it does not survive a deploy that uploads a fresh public/ directory over SFTP, and some archive tools refuse to create it. If uploaded images 404 after a deploy and the files exist on disk, this is why. Re-run the command, or create the link by hand.

If your host does not allow symlinks at all, set FILESYSTEM_DISK=public and change the disk's root to a directory inside public/ in config/filesystems.php. It is not elegant, but it beats a broken media library.

Composer, assets and the caches that make it fast#

Install dependencies the production way:

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

--no-dev skips everything in require-dev. If the site breaks with a "Class not found" naming a debug or testing package, that package is used in production code and belongs in require, not require-dev. --optimize-autoloader builds a class map so PHP stops searching the filesystem on every autoload, which is worth a measurable slice of your response time on a large application.

Front-end assets are built with Vite, and the output lands in public/build. On a 1 GB plan, npm run build on a big project is a plausible way to hit the memory limit and have the container stopped, so building locally or in CI and uploading public/build is both faster and safer. The server does not need Node at all if you do that.

Then the caches. Each one turns a pile of file reads and parsing into a single included PHP array:

CommandWhat it cachesSafe to run
config:cacheEvery file in config/ into one arrayAlways, in production
route:cacheAll route definitionsUnless routes use closures
view:cacheBlade templates compiled ahead of timeAlways
event:cacheEvent and listener discoveryAlways
optimizeRuns the set above, version dependingAlways, in production
bash
$ php artisan optimize

In recent versions php artisan optimize runs the config, event, route and view caches together; in older ones it did less. Run php artisan optimize --help on your version rather than assuming. The reverse, for when you need to rebuild, is php artisan optimize:clear.

The consequence people get bitten by: once the config is cached, `env()` returns null everywhere except inside `config/` files. The cached array is built from the config files, with env() already resolved. If your application calls env('STRIPE_KEY') in a controller, it works locally and returns null in production. The fix is to add the value to a config file and call config('services.stripe.key') instead. This is one of the top two causes of "it works on my machine".

Route caching fails loudly if any route uses a closure instead of a controller. The error names the file. Move the closure into a controller, or skip route:cache and accept the cost.

Migrations without breaking the site#

In production, migrations must not prompt:

bash
$ php artisan migrate --force

Without --force, artisan asks for confirmation and, in a non-interactive deploy script, either hangs or aborts. --force only means "do not ask"; it does not skip anything.

The order matters. A deploy that ships new code before the column it needs exists gives every visitor a 500 for the length of the gap. Two patterns avoid it:

  1. Maintenance mode, for small sites where a minute of downtime is acceptable. php artisan down --secret=some-long-string puts up the 503 page for everyone but lets you through at https://example.com/some-long-string. Deploy, migrate, then php artisan up.
  2. Backwards-compatible migrations, for everything else. Add the column and deploy code that tolerates it being null, backfill, then deploy code that requires it, then drop the old column in a later release. It is three deploys instead of one and nobody sees an error. Migrations without downtime goes through the pattern properly.

Take a database backup before every migration that drops or renames anything. php artisan migrate:rollback only works if the migration has a working down() method, and a surprising number do not. On RE:NODE the Backups tab takes one on demand or on a schedule and restores it with a button, and backups are stored off the machine they protect - which is the only kind that helps when the machine is the problem. Database backups and restores has the rest.

Queues and the scheduler#

Anything slow in a web request - sending mail, resizing an image, calling a third-party API - belongs on a queue. A queue needs a worker process that outlives the request:

bash
$ php artisan queue:work --queue=high,default --tries=3 --max-time=3600 --sleep=3

queue:work is a long-running PHP process, so it holds your code in memory: after a deploy it keeps running the old code until you tell it otherwise. php artisan queue:restart signals every worker to finish its current job and exit, and your process manager starts them again with the new code. Put that line in your deploy script and never think about it again.

--max-time=3600 makes the worker exit after an hour regardless, which is the cheapest defence against a slow memory leak in a package you do not control. --tries=3 stops a permanently failing job from being retried forever; failed jobs land in the failed_jobs table, and php artisan queue:retry all puts them back. Use queue:listen in development only: it reboots the framework for every job, which is convenient and several times slower.

The scheduler is the other process. Laravel's scheduler is one cron entry that runs every minute and decides for itself what is due:

bash
* * * * * cd /var/www/example && php artisan schedule:run >> /dev/null 2>&1

On a container with no crontab, php artisan schedule:work does the same thing as a foreground process you keep running. That is usually the better fit for panel hosting, where the thing that stays alive is the server process itself. Do not schedule the same job from two machines: Laravel has withoutOverlapping() and onOneServer() for exactly that, and the second needs a shared cache store.

On RE:NODE the Schedules tab takes a cron expression and runs ordered tasks against a server - a console command, a backup, a power action - which makes it the right place for the nightly backup and a weekly restart. Cron expressions explained covers the five fields if the syntax is new.

HTTPS, proxies and URL generation#

Almost every Laravel app in production sits behind something that terminates TLS: a reverse proxy, a load balancer, a CDN. That machine speaks HTTPS to the visitor and plain HTTP to your application, which leads to two predictable bugs.

The first is mixed content. Your app sees an HTTP request, so url(), asset() and route() generate http:// links, and the browser blocks them on an HTTPS page. The second is that every visitor appears to come from the proxy's address, which quietly breaks rate limiting, logging and anything geographic.

Both are fixed by trusting the proxy so Laravel reads X-Forwarded-Proto and X-Forwarded-For. On Laravel 11 and 12 that is in bootstrap/app.php:

bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {    $middleware->trustProxies(at: '*');})

On Laravel 10 and earlier it is $proxies = '*'; in app/Http/Middleware/TrustProxies.php. Trusting * is correct when the only route to your application is through the proxy, and wrong when your origin port is reachable from the internet, because then anyone can forge the header. Setting APP_URL=https://example.com covers URL generation in console commands and queued jobs, which have no request to read the scheme from.

RE:NODE's app and web plans include a proxy slot: point an A record at the address shown and the certificate is issued and renewed for you, inside a 21-day window, with the real client address arriving in X-Forwarded-For. What a reverse proxy does explains the request path, and HTTPS and Let's Encrypt explained covers why issuance fails when DNS is not ready.

A repeatable deploy#

Write the sequence down once and run the same thing every time. Manual deploys drift, and the drift is what breaks at midnight.

bash
$ php artisan down --secret="$(cat /var/www/deploy-secret)"$ git pull --ff-only origin main$ composer install --no-dev --optimize-autoloader --no-interaction$ php artisan migrate --force$ php artisan optimize$ php artisan queue:restart$ php artisan up

Where you have no shell, the same steps happen through SFTP and whatever console your host gives you, in the same order. One caution: build the caches on the machine that serves them. bootstrap/cache/packages.php and the compiled views contain absolute paths, so caches generated on your laptop and uploaded can point at directories that do not exist on the server.

Keep the last working release. The fastest rollback is the previous directory plus a migrate:rollback you have actually tested, and the second fastest is a restore from the backup you took five minutes ago. Zero-downtime deploys on a small server covers the symlinked-release pattern when you want the deploy itself to be invisible.

Troubleshooting#

A blank page or a bare 500 with no detail. That is APP_DEBUG=false doing its job. The detail is in storage/logs/laravel.log. If that file is empty, PHP could not write it: check the permissions above and the PHP-FPM error log.

"No application encryption key has been specified". APP_KEY is missing or the config cache was built before it existed. Run php artisan key:generate --force, then php artisan config:cache again.

Everything 404s except the home page. The document root is not public/, or nginx has no try_files $uri $uri/ /index.php?$query_string; line in the location block.

419 Page Expired on every form. The session cookie is not coming back. Usual causes: SESSION_DOMAIN set to the wrong host, SESSION_SECURE_COOKIE=true while the app still thinks it is on HTTP, or a cached config from a different environment.

A setting change does nothing. The config cache is stale. php artisan optimize:clear, then rebuild. This also applies to a .env edit: with the config cached, .env is not read on a request at all.

Uploaded images 404 but exist on disk. The public/storage symlink is gone. Re-run php artisan storage:link.

The queue processes nothing after a deploy. The worker died and nothing restarted it, or it is still holding the old code. Check the process is alive, then php artisan queue:restart.

The site gets slower under load, then restarts. You are at the memory limit. On RE:NODE the container is stopped at the limit and restarted clean rather than left to swap, which is faster to recover from but does mean in-flight requests are lost. Count your PHP-FPM workers times their real memory use before blaming the framework, and read sizing a web app for launch day.

FAQ#

Do I need Composer installed on the server?

Not strictly. You can run composer install --no-dev --optimize-autoloader locally or in CI and upload the vendor directory, as long as the PHP version you built against matches the one on the server. Running it on the server is simpler and avoids that mismatch.

Why does env() return null in production?

Because the config is cached. php artisan config:cache resolves every env() call inside config/ once and writes the result, and env() outside those files then reads an environment that is no longer populated. Move the value into a config file and read it with config().

How much memory does a Laravel site need?

A small application is comfortable in 1 GB, including PHP-FPM workers and a database on the same box. Add memory when you add concurrency rather than features: each PHP-FPM worker holds its own copy of your application, so four workers at 80 MB is 320 MB before anything else.

Can I run the scheduler without cron?

Yes. php artisan schedule:work runs in the foreground and fires due tasks every minute, which is the practical answer on hosting where you cannot edit a crontab. It has to be kept running like any other long-lived process.

Should the queue worker and the web server share a plan?

For a small site, yes - a worker at idle costs almost nothing. Split them when a job is heavy enough to starve web requests of CPU, because a queue burst and a traffic spike arriving together is how a one-container setup falls over.

What do I do about the .env when I change hosts?

Copy it by hand, not with the code. Change the database credentials to the new ones, keep APP_KEY exactly as it was so sessions and encrypted data survive, then clear and rebuild the caches on the new machine.


კომენტარები

სრულიად ანონიმურად: ანგარიშის, ელფოსტის და cookie-ის გარეშე. ინახება მხოლოდ სახელი, ტექსტი და დრო - სხვა არაფერი. ბმულების რაოდენობა ლიმიტირებულია.

0/2000