Mohamed Ashraf Elsaed

Quality gate for AI-written Laravel: system design

By Mohamed Ashraf ElsaedPublished 7 min read

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.

Claude Kit banner: claude-kit, one command sets up Claude Code and a quality gate for Laravel
The project banner. Declared requirements: PHP 8.2 or newer and Laravel 11, 12 or 13. The package's own CI covers PHP 8.2 to 8.4 and Laravel 11 and 12; Laravel 13 is not in it.

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

Diagram: the git pre-commit hook and the Claude Stop hook call one script, quality-checks.sh, which runs Pint, PHPStan, the tests with a coverage floor and the frontend checks; the generated CI workflows run the same tools directly
The two local hooks call 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:

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

Diagram of the hybrid model: runtime scripts are referenced in place from vendor and updated by composer update; stubs are copied into the project, owned by the team and refreshed by claude-kit:update
The rule: machinery goes in runtime/, content and opinion go in stubs/.

Everything the package ships falls into one of two buckets.

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

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

  1. 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.
  2. Make CI read the manifest. Today the workflows carry their own thresholds. Reading .claude/claude-kit.json would make the three places agree without anyone remembering to keep them in step.

Sources

This post was drafted with AI assistance. I reviewed every claim and ran the commands shown.

About the project: Claude Kit.

Mohamed Ashraf Elsaed
Mohamed Ashraf Elsaed
Technical Lead at Convertedin, Cairo. I build tools for AI coding agents and write about Laravel, Node and engineering.