2026-04-05 | PreviewProof Team
Preview-Ready Laravel: A Working Stack with MySQL and Redis Queues
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
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-alpinedocker-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):
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: redisTwo 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
#!/usr/bin/env bashset -e
echo "==> Caching config and routes"php artisan config:cachephp artisan route:cachephp 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=8000migrate --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:
<?phpnamespace 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( ['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:
- Per-preview Redis container (cheapest, simplest). Done above.
- 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. - 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 horizonrather thanqueue:work. The Compose service is the same shape asqueue: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
APP_ENV=previewAPP_KEY= # generated per previewAPP_URL= # injected: https://pr-123.previews.example.comDB_CONNECTION=mysqlDB_HOST= # injectedDB_DATABASE= # injectedREDIS_HOST= # injectedREDIS_PREFIX= # injected: pr_123_CACHE_PREFIX= # injected: pr_123_MAIL_MAILER=logQUEUE_CONNECTION=redisSee 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.