Repository navigation
Laravel role selection can choose a horizon worker that a --no-dev build does not install #1171
Description
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):
depsmergesrequireandrequire-dev, so a dev-only laravel/horizon still selectshorizon. Callers that need production-only matching must readrequirefrom 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:
- Caller-side, no resolver change.
resolveStackRolesusesdepsonly for gating. The future deployment caller can pass therequire-only names asdeps, keeping the merged set for detection elsewhere. A smallparseComposerRequire(composer.json)helper covers "readrequire". This is the smallest change and matches the comment. - Explicit
gatingDepsinput. Add an optionalgatingDepstoresolveStackRoles, separate from detectiondeps, falling back todepswhen absent. Same effect. It makes the production-gating intent explicit when the caller suppliesgatingDeps. 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
sudanese commentedon Oct 13, 2026
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 mergesrequire+require-dev, so passing the merged set toresolveStackRolesselects ahorizonworker even when a production install omits Horizon (i.e. whenlaravel/horizonis only a development dependency). The resolver's currentdepsinput 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:
gatingDepsinput toresolveStackRoles— the direct production dependencies (requirekeys) used for gating, kept separate from the mergeddepsused for detection; falls back todepswhen absent, so existing callers are unchanged.depsinput and have the future production caller pass therequire-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?
sudanese commentedon Oct 14, 2026
@Hydralerne
Hydralerne commentedon Oct 14, 2026
on it, ty
sudanese commentedon Oct 15, 2026
@Hydralerne thanks for picking this up. In case it saves you time: I have option (a) ready on a branch. It adds an optional
gatingDepsinput toresolveStackRoles, which falls back todeps, plus a small PHP helper that returns only therequirenames. 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.
a-abdellatif98 commentedon Oct 16, 2026
@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
depsparameter is what produced this bug, so a namedgatingDepsdocuments 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
slotdiscussion on #935, since both are about what the resolver is told versus what it infers.sudanese commentedon Oct 16, 2026
@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.