bartollo / pipeline-kit
Artisan command that installs/merges the Claude Code + Sloppy + Laravel Boost quality pipeline into a Laravel project
Requires
- php: ^8.1
- illuminate/console: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- phpunit/phpunit: ^10.0|^11.0|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-12 13:06:26 UTC
README
Installs/merges the Claude Code + Sloppy + Laravel Boost quality pipeline
(described in stubs/PIPELINE.md) into another Laravel
project. Available in two forms:
- Standalone script (
install.php) — no installation needed, run from outside the target project. - Composer package with an artisan command (this README) — installed
into the target project and run from inside it via
php artisan pipeline:install.
Installing it in another project
This package's code lives at
github.com/bartollo/pipeline-kit
(tag v1.0.0 onward). While it isn't published on Packagist yet, add it as a
vcs repository in the other project's composer.json:
"repositories": [ { "type": "vcs", "url": "https://github.com/bartollo/pipeline-kit.git" } ]
Then:
composer require --dev bartollo/pipeline-kit:^1.0
If the package is already on Packagist, the repositories step above isn't
needed — composer require --dev bartollo/pipeline-kit resolves it directly.
If both projects sit side by side on disk (as in this monorepo, which uses
pipeline-kit/ itself as a local dependency for dogfooding), a path
repository works too:
"repositories": [ { "type": "path", "url": "../laravel-ai-pipeline/pipeline-kit" } ]
composer require --dev bartollo/pipeline-kit:@dev
The service provider is auto-discovered by Laravel (extra.laravel.providers
in the package's composer.json) — nothing to register manually.
Usage
php artisan pipeline:install # applies it php artisan pipeline:install --dry-run # just shows what it would do php artisan pipeline:install --force # overwrites phpstan.neon, boost.json, # CLAUDE.md and PIPELINE.md if they # already exist (backs up to .bak first)
The command always operates on the project it's run in (base_path()) — it
doesn't take a target path, because it's already installed inside the
project you want to configure.
To check what's configured without changing anything, use the companion status command:
php artisan pipeline:status # table of every item the pipeline manages php artisan pipeline:status --json # same data as JSON, for scripting/CI
It reports one row per item (.mcp.json servers, Claude Code hooks,
.claude/settings.local.json enabled servers, whether those two are
actually tracked by git, phpstan.neon, boost.json, CLAUDE.md,
PIPELINE.md, the composer.json require-dev packages and scripts, and
whether the sloppy scripts carry an explicit memory limit) with one of four
statuses:
| Status | Meaning |
|---|---|
OK |
fully present and matching what the installer would have written |
PARTIAL |
some but not all expected entries are present (e.g. one MCP server, one missing script) |
MODIFIED |
the file exists but its content differs from the bundled stub — expected once Sloppy/Boost append their own sections, or once you edit it yourself |
MISSING |
not installed at all |
The command exits 0 when nothing is MISSING or PARTIAL (so it can gate
a CI step or a pre-deploy check), and 1 otherwise.
One row, Claude Code hook reachability, checks something pipeline:install
itself can't: Claude Code only reads .claude/settings.json (and therefore
its hooks) from the project root where a session was opened — never from a
subdirectory. If this project turns out to be nested inside a bigger git
repository (a monorepo with site/, backoffice/, api/, …) and that
repo's root has no matching hooks, this row comes back MISSING even though
every other row is OK — that combination means the Sloppy hooks installed
here will silently never fire for a session opened at the repo root. Fix it
by mirroring the hooks into .claude/settings.json at the repo root (routing
by the edited file's path if more than one sub-app has Sloppy installed), or
by opening Claude Code sessions inside this project instead of the monorepo
root.
A related row, Claude Code config committed to git, catches a quieter
version of the same problem: .mcp.json and .claude/settings.json can
exist on disk, pass every other check here, and still never reach a
teammate, if .gitignore excludes them — directly, or because a blanket
.claude/ rule swallows settings.json along with the genuinely-personal
settings.local.json it was meant for. From anyone who hasn't got this
exact working copy, a gitignored file is indistinguishable from one that was
never installed — a fresh git clone just won't have it. This row runs
git check-ignore against both files and comes back MISSING/PARTIAL if
either is excluded; only settings.local.json is supposed to be gitignored,
so commit the rest.
Another row, Sloppy memory limit, flags sloppy/sloppy:diff/
sloppy:review scripts that don't raise PHP's memory limit. vendor/bin/sloppy
indexes the whole project for cross-file analysis and can exceed the default
128M on anything past a small codebase — the fragment now runs it the same
way types already runs Pest, via php -d memory_limit=2G vendor/bin/sloppy .... Because pipeline:install never rewrites a script that's already
present (see below), a project that merged the fragment before this fix
is stuck without it until the scripts are edited by hand.
Same additive/idempotent logic as the standalone script: it never overwrites
hooks, require-dev packages, or scripts already present in the target,
unless you pass --force (and even then, only for the four whole-file
copies, never for composer.json).
Every run that actually merges composer.json backs it up to
composer.json.bak first, regardless of --force — this happens on the
very first install too, not only when re-running against an existing one. It's
redundant with your git history, so it's safe to delete once you've reviewed
the diff.
After installing
The command prints a "Next steps" hint, but a few of those steps have sharp edges worth knowing up front:
-
Scope your
composer update. A barecomposer updateresolves the entire dependency tree, not just the packages the fragment just added — on an existing project this can silently upgrade dozens of unrelated packages (framework patch bumps, a major version jump in some transitive dependency, etc.). Prefer naming the packagescomposer.jsonjust gained:composer update bartollo/pipeline-kit heyosseus/sloppy larastan/larastan \ laravel/boost laravel/pint pestphp/pest-plugin-type-coverage \ phpunit/phpunit --with-dependencies --no-interaction composer install --no-interactionIf you already ran a bare
composer updatebefore noticing,git checkout -- composer.lockto undo it and re-run scoped — check the lock diff afterwards for anything beyond new entries and alphabetical reordering. -
laravel/paocan conflict with the project'scollision/phpunit/pest/frameworkversions.laravel/paodeclares (in its owncomposer.json) explicitconflictentries against version ranges of those packages — but which ranges depends on which version ofpaoComposer ends up resolving, and that table has changed acrosspao's own releases (an olderpaorelease conflicted withpestphp/pest <4.6.3directly; the version current at the time of writing instead conflicts withnunomaduro/collision <8.9.3,phpunit/phpunit <12.5.23 || >=13.0.0 <13.1.7 || >=14.0.0, andlaravel/framework <12.0.0). So this isn't a fixed, permanent incompatibility — it's whicheverpaoversion and collision/phpunit/pest version the target project happens to pair up. If the target project's versions of those packages don't satisfy whatever range the resolvedpaoversion declares, Composer reports a conflict instead of installing anything.Don't trust a stale list (including the one above) — check what Composer actually resolves for this project before assuming it's blocked:
composer require --dev laravel/pao --dry-run
If that resolves cleanly, drop
--dry-runand you're done. If it still conflicts, Composer's own output names the exact package(s) and version range at fault — bump those specific packages (not a barecomposer update, per point 1 above) rather than droppinglaravel/pao; removing it should be a last resort for a project that can't move those versions yet. -
vendor/bin/sloppy rules --format=claudewrites intoCLAUDE.mddirectly — it's not a "copy this output by hand" step. It inserts or updates a single block delimited by<!-- sloppy:begin -->/<!-- sloppy:end -->and leaves the rest of the file untouched. -
php artisan boost:installcan take a couple of minutes (agent detection plus MCP server installs); it's safe to run in the background. Besides updatingCLAUDE.md, it may also create anAGENTS.mdif it detects an agent (e.g. GitHub Copilot) that reads guidelines from that file instead. -
sloppy:diff/sloppy:reviewassume the trunk branch is calledmain. The bundled scripts runsloppy diff main/sloppy review main—pipeline:installhas no way to know what this project's actual integration branch is called. If it'sdevelop,master, or anything else, both scripts will fail (or silently compare against the wrong ref) until you edit the branch name incomposer.jsonto match. -
Publish Sloppy's own config. Until you run this, Sloppy's settings — including which paths it excludes, e.g.
tests/— are only the package's implicit defaults: invisible and not adjustable from this project.php artisan vendor:publish --tag=sloppy-config
AI code review gate
PIPELINE.md's step 19 describes an "AI Code Review" stage that's meant to
run, with a fresh/independent perspective, once the deterministic pipeline
(composer quality) is green — but nothing made that actually happen. A
session could run composer quality, see it pass, and just stop without
ever running that review. pipeline:install wires a second Stop hook
(alongside Sloppy's own) for exactly this:
{
"type": "command",
"command": "php \"${CLAUDE_PROJECT_DIR}/artisan\" pipeline:ai-review-gate",
"timeout": 600
}
Every time Claude Code tries to end a turn, this hook:
- Allows the stop immediately if Claude Code says
stop_hook_active(it's already mid-way through honoring a previous block for this same turn — never re-block here, or the session would loop). - Allows the stop if there are no pending changes (
git diff HEADplus any untracked files), or if the current diff's hash matches the last one this hook already forced a review for. - Otherwise runs
composer quality. If it isn't green, allows the stop — this hook only ever gates the review, not the quality pipeline itself. - If it's green and this exact diff hasn't been reviewed yet, blocks
the stop and returns
{"decision": "block", "reason": "..."}: a reason telling Claude, in PIPELINE.md's own terms, to run thecode-reviewskill (or a code-reviewer subagent) against the pending diff before actually finishing — and remembers the diff's hash so it won't ask again for the same changes.
If the forced review leads to more edits, the diff's hash changes and the
next stop attempt blocks again — each new slice of changes gets its own
review. composer quality on a non-trivial project can take a while, hence
the generous timeout; shrink it (or swap in a cheaper check) if your
quality script is fast, or extend it if it's slower than 10 minutes.
The hash-based "already reviewed" state is a small file under the system temp directory (keyed by the project path), not inside the repo — it never gets committed and resets if that directory is cleared, which just costs one extra forced review, not a correctness problem.
Troubleshooting
-
Could not delete vendor/bin/...duringcomposer update/install.vendor/has files owned by another user — commonlyroot, left over from acomposer installrun inside a Docker container — that your shell user can't replace. Fix ownership and retry:sudo chown -R $USER vendor. -
php artisan pipeline:installisn't listed after requiring the package. Laravel's package manifest (bootstrap/cache/packages.php) may still be cached from before the package existed in the project, even though the autoloader already knows about it. Clear it and rediscover:rm -f bootstrap/cache/packages.php bootstrap/cache/services.php php artisan package:discover
-
Sloppy's hooks never seem to fire, even though
pipeline:statusshows everything elseOK. This happens when the project is installed inside a monorepo subdirectory (e.g.site/alongsidebackoffice/andapi/) and Claude Code sessions are opened at the monorepo root rather than insidesite/. Claude Code only reads.claude/settings.jsonfrom the root of the open session, not from subdirectories, so hooks installed a level down are configured correctly but unreachable. Runphp artisan pipeline:status— its Claude Code hook reachability row catches exactly this case — and mirror the hooks into a.claude/settings.jsonat the monorepo root (with the hook command inspecting the edited file's path to route to the right sub-app'svendor/bin/sloppy, since a root-level hook can't blindly call one sub-app's binary for every edit) or open sessions inside the sub-app instead. -
vendor/bin/sloppy scan/diff/reviewdies with "Allowed memory size of 134217728 bytes exhausted". That's PHP's default 128M limit — Sloppy indexes the whole project for cross-file analysis, which can outgrow it on anything past a small codebase. Run it with a raised limit, the same way thetypesscript already does for Pest:php -d memory_limit=2G vendor/bin/sloppy scan
php artisan pipeline:statusflags this under Sloppy memory limit if thesloppy/sloppy:diff/sloppy:reviewscripts incomposer.jsondon't already set it. -
vendor/bin/sloppy diff mainreports "0 changed" right after writing new files.sloppy diffcompares against git, not the working tree, so a brand-new file that hasn't beengit added yet is invisible to it — "0 changed" doesn't mean there's nothing to review, it means Sloppy couldn't see the new file at all. Stage it first (git add, no need to commit) and re-run the diff. -
composer qualityfails immediately on an existing codebase, drowned in pre-existing errors.analyse(PHPStan/Larastan atlevel: 8) has nothing to tell pre-existing debt apart from debt your change introduced — the bundledphpstan.neondoesn't ship a baseline, becausepipeline-kithas no way to know what's already acceptable in a project it has never seen. Generate one once, right after installing:vendor/bin/phpstan analyse --generate-baseline
then add
- phpstan-baseline.neonunderincludes:inphpstan.neonand commit it. From then on,analyseonly reports new errors.
Package structure
pipeline-kit/
├── composer.json # package manifest (bartollo/pipeline-kit)
├── src/
│ ├── PipelineKitServiceProvider.php
│ ├── Console/Commands/InstallPipelineCommand.php
│ ├── Console/Commands/PipelineStatusCommand.php
│ ├── Console/Commands/PipelineAiReviewGateCommand.php
│ ├── PipelineMerger.php # merge logic, no Laravel dependency
│ ├── PipelineStatus.php # read-only status checks, no Laravel dependency
│ └── PipelineAiReviewGate.php # Stop-hook decision logic, no Laravel dependency
├── stubs/ # bundled copies of .mcp.json, CLAUDE.md, etc.
├── bin/sync-stubs.php # resyncs stubs/ from the source project's real files
└── tests/
├── PipelineMergerTest.php
├── PipelineStatusTest.php
└── PipelineAiReviewGateTest.php
Maintenance (only relevant to whoever maintains the source repo)
The files under stubs/ are copies — once installed in another project, the
package no longer has access to the source repo's real files. After editing
CLAUDE.md, PIPELINE.md, .mcp.json, .claude/settings*.json,
phpstan.neon, boost.json, or the pipeline's package/script list, run:
php pipeline-kit/bin/sync-stubs.php
Tests
cd pipeline-kit && php ../vendor/bin/phpunit -c phpunit.xml
(PipelineMerger doesn't depend on Laravel, so the tests run with plain
PHPUnit — no need to install the package or use orchestra/testbench.)