Skip to content

Laravel role selection can choose a horizon worker that a --no-dev build does not install #1171

Open

Description

@sudanese

Summary

OpenShip's Laravel presets select the horizon worker role whenever laravel/horizon appears in composer.json. Selection reads require and require-dev merged. The default PHP install is composer install --no-dev, which omits require-dev. So a project with Horizon only in require-dev selects a horizon worker whose command the built image does not contain.

At 75df170f no deployment code consumes resolveStackRoles, so nothing spawns a worker from it yet. A future deployment caller that passes the merged dependency set unchanged would select horizon here.

The mismatch (source at 75df170f)

Selection merges require + require-dev. packages/core/src/languages/php.ts returns { ...require, ...require-dev }. That merged set is what resolveStackRoles gates on. At 75df170f only the resolver's own tests pass it that set. The horizon preset matches when: { deps: ["laravel/horizon"] }. The queue preset steps aside on unless: { deps: ["laravel/horizon"] }. A dev-only Horizon selects horizon and drops queue.

The default PHP install is --no-dev. getInstallCommand("composer") returns composer install --no-dev --optimize-autoloader. That install omits require-dev, so a dev-only laravel/horizon is not in the image.

The preset comment already names the gap (packages/core/src/stacks.ts):

deps merges require and require-dev, so a dev-only laravel/horizon still selects horizon. Callers that need production-only matching must read require from composer.json.

No caller reads require-only yet.

What I reproduced

A minimal Laravel 13 app with laravel/horizon. I moved it from require to require-dev, regenerated the lockfile, then ran the default install composer install --no-dev --optimize-autoloader. vendor/laravel/horizon was then absent. Host PHP 8.5, composer 2.10.2. This exercises composer on the host, not a full Docker build.

With Horizon in require-dev and horizon:install not run, php artisan horizon exits 1 with Command "horizon" is not defined. php artisan queue:work --help exits 0. So the command the horizon preset selects is unavailable after this host install, while the queue preset's command is available. A deployment that started the selected command unchanged would run an undefined command, and would not run a queue worker, because the unless gate dropped queue. This could cause repeated worker failures and leave jobs unprocessed. I did not run a deployed worker to measure that.

(Separately: if horizon:install was run while Horizon sits in require-dev, php artisan package:discover exits 1 with Class "Laravel\Horizon\HorizonApplicationServiceProvider" not found — the registered provider's parent class is absent under --no-dev. A build step that runs discovery would fail there. This is a general Laravel --no-dev + dev-only-Horizon interaction. Role-selection gating does not fix it. I mention it only so the two failure shapes are not confused.)

Proposed direction

Gate worker-role selection on the production dependency set (require), as the preset comment prescribes. This affects the dev-only-Horizon case under the default presets and the default install. Explicit openship.json roles already bypass preset selection and are unaffected.

Two shapes, your call:

  1. Caller-side, no resolver change. resolveStackRoles uses deps only for gating. The future deployment caller can pass the require-only names as deps, keeping the merged set for detection elsewhere. A small parseComposerRequire(composer.json) helper covers "read require". This is the smallest change and matches the comment.
  2. Explicit gatingDeps input. Add an optional gatingDeps to resolveStackRoles, separate from detection deps, falling back to deps when absent. Same effect. It makes the production-gating intent explicit when the caller supplies gatingDeps. A caller that omits it still falls back to the merged set, so the caller must supply it either way.

Either way, when the caller supplies production dependencies, a dev-only Horizon resolves to queue, which the image installs. I have a branch with shape 2 (helper, optional input, tests, backward compatible) ready if a PR is welcome. Happy to trim it to shape 1 if you prefer.

Relates to the role runtime in #935 and the presets in #976.

Activity

  1. sudanese commented on Oct 13, 2026

    @sudanese
    ContributorAuthor

    I have a candidate resolver change for this and, before opening a PR, wanted to check the preferred API shape — it touches the role resolver.

    The core issue: the default Composer install uses --no-dev, and dependency detection merges require + require-dev, so passing the merged set to resolveStackRoles selects a horizon worker even when a production install omits Horizon (i.e. when laravel/horizon is only a development dependency). The resolver's current deps input already serves gating, so the change is about giving gating a production-only view.

    Two API shapes — both rely on the future production caller supplying the right names:

    • (a) Add an optional gatingDeps input to resolveStackRoles — the direct production dependencies (require keys) used for gating, kept separate from the merged deps used for detection; falls back to deps when absent, so existing callers are unchanged.
    • (b) Keep the single deps input and have the future production caller pass the require-only names there.

    (a) keeps detection and gating cleanly separated; (b) is a smaller surface. Either way the caller must supply the production names — adding the optional field alone doesn't change behaviour. Do you have a preference, or would you rather fold this into the roles-runtime work?

  2. sudanese commented on Oct 14, 2026

    @sudanese
    ContributorAuthor
  3. Hydralerne commented on Oct 14, 2026

    @Hydralerne
    Member

    on it, ty

  4. sudanese commented on Oct 15, 2026

    @sudanese
    ContributorAuthor

    @Hydralerne thanks for picking this up. In case it saves you time: I have option (a) ready on a branch. It adds an optional gatingDeps input to resolveStackRoles, which falls back to deps, plus a small PHP helper that returns only the require names. It has tests for the dev-only Horizon case. On #935 the resolver's author also leaned toward (a), while explicitly leaving the decision to you: #935 (comment)

    Happy to open it as a PR, or to leave it if you already have something in progress.

  5. a-abdellatif98 commented on Oct 16, 2026

    @a-abdellatif98
    Contributor

    @sudanese One correction on the attribution, since it is steering a decision. On #935 I pointed at the shape numbered 1 in this issue's description, the caller-side one with no resolver change, which maps to (b) in your comment above rather than (a).

    That is not an objection to (a). Conflating detection and gating in a single deps parameter is what produced this bug, so a named gatingDeps documents the intent at the call site and I would not argue against it. Either shape still depends on the caller supplying the production names, which is the part that actually fixes the dev-only Horizon case.

    @Hydralerne your call, and either way I would keep whichever you pick coherent with the slot discussion on #935, since both are about what the resolver is told versus what it infers.

  6. sudanese commented on Oct 16, 2026

    @sudanese
    ContributorAuthor

    @a-abdellatif98 You're right, thanks for correcting me. I mixed up the numbering. You favored the caller-side approach with no resolver change, which is shape 1 in the description and (b) in my comment, not (a). Sorry for the misattribution.

    Both approaches still depend on the caller supplying the production require-only names. @Hydralerne, the choice remains yours.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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