all posts

How to deploy a Laravel app without Docker

Ajay Kumar··8 min read

Almost every Laravel deployment failure I have watched happens in the same eight places, and a Dockerfile fixes none of them. It installs a PHP version composer.json already declared, runs composer install against a lockfile you committed, and ends with a command to start a web server. It restates what the repo already states.

Here is the deploy without one: a git repo, a build command, a start command, and the eight decisions that decide whether a Laravel 11 or 12 app comes up healthy somewhere that isn't your laptop. Commands are PandaStack's; the decisions travel.

1. Pin PHP in the repo, and check the extensions first

The constraint in composer.json tells the resolver what is acceptable. It does not tell the build machine what to install. Pin an exact version in a file, so it resolves identically everywhere and a bump arrives as a pull request. The PandaStack base template is Ubuntu 24.04 with mise, which reads .tool-versions.

# .tool-versions — read by mise at build time
cat > .tool-versions <<'EOF'
php 8.3.14
nodejs 22.14.0
EOF

# Node, Python, Go and Bun are pre-warmed in the base image. PHP installs
# on demand from this pin: a minute or two on the first build, nothing after.
# nodejs is here because Vite builds your CSS and JS — drop it if you
# have no frontend build step.

Now the part that breaks the first build. A stock PHP is not the PHP your app expects: Laravel needs mbstring, tokenizer, xml and ctype; Postgres needs pdo_pgsql; Carbon localisation needs intl; money arithmetic needs bcmath. A missing extension rarely fails the build. It fails the first request that touches it, with a class-not-found error that reads like your own code is wrong.

# Locally: what does the dependency graph actually require?
composer check-platform-reqs

#   ext-intl    8.3.14   success
#   ext-bcmath  missing  requirement not satisfied

# Because a PandaStack app is a real VM with root, the fix is just apt.
# Put this at the front of the build command:
sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends \
  php8.3-pgsql php8.3-mbstring php8.3-intl php8.3-bcmath php8.3-gd php8.3-zip
This is the single most common first-build failure for Laravel on any platform, and thirty seconds of prevention avoids it. Run composer check-platform-reqs, write down every ext- line, make sure the build installs all of them. If your platform hands you a fixed image instead of a machine you can apt-install into, this is the moment you find out.

2. composer install --no-dev, and the trap inside it

composer install \
  --no-dev \
  --optimize-autoloader \
  --no-interaction \
  --prefer-dist \
  --no-progress

--optimize-autoloader dumps a classmap instead of walking PSR-4 paths on every autoload, a free latency win. --no-dev is the one that bites: anything you use at runtime that happens to sit in require-dev disappears. The classic case is fakerphp/faker, referenced by a seeder you run in production. The class-not-found arrives at boot, and the class exists fine on your machine.

Two rules either side of this. Commit composer.lock, or the build resolves versions fresh and Tuesday's dependency graph is not Wednesday's. And never commit bootstrap/cache: a stale packages.php in there will register a service provider from a package the build just declined to install, and the error looks nothing like the cause.

3. APP_KEY must exist, and must never change

APP_KEY is the symmetric key behind encrypted cookies, the session payload, signed URLs and encrypted model casts. Missing, it throws on the first request touching a cookie — loud and easy. Changing between deploys is the bad one. Someone puts php artisan key:generate in the build command, because the local setup guide says to. Every release then mints a new key, every user is silently signed out, and encrypted columns become unreadable.

# Generate ONCE, on your machine, and keep the output:
php artisan key:generate --show
# base64:kQ8pJ1nX2vLm4rT7yW0aZbC3dE6fG9hI2jK5lM8nO1s=

# Then set it as an app-level environment variable. Never in the build.
pandastack apps env set web APP_KEY='base64:kQ8pJ1nX...'
pandastack apps env set web APP_ENV=production
pandastack apps env set web APP_DEBUG=false
pandastack apps env set web LOG_CHANNEL=stderr
Do not run php artisan key:generate in a build or start command. It is a one-time local setup step; in a pipeline it regenerates the key on every release. This is Laravel's version of the Rails secret_key_base trap, with the same blast radius. Losing the key is not recoverable for anything already encrypted with it. And while you are setting env: APP_DEBUG=false in production, no exceptions — Laravel's debug page renders your environment variables, database credentials included, to anyone who can trigger a 500.

4. The caching commands, and what config:cache freezes

config:cache collapses every config file into one array, route:cache precompiles routes, view:cache pre-renders Blade; php artisan optimize runs the set. All worth it, and config:cache has a consequence people discover in production. Once a config cache exists, Laravel stops loading .env during the request lifecycle, so any env() call outside a config file returns null. Not an error — null. Your Stripe client initialises with a null key, your feature flag reads false, and it worked locally because there was no config cache.

// config/services.php — correct. env() runs here at cache time.
return [
    'stripe' => [
        'secret' => env('STRIPE_SECRET'),
    ],
];

// app/Http/Controllers/CheckoutController.php
// WRONG — null once config:cache has run
$stripe = new StripeClient(env('STRIPE_SECRET'));

// RIGHT — reads the cached config array
$stripe = new StripeClient(config('services.stripe.secret'));

Grep for env( outside config/ before your first optimize; every hit is a bug waiting on that command. route:cache has a narrower trap: it cannot serialise closures, so a route defined as an inline closure fails the cache outright.

5. Serving it: artisan serve is not a production server

php artisan serve wraps PHP's built-in development web server: no real concurrency, keep-alive or graceful restarts. Use it for demos, honestly labelled. For real traffic, FrankenPHP is the simplest good answer when you get one process and one port — a single binary embedding PHP in a Caddy server, optionally in Octane's worker mode so the framework boots once and stays resident. php-fpm behind nginx also works.

Either way: bind to 0.0.0.0, not 127.0.0.1, and read the injected PORT rather than hard-coding 8000. A loopback bind is the classic silent failure — the app starts, logs that it is listening, passes no health check, and is marked unhealthy with perfectly clean logs.

# Simplest production-grade option: FrankenPHP serving public/
frankenphp php-server --root public/ --listen "0.0.0.0:$PORT"

# Octane worker mode — big latency win, but your code must be free of
# state that leaks between requests (static props, singletons holding
# request data).
php artisan octane:start --server=frankenphp --host=0.0.0.0 --port="$PORT"

# Web server AND queue worker in one microVM, because it is a real VM
# and you get more than one process:
sh -c 'php artisan queue:work --tries=3 --max-time=3600 & \
  frankenphp php-server --root public/ --listen "0.0.0.0:$PORT"'

6. Migrations: --force, and build step versus release step

php artisan migrate asks for confirmation in production and nobody is there to give it, so the command is migrate --force. Where it runs matters more. Not the start command: every restart re-runs it, every replica races the others, and a failed migration turns a blocked release into a crash-loop.

Build step versus release step matters under blue-green, which is what PandaStack does: a fresh microVM builds and health-checks while the old one still serves. Migrate in the build and old code briefly runs against the new schema — fine for additive changes, fatal for destructive ones.

# Discrete step, with an atomic lock so concurrent deploys can't collide.
# The first process takes the lock, the others exit cleanly.
php artisan migrate --force --isolated

# See what would run, before it runs:
php artisan migrate:status | grep Pending
Under blue-green, old and new code are alive at the same time, so every migration must be compatible with the version currently in production, not just the one you are shipping. Add a column in one deploy and start reading it in the next. Stop writing a column in one deploy and drop it in the one after.

7. Queue workers and the scheduler

Laravel usually needs two extra long-running processes. Because a PandaStack app is a full Linux microVM, not a single-process container slot, you can run both alongside the web server.

  • queue:work runs jobs. Pass --max-time or --max-jobs so the process recycles; a long-lived PHP worker accumulates memory and eventually gets OOM-killed. Run queue:restart on deploy, or workers keep executing the previous release's code.
  • schedule:work replaces the crontab line: one long-running process instead of a system cron entry calling schedule:run every minute.
  • Exactly one scheduler runs across the deployment. Two instances means every nightly email goes out twice — discovered by a customer, not a monitor.
  • A queue worker and scale-to-zero do not mix: polling every second keeps the VM busy, so an app that would otherwise hibernate never does. Want the web tier to idle at zero? Run the worker as a separate app from the same repo.

8. Storage, sessions, and a disk that forgets

php artisan storage:link creates the public/storage symlink into storage/app/public; it has to run on the deployed machine, so put it in the build command. What you cannot forget is that the disk is per-deploy. A blue-green release provisions a new VM and everything written to the old one goes with it. Nothing errors — the rows survive, the files do not, and you find out when a customer asks where their invoice went. Use S3 before you accept a single upload.

Same logic, two more things. File-driven sessions live in storage/framework/sessions, so a deploy logs everyone out — use the database or a cache driver. And logs in storage/logs sit on a disk nobody reads; set LOG_CHANNEL=stderr.

Putting it together

Connect the GitHub repo, and the build and start commands are the ones you would have written into that Dockerfile anyway. What matters: PHP comes from the repo pin, the extensions get installed, and APP_KEY is an environment variable, not something generated per build.

pandastack apps create --name web \
  --git-url https://github.com/acme/invoices \
  --build-cmd 'sudo apt-get update -qq \
    && sudo apt-get install -y --no-install-recommends \
       php8.3-pgsql php8.3-mbstring php8.3-intl php8.3-bcmath \
    && composer install --no-dev --optimize-autoloader --no-interaction \
    && npm ci && npm run build \
    && php artisan storage:link \
    && php artisan optimize' \
  --start-cmd 'frankenphp php-server --root public/ --listen "0.0.0.0:$PORT"'

# Managed Postgres 16, attached by env var
pandastack db create --name invoices-db
pandastack apps env set web DB_CONNECTION=pgsql
pandastack apps env set web DATABASE_URL="$(pandastack db url invoices-db)"

# Migrations as their own step, before traffic moves
pandastack apps exec web -- php artisan migrate --force --isolated

# Queue worker as a second app from the same repo
pandastack apps create --name worker \
  --git-url https://github.com/acme/invoices \
  --start-cmd 'php artisan queue:work --tries=3 --max-time=3600'

A managed Postgres takes 30 to 90 seconds to create: a real PostgreSQL 16 instance in its own Firecracker microVM with a durable volume, not a schema in a shared cluster. After that it is just DATABASE_URL.

The deploy is blue-green throughout: fresh microVM, repo cloned at the exact commit, extensions installed, composer run, assets built, FrankenPHP started — and only when the health check passes does traffic flip and the old VM get torn down. A failed build leaves the old version serving. Push to the branch and it repeats; roll back with one command.

Before you send real traffic

  1. Run composer check-platform-reqs; confirm the build installs every ext- line it names.
  2. Curl the assigned URL from outside the VM, not localhost inside it.
  3. Log in, deploy again, check you are still logged in. If not, APP_KEY is regenerating or the session driver is file.
  4. Upload a file, deploy again, then go find it. Missing means the disk is still local.
  5. Deploy a broken migration to staging and confirm it blocks the release rather than crash-looping the app.

That is the whole thing: pin PHP and its extensions, install with --no-dev against a committed lockfile, set APP_KEY once, keep env() inside config files, serve on 0.0.0.0 and the injected port, migrate as a discrete step, keep uploads off local disk. No Dockerfile, no registry, no image build.

Write the Dockerfile when it earns its place — a system library nothing else provides, a byte-identical image across CI and production, an ops team already standardised on images. Not because a tutorial put one there.

Frequently asked questions

Can I deploy Laravel 11 or 12 without a Dockerfile?

Yes. A platform with PHP build detection reads composer.json and composer.lock, installs your dependencies, builds frontend assets, and runs whatever start command you give it. Pin the exact PHP version in .tool-versions so mise resolves the same interpreter everywhere, and make sure the build installs the extensions your dependency graph needs. A Dockerfile earns its place when you need a system library nothing else provides, when you want a byte-identical image in CI and production, or when your organisation already standardises on images. For a normal Laravel app talking to Postgres, the Dockerfile mostly restates facts your repository already declares.

Why does my Laravel app fail with a missing PHP extension after deploying?

Because a stock PHP install is not the PHP your app expects. Laravel itself needs mbstring, openssl, tokenizer, xml and ctype; a Postgres app needs pdo_pgsql; Carbon localisation and number formatting need intl; precise arithmetic needs bcmath; image handling wants gd. Missing extensions usually do not fail the build — they fail the first request that touches them, with a class-not-found error that looks like an application bug. Run composer check-platform-reqs locally, note every ext- requirement, and install all of them in your build command. On a platform that gives you a real VM with root you can apt-get install the distro packages directly.

What happens if APP_KEY changes between deploys?

Everything encrypted with the old key becomes unreadable. APP_KEY is the symmetric key behind encrypted cookies, the session payload, signed URLs and encrypted model casts, so a new key on every release silently signs out every logged-in user, invalidates outstanding signed URLs and password reset links, and makes encrypted database columns permanently undecryptable. The usual cause is php artisan key:generate sitting in a build or deploy command, copied from a local setup guide where running it once is correct. Generate the key once on your machine with key:generate --show, store it wherever you keep secrets, and set it as an app-level environment variable that never changes.

Why does env() return null in production after config:cache?

By design. Once a config cache file exists, Laravel skips loading the .env file during the request lifecycle entirely and reads the cached config array instead. Any env() call outside a config file therefore returns null — no error, no warning, just null, which quietly becomes an empty API key or a false feature flag. It works locally only because there is no config cache there. The fix is mechanical: env() belongs in config files, and application code reads config('services.stripe.secret'). Grep your app directory for env( outside config/ before you first run php artisan optimize, because every hit is a bug waiting on that command.

Should I use php artisan serve in production?

No. It wraps PHP's built-in development web server, which is not built for concurrent traffic, keep-alive connections or graceful restarts. It is fine for a demo or a throwaway preview environment as long as you know that is what you are choosing. For real traffic, FrankenPHP is the simplest good answer on a platform that gives you one process and one port: a single binary embedding PHP in a Caddy server, optionally running Laravel Octane's worker mode so the framework boots once and stays resident. The traditional php-fpm behind nginx or Caddy inside the same machine also works. Whichever you pick, bind to 0.0.0.0 and read the injected PORT variable rather than hard-coding 8000.

Where should a Laravel queue worker run?

It depends on whether you want the web tier to scale to zero. Because a microVM is a real Linux machine you can start queue:work alongside the web server in the same VM, which is the simplest deployment and fine for modest job volume. The cost is a shared memory budget and a shared failure domain, and a worker polling the queue every second keeps the VM permanently busy so an idle app never hibernates. Once jobs matter, deploy the same repository a second time with a queue:work start command so the two scale and fail independently. Always pass --max-time or --max-jobs so the worker recycles, and run queue:restart on deploy or workers keep executing the previous release's code.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.