Skip to content

Discussion: multi-process apps and the single-start-command model (Laravel as the case study) #231

Description

@sudanese

Context

Opening this as a discussion rather than a bug report, because the interesting question underneath it seems to be about the app model generally rather than about Laravel specifically.

I traced how PHP stacks are built and run today to see where Laravel actually stands. Verified against 210d5eb.

The laravel and symfony stack entries are identical in every field except name and detection (packages/core/src/stacks.ts:678 and :692) — same category, outputDirectory, defaultPort, defaultBuildCommand, and byte-for-byte the same defaultStartCommand. Laravel support today is the generic PHP recipe plus a display name and an icon.

That recipe runs a single web role — php-fpm and nginx in one container — which is right for a plain PHP app. Laravel isn't a plain PHP app: a production deployment is a web role, a queue worker, a scheduler, and a release step. Three consequences, each checkable in a minute:

1. A deploy discards everything the app has written.
StackDefinition (stacks.ts:162) has no volume or persistent-path concept — grep -ci volume packages/core/src/stacks.ts returns 0, and the app deploy path creates containers with no volume mounts at all.

This is worse than it first looks, because of what current Laravel defaults to. The shipped .env.example on 13.x is DB_CONNECTION=sqlite, SESSION_DRIVER=database, CACHE_STORE=database, QUEUE_CONNECTION=database. So for an app deployed on stock defaults, the entire application database — rows, sessions, cache, queued jobs — is a container-local file at database/database.sqlite, and it's gone on the next deploy. Uploads, compiled views, and logs under storage/ go with it.

The reason this is quiet rather than loud is that pdo_sqlite is compiled into the official php:* base images, so the app boots, serves traffic, and looks healthy.

Worth noting a storage/ volume alone wouldn't fix this — database/ is a sibling of storage/, not inside it.

The primitive does already exist one layer over: the service catalog has a defaultVolumes field used by 20 entries — e.g. local-service-catalog.ts:328 gives MinIO defaultVolumes: ["minio_data:/data"], and the services deploy path does mount volumes. Services can declare default volumes; app stacks can't. So possibly less "add a new concept" than "is the asymmetry deliberate" — which is part of what I'm asking.

2. pdo_mysql is the only driver the recipe installs, so PostgreSQL apps can't connect.
packages/adapters/src/runtime/docker-build-plan.ts:144 and :151 hardcode docker-php-ext-install pdo_mysql. The comment at :142 is upfront that this is the common denominator and that app-specific extensions are a follow-on, so this looks like a known edge rather than an oversight — flagging it mainly because it's what makes the sqlite default above silently "work". Also absent: pcntl (Horizon requires it), redis, bcmath, intl, zip, gd, opcache.

Minor related note: the php language entry pins php:8.3-cli / php:8.3-fpm (stacks.ts:82-83). Laravel 13's minimum is PHP 8.3, so that satisfies it exactly — but with no headroom for an app or dependency wanting 8.4+.

3. No queue worker, no scheduler, no release phase.
queue:work, schedule:run, schedule:work, and migrate --force have zero occurrences anywhere in the repo. A deployed Laravel app therefore doesn't process queued jobs, doesn't run scheduled tasks, and doesn't migrate — all silently rather than erroring, which is the part that makes them hard to notice. Assets aren't built either (no Node stage in the PHP branch), so an app using Vite ships without compiled CSS/JS.

The part I'd like to discuss

These don't feel like three independent bugs. StackDefinition allows exactly one defaultStartCommand, so a stack can't express "one image, several runtime roles" even if the extensions and the volume existed. Laravel is the clearest case, but the shape recurs — Rails (Sidekiq or Solid Queue, plus cron), Django (Celery worker plus beat), and anything else where the web process isn't the whole app.

So the question:

Is the single-process app model a deliberate boundary — with multi-process apps expected to use --type services and bring their own Compose file — or is "a stack declares multiple runtime roles" something you'd want in the app model eventually?

Three shapes seem defensible, and I genuinely don't know which fits openship best:

  • Roles in the stack definition. openship detects Laravel and the worker and scheduler simply exist — consistent with how the rest of stack detection behaves, and the zero-config promise holds. Cost: a real change to the runtime model, and it would need to compose with any multi-node direction.
  • A process supervisor inside one image. One container per app, with a supervisor running nginx, php-fpm, the worker(s) and the scheduler side by side. The appeal is that the deployment model doesn't change at all — openship still builds one image and runs one container, and everything above stays a build-time concern. Cost: roles can't be scaled or restarted independently, and a crashed worker isn't visible from outside the container unless the supervisor surfaces it. Worth saying this is a proven shape rather than a hypothetical — it's the arrangement Coolify recommends for Laravel, via a repo-level config that layers Supervisor into the container.
  • Keep apps single-process, push the rest to --type services. Simplest model, clearest boundary. Cost: the auto-detected Laravel path stays quietly incomplete for anything using queues or scheduling, which is most production apps — and since detection succeeding reads as "this is supported", users probably won't realise.

If it's the third, that's a completely reasonable answer and largely a documentation problem — worth saying plainly that queues and scheduling need the services path.

The runtime base matters for all three

Something that cuts across whichever shape you'd pick, and which I think is the highest-leverage change independent of the model question: the generated Dockerfile builds on the official php:*-fpm image and adds nginx via apt. That image is a fine starting point but it isn't a production process host, and two gaps bite specifically once anything other than a web process exists:

  • Signal handling. A queue worker needs SIGTERM to mean "finish the current job, then exit". Under a naive php-fpm -D && nginx launch there's no supervision tree propagating signals, so a container stop can kill a worker mid-job. For a payment or email job that's a real correctness problem, not a tidiness one — and it's invisible until it happens under load.
  • Non-root execution. The generated image runs as root, which is worth avoiding regardless of the rest.

Purpose-built production PHP base images exist that solve both — serversideup/php is the widely used one (MIT, vendor-neutral): non-root by default, S6 process supervision with correct signal propagation, native healthchecks, env-var configuration, and separate fpm-nginx and cli variants that map cleanly onto the web / worker / scheduler split. It also handles migrations at container boot via AUTORUN_ENABLED with AUTORUN_LARAVEL_MIGRATION_ISOLATION, which is replica-isolation-aware by construction — a stronger guarantee than a release-script migrate --force gives you.

Mentioning it because the S6 supervision is what makes the supervisor-in-one-image option cheap rather than something you'd hand-roll, and the signal handling is what makes the multi-role option safe. So the base-image choice isn't independent of the design question — it partly determines which shapes are easy.

One related question, since it shapes any answer: #163 asks how volumes behave once containers can reschedule across nodes. If there's already a view on that, it probably constrains whether a stack-declared volume is the right primitive at all.

On storage, since it isn't one thing

Noting this in case it's useful for #188 too — storage/ is several kinds of data with different correct answers, and only some can live in object storage:

Path What it is Can it go to S3? Container-appropriate answer
database/database.sqlite the whole DB on stock Laravel 11+ defaults — incl. sessions, cache, jobs ❌ no a real database service
storage/app/, storage/app/public/ user uploads ✅ yes S3 — effectively required above 1 replica
storage/framework/cache/, sessions/ only when drivers are set to file ❌ no Redis or database
storage/framework/views/ compiled Blade templates ❌ never — no driver exists ephemeral; recompiled on demand, optionally pre-warmed with view:cache
storage/logs/ application logs ❌ no stderr
bootstrap/cache/ where optimize writes its caches ❌ no writable at runtime — the deployment docs call out bootstrap/cache alongside storage as directories the app must be able to write

So a well-configured containerised Laravel app needs very little persistent local storage — real DB service, uploads to S3, cache and sessions to Redis, logs to stderr, compiled views disposable. But a stock app isn't configured that way, and a stock app is what openship gets when someone points it at a Laravel repo and presses deploy.

Which suggests both paths matter rather than one replacing the other: something local as the zero-config correctness floor, and object storage as the path the platform steers people toward as they scale. They're also coupled — a local volume with more than one web replica gives split-brain uploads, invisible intermittently across containers and presenting as random 404s.

Encouragingly most of the machinery exists: packages/adapters/src/backup/destinations/s3.ts is a complete S3-compatible client (AWS, R2, Wasabi, B2, MinIO, Spaces, Ceph, Storj, path-style addressing, multipart), and MinIO is in the service catalog. What's missing is wiring a provisioned bucket to an app's filesystem config — #188's territory rather than something to duplicate here.

If the direction is "yes, eventually"

Sketching roughly what it would involve, only to make the discussion concrete — not a plan I'm proposing to execute:

  1. Laravel-appropriate PHP extensions, a Node build stage when package.json has a build script, and some answer for persistence.
  2. A release phase — commands running once per deploy, after build and before cutover, failing the deploy if they fail. Generic across stacks; for Laravel on 13.x that's migrate --force, optimize (the single command that caches config, events, routes and views), storage:link, and reload — the last being 13's umbrella for cycling long-running services, superseding queue:restart for deployment purposes since it also covers Reverb and Octane. (Or fewer of these, if the base image handles migrations at boot as noted above.)
  3. Whichever multi-role shape follows from the question above.
  4. Laravel ships a /up health route out of the box (configurable in bootstrap/app.php), so a default healthcheck is close to free for any app on a current skeleton.

Related

Mostly I'd like to know whether multi-process app support is on the roadmap or deliberately out of scope, since that determines whether the rest is worth discussing further. Happy to be told the services path is the intended answer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions