Back to Blog
Tutorial7 min read2026-07-12

How to Deploy Laravel with a Managed Database on PandaStack

A practical Laravel deploy guide: the config:cache trap in containers, a FrankenPHP Dockerfile, DB_URL vs DATABASE_URL, and safe production migrations.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Laravel deploys are mostly straightforward, but the framework has three traps that specifically punish container deployments: caching config at the wrong time, using php artisan serve as a production server, and the quiet rename of DATABASE_URL to DB_URL in newer versions. This guide walks the whole path — build, server, database, migrations — and calls out each trap where it lives.

The production build, in order

Three things happen at build time: PHP dependencies, frontend assets, and autoloader optimization.

# 1. PHP dependencies, production only, optimized autoloader
composer install --no-dev --optimize-autoloader --no-interaction

# 2. Frontend assets (Vite is the Laravel default; outputs to public/build)
npm ci
npm run build

# 3. Verify nothing depends on dev packages
php artisan about

--no-dev matters more than it looks: packages like barryvdh/laravel-debugbar or laravel/telescope in the wrong dependency section will either bloat the image or break the boot when they're absent. npm run build runs Vite and writes hashed assets plus a manifest into public/build/ — that directory must exist in the final image or every page renders unstyled.

The config:cache trap

Laravel's optimization commands — config:cache, route:cache, view:cache — are close to mandatory for production performance. But there's a rule that bites almost everyone once: after config:cache runs, env() returns null everywhere except inside config/ files. The cache stores the *resolved* values.

In a container that means: if you run php artisan config:cache during the Docker build, it resolves environment variables *as they exist at build time* — which is to say, not at all. Your runtime DB_URL, APP_KEY, and mail credentials get baked in as nulls, and you spend an evening wondering why the app can't see variables that are plainly set.

The fix is to cache at container start, not image build. Put the cache commands in an entrypoint:

#!/bin/sh
# docker/entrypoint.sh
set -e
php artisan config:cache
php artisan route:cache
php artisan view:cache
exec "$@"

Now the caches are built with the real runtime environment, and you still get cached-config performance for every request after boot.

A Dockerfile with FrankenPHP

php artisan serve is a development server — single-process, no opcache tuning, not meant for traffic. The classic production answer is nginx + PHP-FPM, which works but means two processes and a fiddly config. [FrankenPHP](https://frankenphp.dev/docs/laravel/) collapses that into one binary: a Caddy-based server with PHP embedded, and its official image serves whatever sits in /app/public — exactly Laravel's layout.

# Stage 1: build frontend assets
FROM node:20-slim AS assets
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: the app
FROM dunglas/frankenphp
RUN install-php-extensions pdo_pgsql pdo_mysql opcache pcntl
ENV SERVER_NAME=:8080
WORKDIR /app
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY . /app
COPY --from=assets /app/public/build /app/public/build
RUN composer install --no-dev --optimize-autoloader --no-interaction \
 && chown -R www-data:www-data storage bootstrap/cache
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 8080
ENTRYPOINT ["/entrypoint.sh"]
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]

Two details worth flagging: SERVER_NAME=:8080 tells Caddy to listen plain-HTTP on 8080 — correct behind a platform that terminates TLS for you — and storage/ plus bootstrap/cache/ must be writable or Laravel fails on the first log write with a permission error that doesn't mention permissions clearly.

Wiring the database: DB_URL vs DATABASE_URL

Laravel's config/database.php supports a single connection URL, but the environment variable it reads changed between versions: configs generated by Laravel 10 and earlier read DATABASE_URL, while Laravel 11+ reads DB_URL. Managed platforms — PandaStack included — inject DATABASE_URL, so make your config accept either:

// config/database.php
'pgsql' => [
    'driver' => 'pgsql',
    'url' => env('DB_URL', env('DATABASE_URL')),
    // discrete DB_HOST/DB_PORT/etc. keys remain as fallback
],

Then set the connection driver to match your database engine:

DB_CONNECTION=pgsql   # or mysql

When the URL is present, Laravel parses host, port, user, password, and database out of it and ignores the discrete keys. One connection string, no credential copying.

Migrations

Production migrations need the --force flag — without it, artisan prompts for confirmation and a non-interactive deploy hangs:

php artisan migrate --force

If you run more than one container replica and migrations execute at boot, add --isolated (Laravel 9.38+), which takes an atomic lock so only one instance runs the migration while the others skip past. Note that --isolated requires a cache driver that supports locks — Redis or database, not file. The cleaner pattern as you grow: run migrations once as an explicit deploy step, then start the app containers.

Environment variables that matter

APP_ENV=production
APP_DEBUG=false          # never true in production — it leaks secrets in error pages
APP_KEY=base64:...       # generate with: php artisan key:generate --show
APP_URL=https://yourapp.example.com
LOG_CHANNEL=stderr       # containers log to stdout/stderr, not storage/logs
DB_CONNECTION=pgsql
SESSION_DRIVER=database  # file sessions die with the container

Two of these deserve emphasis. APP_DEBUG=false is a security control, not a preference — debug pages print environment variables. And SESSION_DRIVER: the default file driver stores sessions on the container's disk, so every restart (or every request that lands on a different replica) logs users out. Use database (run php artisan session:table and migrate) or Redis.

If you serve user uploads from the public disk, remember php artisan storage:link — and remember that container filesystems are ephemeral, so anything users upload should go to object storage, not local disk.

Deploying on PandaStack

With the Dockerfile in place, the platform side is short:

  1. 1Create the database. Provision managed PostgreSQL or MySQL from the dashboard — both engines are supported (MySQL 5.7 and 8.x, PostgreSQL 14.x and 16.x), with daily scheduled backups retained per plan.
  2. 2Connect the repo as a container app. PandaStack detects the Dockerfile and builds it with rootless BuildKit in an ephemeral Kubernetes job pod — no host Docker socket involved.
  3. 3Attach the database to the app. DATABASE_URL is injected automatically; with the env('DB_URL', env('DATABASE_URL')) mapping above, Laravel connects without any credential copy-paste.
  4. 4Set the remaining variablesAPP_KEY, APP_ENV, APP_DEBUG, LOG_CHANNEL, SESSION_DRIVER — in the app's environment settings.
  5. 5Push. Each git push builds and deploys, with build logs streaming live, so a failed composer install or Vite build shows up while it's happening.

On the free tier ($0/mo: 5 web services, 1 database, 100 GB bandwidth) idle apps scale to zero — fine for a staging Laravel app, but expect a cold start on the first request after a quiet period. Pro at $15/mo removes that trade-off for production traffic.

The checklist

Build assets and vendor at image time, cache config at boot time, serve with a real server on the port the platform expects, accept DATABASE_URL, migrate with --force, and keep sessions out of the filesystem. That's the entire distance between laravel new and production. If you want to walk it with the database pre-wired, start at [pandastack.io](https://pandastack.io).

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also