A Laravel application on a shared host works right up until it needs a queue. Then jobs run in the request cycle, emails delay page loads, and a deploy kills whatever job was mid-flight. Production Laravel is a web tier, a worker tier, a cache and a scheduler, and this post follows a developer building exactly that on MassiveGRID PaaS, which is built on Virtuozzo Application Platform, formerly Jelastic.
The application is a booking platform for a chain of climbing gyms we will call Cragside: bookings, payments, confirmation emails, nightly reports. The Laravel package deploys a tuned PHP application server with Composer, a MariaDB database and a fresh Laravel project; the developer replaces the project with Cragside's repository and adds the missing tiers.
Tier one: the web layer, deployed from Git
The developer connects Cragside's repository to the PHP layer through the Deployment Manager with a post-deploy hook: composer install --no-dev --optimize-autoloader, then php artisan migrate --force, config:cache, route:cache, view:cache. The platform's PHP layer supports zero-downtime deployment, which deploys into a new release directory and switches a symlink atomically, so a visitor mid-checkout never sees a half-copied vendor directory. The layer runs two nodes behind an NGINX balancer and deploys sequentially. Environment variables (APP_KEY, database and Redis credentials, payment keys) live in the layer's environment variables in the dashboard, not in a committed .env.
Tier two: a Redis node for cache, sessions and queues
He adds a Redis node from the topology wizard and points CACHE_STORE, SESSION_DRIVER and QUEUE_CONNECTION at it over the internal network. Sessions in Redis are what make two web nodes interchangeable; cache in Redis is what makes them fast; and the queue in Redis is what lets the next tier exist. The Redis node is small, a few cloudlets under vertical scaling, and for Cragside's scale a single node is enough; a Redis Cluster is the upgrade path if it ever is not.
Tier three: workers, on their own layer
This is the change that matters. Queue workers should not share a container with the web server: a burst of emails should not compete with page requests for PHP-FPM processes, and a deploy of the web layer should not kill a job. The developer adds a second PHP layer to the environment, deployed from the same repository, whose only job is to run workers. Supervisor is available on the platform's PHP containers, so he adds a program that runs php artisan horizon, Laravel's Redis queue dashboard and supervisor, which manages the worker processes and balances them across queues.
[program:horizon]
command=php /var/www/webroot/ROOT/artisan horizon
autostart=true
autorestart=true
stopwaitsecs=3600
user=jelastic
redirect_stderr=true
stdout_logfile=/var/log/horizon.log
Horizon's own configuration sets the worker counts per queue: three processes for emails, two for payments, one for reports. The worker layer has its own cloudlet limit and scales vertically with the queue; at 23:00 when the nightly reports run, it takes more cloudlets for twenty minutes and releases them.
Deploying workers without losing jobs
When new code deploys to the worker layer, running workers must finish their current job and restart to load it. Laravel handles this with php artisan horizon:terminate, which tells Horizon to exit gracefully after in-flight jobs complete; Supervisor then restarts it on the new release. The developer adds that command as the worker layer's post-deploy hook. The stopwaitsecs above gives a long report job time to finish. Jobs that fail are retried per Laravel's configuration and land in the failed-jobs table, visible in Horizon's dashboard, which is exposed on the web layer behind Laravel's authentication gate.
| Layer | Runs | Deploy behaviour | Cloudlets (typical) |
|---|---|---|---|
| Web (2 nodes) | PHP-FPM, NGINX, Horizon dashboard | ZDT symlink switch, sequential | 6 to 12 each |
| Workers (1 node) | Supervisor, Horizon, queue processes | horizon:terminate, graceful restart | 4 to 16 |
| Redis | Cache, sessions, queues | Unaffected | 2 to 4 |
| MariaDB | Application data | Migrations from web deploy hook | 4 to 12 |
Tier four: the scheduler
Laravel's scheduler needs one cron entry, once, running php artisan schedule:run every minute; the scheduler decides what is due. The developer adds it to the platform's cron editor on the worker node, not the web nodes, so it runs exactly once and never on a node that scaling might add. Report generation, booking reminders and cache warming are all defined in the application's schedule and dispatched as jobs to Horizon.
What the platform contributed
Logging deserves a sentence. Laravel writes to the storage directory by default, which is per node and per release; the developer switches the log channel to the platform's node log directory, readable from the dashboard's log viewer, and ships Horizon's failed-job notifications to the team's chat. A failure in a queued job is visible within a minute rather than found in a file a week later.
Nothing here is Laravel-specific magic; it is Laravel's own recommended production layout. What the platform contributed was making each tier a separate container with its own scaling and billing, deploying from Git with atomic release switching, and keeping the tiers on one internal network without a single server to build. Cragside's whole production estate averages about 40 cloudlets an hour, roughly $97 a month before discounts at MassiveGRID's published $0.003372 per cloudlet-hour, and a confirmation email no longer waits for a page to render.
Frequently Asked Questions
Can I use the database queue driver instead of Redis?
Yes, and for very low volumes it is fine. Redis is faster, supports Horizon's dashboard and metrics, and keeps queue traffic off the database. Since a Redis node is a few cloudlets, most production Laravel apps use it.
How do I scale workers horizontally?
Add a horizontal scaling trigger to the worker layer on CPU. Each new node runs the same Supervisor configuration and Horizon instance, and Horizon instances coordinate through Redis. Keep the scheduler's cron on the master node only.
Should Horizon run on the web nodes to save a container?
It works, but a burst of jobs then competes with web requests and a web deploy interrupts jobs. A dedicated worker node costs a few dollars a month and removes both problems, which is why Laravel's own guidance separates them.
Web, workers, Redis and a scheduler, each on its own terms
The Laravel package on MassiveGRID PaaS gives you a tuned PHP server, Composer and a database in one click. Add a worker layer and Redis from the topology wizard, deploy from Git with zero downtime and pay per cloudlet-hour. Free 14-day trial.
Laravel on MassiveGRID PaaS