Quality gate for AI-written Laravel: system design
Claude Kit is a Composer package that sets up Claude Code on a Laravel project with an installer command, and puts the same checks (PHPStan, Pint and Pest) at commit time, at the end of every Claude turn and in CI. The first two call one script; the generated CI runs the same tools directly. This post explains how the package is designed and where that sentence stops being true.
The problem
An AI coding agent can finish a turn with code that does not pass your own checks, and a team can hand-copy configuration between repositories until the copies drift apart. The design answers both: define the bar in one place, and run it wherever code can leave your control.
Principle 1: one script behind the hooks, the same tools in CI
quality-checks.sh. The generated CI workflows run the same tools directly, not through the script.runtime/quality-checks.sh is the single source of truth for the local gate. It runs:
- Pint in test mode, when
pint.jsonexists. - PHPStan, when
phpstan.neonexists, at the level you chose, with zero errors allowed. - Your test runner, as recorded in
.claude/claude-kit.json. For Pest with a coverage driver, it adds--coverage --min=<threshold>. Withoutpcovor Xdebug the suite still runs and coverage is warned about, not enforced. - Frontend checks, only for the scripts your
package.jsondefines:lint:check,format:checkandtypes:check.
The git pre-commit hook and Claude’s Stop hook both call the script, so the bar is defined once for those two. CI is a separate path: the generated workflows run composer lint:check, composer types:check, php artisan test --coverage --min=80 and the frontend checks themselves. The tools are the same, and the thresholds are written in the workflow files, so a change to your manifest does not change CI by itself.
CLAUDE_KIT_CHECKS selects a subset of the PHP checks (pint, phpstan and tests). The frontend checks always run, and the Stop hook uses the checks listed in hooks.checks in the manifest.
| Where | File | What it blocks |
|---|---|---|
| Commit time | .githooks/pre-commit |
a bad git commit |
| End of a Claude turn | the Stop hook in .claude/settings.json |
Claude finishing the turn |
| CI | .github/workflows/* |
a bad merge (same tools, run directly) |
| Each edit | a PostToolUse hook that runs PHPStan on the edited file | nothing; see below |
Principle 2: the agent has to pass, more than once
The Stop hook requires hooks.min_passes consecutive clean runs, between 1 and 5 and 2 by default, before a turn may finish. Any failure stops the loop immediately, exits with code 2 and feeds the report back to Claude to fix. The reason to ask for more than one clean run is that a fix can introduce a new failure.
The Stop hook also carries a feature-doc gate: a change under app/, database/, routes/ or resources/js/ must come with an added or updated document under features/<name>/. That keeps documents in step with code. It can be turned off with CLAUDE_KIT_FEATURE_DOCS=0.
The optional edit-time hook is honest about what it cannot do. A PostToolUse hook runs after the tool has run, so it cannot prevent or revert the edit. That is a Claude Code guarantee, not a Claude Kit choice. The mode only decides who hears about a failure: in block mode the errors go back to Claude with exit code 2, and in warn mode you see a warning and Claude is not interrupted. The Stop hook is what actually blocks a turn.
Principle 3: machinery is referenced, content is copied
runtime/, content and opinion go in stubs/.Everything the package ships falls into one of two buckets.
runtime/is referenced. The host project calls it in place undervendor/, so acomposer updatefixes every project that uses it. It is treated as a public API, because the host’s Stop hook points at those paths.stubs/is copied.CLAUDE.md, the rules, the skills, the agents, the feature templates and the linter configurations are written into your repository, so you can edit them freely.
php artisan claude-kit:update refreshes the copied content. A manifest records a hash for each file it published, so an untouched file is updated silently, and a file you edited is not replaced without asking, unless you pass --force. The edit-time PHPStan hook is the exception to “machinery is referenced”: it is copied into your project, because it is rendered per project.
Principle 4: do not clobber
Two write paths make the promise concrete.
| Path | Used for | If the file exists |
|---|---|---|
put |
Rules, skills, agents, configurations, hooks, templates | Identical: left alone. Matches its published hash: updated. Otherwise: ask (keep, replace or show a diff) |
putMerged |
.claude/settings.json, CLAUDE.md, phpunit.xml |
Never asks: the new content already includes what was there |
install --force and update --force replace files without asking. composer.json and package.json are merged and keep your entries. The part of CLAUDE.md that Claude Kit owns sits between claude-kit:begin and claude-kit:end markers, so re-running refreshes it without touching your own additions.
Principle 5: the installer is a configurator, and it is testable
The command layer owns every prompt. The installer and the file writer never prompt: they receive a resolver closure and call it when a file exists and differs. That separation keeps the scaffolder testable against a temporary directory. The choices become a read-only options object, and the same object can be rebuilt from the manifest for an update. Per-stack differences live mostly in one place, an enum for the frontend stack. Adding a stack takes an enum entry, a stub folder, a rule file and a case in the stack detector.
The installer detects your stack (Inertia with Vue or React, Livewire, Blade or API-only), asks what to install and works out the exact tooling your answers need. It offers to run the composer require --dev command and prints it otherwise. It never moves a version you already have and never adds -W by itself.
Principle 6: generated architecture tests that actually run
Choose layers such as services, repositories, actions or contracts, and Claude Kit generates tests/Arch/ArchTest.php from per-layer fragments. It then patches your phpunit.xml to add a tests/Arch test suite, because a generated test that no suite runs protects nothing. A layer you decline gets no rule, no architecture test and no mention in CLAUDE.md.
Trade-offs
- The conventions are opinionated defaults. The README says to read them and adapt them to your team.
- Hooks are only as strong as the host. The local gate relies on Claude Code honoring a Stop hook’s exit code, and CI is the backstop. The two are defined separately, so keep their thresholds in step.
- A Pest conflict on Laravel 13. A check run for this post on 2026-10-31 installed a fresh Laravel 13 skeleton, which resolved PHPUnit 12.5.42.
composer require --dev pestphp/pestfailed there, even without Claude Kit. With-W, Composer settled on Pest 4.7.8 and PHPUnit 12.5.33. That downgrade is your decision, and Claude Kit does not make it for you. - Escape hatches exist.
git commit --no-verifybypasses the local gate. To lower the coverage floor, changetests.coverage_minin.claude/claude-kit.json(nullmeans do not enforce). TheCLAUDE_KIT_MIN_COVERAGEvariable is only a fallback for installs that predate the manifest. CI still enforces its own thresholds.
What I checked, and what I did not
On 2026-10-31 I read the README, docs/Architecture.md, docs/Quality-Gate.md, docs/Configuration.md, the changelog, the generated workflow files and the layout of the repository. A reviewer of this post also installed a fresh Laravel 13 application and tried Pest on it, which is where the Pest figures above come from. The repository runs its own gate through a committed pre-commit hook, including a changelog rule, and the documentation says coverage is enforced at 80% or more in CI.
I did not run the Claude Kit installer against a fresh Laravel application for this post, and I did not run quality-checks.sh. The behavior described here comes from the project’s documentation and files. A reader who wants proof should run the commands in the README on a throwaway project. This post says nothing about other coding agents, because I tested none.
What I would do differently
- Say the Pest conflict first. The installer already explains it after Composer fails (
explainPestConflict). I would say it before offering the command, so nobody reads a Composer error first. - Make CI read the manifest. Today the workflows carry their own thresholds. Reading
.claude/claude-kit.jsonwould make the three places agree without anyone remembering to keep them in step.
Sources
- Claude Kit README, fetched 2026-10-31
- Claude Kit documentation, architecture, fetched 2026-10-31
- Claude Kit documentation, the quality gate, fetched 2026-10-31
- Claude Kit documentation, configuration, fetched 2026-10-31
- Claude Kit changelog, fetched 2026-10-31
- Claude Code documentation, hooks guide, fetched 2026-10-31
