2026-04-05 | PreviewProof Team

Preview-Ready Laravel: A Working Stack with MySQL and Redis Queues

laravelpreview environmentsmysqlredisqueue workers

Laravel previews live in a different ecosystem than Rails or Django previews. The queue workers look like Sidekiq but behave differently. The cache layer is more deeply tied to feature flags and configuration than most frameworks. Artisan-based migrations have their own preview-environment quirks. And teams running Laravel often also run Horizon, which adds a dashboard service that needs its own preview consideration.

This post covers the configuration that makes a Laravel app preview-ready. The reference stack is Laravel 11 (or 10 LTS) with MySQL 8, Redis 7, queue workers, and optional Horizon. The patterns translate to PostgreSQL with minor adjustments.

docker-compose.yml: local development

docker-compose.yml
services:
app:
build: .
command: php artisan serve --host=0.0.0.0 --port=8000
ports:
- "8000:8000"
environment:
DB_CONNECTION: mysql
DB_HOST: db
DB_DATABASE: app
DB_USERNAME: app
DB_PASSWORD: app
REDIS_HOST: redis
QUEUE_CONNECTION: redis
CACHE_STORE: redis
depends_on:
db: { condition: service_healthy }
redis: { condition: service_started }
queue:
build: .
command: php artisan queue:work redis --tries=3 --max-time=3600
environment:
DB_CONNECTION: mysql
DB_HOST: db
REDIS_HOST: redis
db:
image: mysql:8
environment:
MYSQL_DATABASE: app
MYSQL_USER: app
MYSQL_PASSWORD: app
MYSQL_ROOT_PASSWORD: root
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-proot"]
interval: 5s
timeout: 3s
retries: 10
redis:
image: redis:7-alpine

docker-compose.yml: preview-ready

Once the stack moves to ephemeral previews, the same docker-compose.yml shifts to use built images, env-var-injected hostnames, and preview-specific settings (notably the per-PR Redis prefixes that keep cache and session data isolated):

docker-compose.yml
services:
app:
image: ghcr.io/myorg/myapp:${GIT_SHA}
command: ./bin/preview-entrypoint.sh
environment:
APP_ENV: preview
APP_DEBUG: "false"
APP_KEY: ${APP_KEY}
APP_URL: https://${PREVIEW_HOST}
ASSET_URL: https://${PREVIEW_HOST}
DB_CONNECTION: mysql
DB_HOST: db
DB_DATABASE: app_preview
DB_USERNAME: app
DB_PASSWORD: app
REDIS_HOST: redis
REDIS_PREFIX: pr_${PR_NUMBER}_ # critical
QUEUE_CONNECTION: redis
CACHE_STORE: redis
CACHE_PREFIX: pr_${PR_NUMBER}_ # critical
SESSION_DRIVER: redis
MAIL_MAILER: log
TRUSTED_PROXIES: "*"
queue:
image: ghcr.io/myorg/myapp:${GIT_SHA}
command: php artisan queue:work redis --tries=3 --max-time=600 --queue=pr_${PR_NUMBER}
environment:
APP_ENV: preview
APP_KEY: ${APP_KEY}
DB_CONNECTION: mysql
DB_HOST: db
DB_DATABASE: app_preview
REDIS_HOST: redis
REDIS_PREFIX: pr_${PR_NUMBER}_
QUEUE_CONNECTION: redis

Two non-obvious settings carry their weight:

REDIS_PREFIX and CACHE_PREFIX. If two preview environments share a Redis instance — which is the cheapest pattern at scale — they will read each other’s cache and session data without isolation. Laravel makes prefix scoping easy: every cache and session key gets prefixed with the per-PR string. Previews don’t see each other’s data, even on the same Redis box.

TRUSTED_PROXIES: "*". Preview platforms typically terminate TLS upstream. Without trusted proxies set, Laravel’s App\Http\Middleware\TrustProxies won’t recognize the X-Forwarded-Proto header, and URL::current() returns http://... instead of https://.... Effects: broken redirects, broken secure cookie flags, broken OAuth callback construction.

The entrypoint

bin/preview-entrypoint.sh
#!/usr/bin/env bash
set -e
echo "==> Caching config and routes"
php artisan config:cache
php artisan route:cache
php artisan view:cache
echo "==> Migrating"
php artisan migrate --force
echo "==> Seeding"
php artisan db:seed --class=PreviewSeeder --force
echo "==> Booting"
exec php artisan serve --host=0.0.0.0 --port=8000

migrate --force is the right command — it suppresses the “Are you sure?” prompt that fails non-interactive environments. db:seed should target a PreviewSeeder rather than the default DatabaseSeeder, so the seeds you run in preview are explicitly different from the ones you might run elsewhere.

A working PreviewSeeder:

database/seeders/PreviewSeeder.php
<?php
namespace Database\Seeders;
use App\Models\User;
use App\Models\Project;
use Illuminate\Database\Seeder;
class PreviewSeeder extends Seeder
{
public function run(): void
{
$admin = User::firstOrCreate(
['email' => '[email protected]'],
['name' => 'Test Admin', 'password' => bcrypt('preview123')]
);
User::factory()->count(20)->create();
Project::factory()
->count(5)
->for($admin, 'owner')
->create();
}
}

firstOrCreate makes the seeder idempotent — safe to re-run if the entrypoint runs twice. The factory-generated data uses Laravel’s built-in factories with Faker; seed Faker deterministically by setting Faker\Factory::create('en_US')->seed(42) at the top of the seeder if you want stable data across boots.

Queue workers: scoping and concurrency

Laravel’s queue connection pulls jobs from a Redis list. If two previews share a Redis and both run queue:work, they’ll race for each other’s jobs. Three patterns to avoid this:

  1. Per-preview Redis container (cheapest, simplest). Done above.
  2. Per-preview queue name (works on shared Redis). --queue=pr_${PR_NUMBER} plus dispatching with ->onQueue("pr_{$pr}"). Requires application code to know about the per-PR queue, which is awkward.
  3. Per-preview Redis prefix (covered above). Combined with QUEUE_CONNECTION=redis, the queue’s keys get prefixed automatically; jobs are isolated without code changes.

Option 3 is what the Compose file uses. It’s the cleanest balance for Laravel specifically. See background jobs in ephemeral preview environments for the broader pattern.

Horizon

Horizon is Laravel’s queue dashboard. In preview environments, two things matter:

  • Don’t expose Horizon to the public preview URL without auth. The default Horizon dashboard route is open, and previews are often shared more freely than production.
  • Run Horizon as a separate service if you use it locally — php artisan horizon rather than queue:work. The Compose service is the same shape as queue: above with a different command.

For most preview environments, the simpler queue:work command is sufficient. Reserve Horizon for environments where the queue dashboard is something reviewers will actually look at.

Cache layer

Laravel’s config and route caches need to be rebuilt every preview boot — they bake in the APP_URL, which is per-PR. The entrypoint runs config:cache and route:cache before the app starts. Skipping this is one of the most common Laravel preview mistakes; the symptom is OAuth callbacks pointing at the wrong URL because the cached config still has whatever value APP_URL had at image build time.

If you use Laravel Pennant for feature flags, see feature flags in preview environments for the per-PR override pattern.

Env template

.env.preview.example
APP_ENV=preview
APP_KEY= # generated per preview
APP_URL= # injected: https://pr-123.previews.example.com
DB_CONNECTION=mysql
DB_HOST= # injected
DB_DATABASE= # injected
REDIS_HOST= # injected
REDIS_PREFIX= # injected: pr_123_
CACHE_PREFIX= # injected: pr_123_
MAIL_MAILER=log
QUEUE_CONNECTION=redis

See environment variables for ephemeral preview environments for the broader env-var injection patterns.

Laravel has meaningful presence in govtech and federal-adjacent work. If you’re shipping Laravel for those clients, the FedRAMP tool requirements for preview environments framing is worth pairing with the configuration above. The Compose setup itself is portable across preview platforms or your own infrastructure.

If you’d rather not wire up the per-PR orchestration yourself, PreviewProof handles Redis prefix injection and APP_URL rewriting for Compose-defined Laravel stacks, so the cache and queue isolation patterns work without additional CI scripting. The bigger reason to consider it is the verification layer — relevant for regulated and contractor work where a per-PR audit trail of who reviewed what is part of what gets shipped.