Static Analysis Gates: PHPStan, TypeScript Strict & Coverage in CI
A client's Laravel team asked me why their AI-assisted pull requests kept passing review and then failing in production. One example: a generated service method returned ?Order and the controller called $order->total wit
A client's Laravel team asked me why their AI-assisted pull requests kept passing review and then failing in production. One example: a generated service method returned ?Order and the controller called $order->total without a null check. Tests covered the happy path only. The reviewer skimmed 400 lines of plausible code and approved it. PHPStan at level 6 would have flagged that exact line in under a second. They had PHPStan installed, but nobody ran it in CI because the first run produced 1,800 errors and the team gave up.
I wrote this with help from AI tools and checked every config against PHPStan 2.x, Larastan 3, TypeScript 5.6, Vitest 2 and GitHub Actions. I am a Principal Software Engineer in Karachi with 9+ years across PHP/Laravel, Vue, Node and .NET microservices, and through Loop Origin I maintain production SaaS codebases where a growing share of code is AI-assisted.
π Series Navigation β Maintaining AI-Written Code in Production
- Part 1: Reviewing AI-Generated Pull Requests: A Checklist That Catches Real Bugs
- Part 2: Characterization Tests Before You Refactor AI-Written Code
- Part 3: Hallucinated Packages & Dependency Drift in AI-Assisted Code
- Part 4 (This Post): Static Analysis Gates: PHPStan, TypeScript Strict & Coverage in CI
- Part 5: Decision Records So AI Context Doesn't Rot
- Part 6: Measuring AI Coding Impact With DORA Metrics, Not Vibes
Related earlier posts: Config & Feature Flags That Quietly Diverged Across Environments Β· Synthetic Checks & SLOs for the Paths Nobody Clicks
How do you add static analysis gates to a codebase with lots of AI-written code?
Direct answer: Start with a baseline so existing errors do not block anyone, then fail CI only on new problems. Run PHPStan (with Larastan for Laravel) at a realistic level, turn on TypeScript strict gradually per folder, and require coverage on changed lines rather than a global percentage. Raise the bar in small steps each sprint, and never let AI-generated code add to the baseline.
Why AI-written code needs machine gates
Human reviewers get tired; type checkers do not. Assistants are good at producing code that looks right and passes the test they also wrote. The bugs that slip through are usually the boring kind static analysis is built for: nullable values used as non-null, wrong argument types, unreachable branches, undefined array keys, and methods called on the wrong model. A gate catches those in seconds and frees the reviewer to think about design, which is where Part 1's checklist puts the human effort.
| Gate | What it catches | Typical runtime | Blocking rule I use |
|---|---|---|---|
| PHPStan + Larastan | Null misuse, wrong types, undefined methods/properties | 20β90 s | No new errors vs baseline |
TypeScript strict
|
Implicit any, unchecked nulls, unsafe indexing |
10β60 s | Strict folders must compile clean |
| ESLint (typed rules) | Floating promises, unsafe any flows |
15β60 s | No new errors in changed files |
| Diff coverage | Untested new branches | Depends on suite | 80%+ of changed lines covered |
| Global coverage | Overall trend | Same | Report only, never block |
Gate 1: PHPStan with a baseline
Install Larastan and generate a baseline that records today's errors so the build goes green immediately:
composer require --dev larastan/larastan
vendor/bin/phpstan analyse --generate-baseline
# phpstan.neon
includes:
- vendor/larastan/larastan/extension.neon
- phpstan-baseline.neon
parameters:
level: 6
paths:
- app
- routes
reportUnmatchedIgnoredErrors: true
treatPhpDocTypesAsCertain: false
reportUnmatchedIgnoredErrors matters: when someone fixes a baselined error, the build tells them to remove it from the baseline, so the file only ever shrinks. Pick a level you can hold; level 6 is a solid start for most Laravel apps, and you can move to 8 once nullable handling is tidy.
The rule I add to the PR template: AI-assisted changes may never add entries to phpstan-baseline.neon. If the generated code fails analysis, fix the code. A CI check enforces it:
# ci/baseline-guard.sh
set -euo pipefail
git fetch origin main --depth=1
added=$(git diff origin/main -- phpstan-baseline.neon | grep -c '^+\s*-\s*message' || true)
if [ "$added" -gt 0 ]; then
echo "::error::PR adds $added PHPStan baseline entries. Fix the code instead."
exit 1
fi
Gate 2: TypeScript strict, one folder at a time
Turning on strict for a large Vue or Node codebase at once produces hundreds of errors. Instead, keep the main config permissive and add a strict config that covers folders you have cleaned:
// tsconfig.strict.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noEmit": true
},
"include": ["src/services/**/*", "src/composables/**/*", "src/api/**/*"]
}
// package.json scripts
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
"typecheck:strict": "vue-tsc --noEmit -p tsconfig.strict.json"
Every sprint, add one folder to include. New folders start strict from day one. noUncheckedIndexedAccess is the flag that catches the most AI-written bugs in my experience, because generated code loves items[0].id without checking the array is non-empty.
Gate 3: diff coverage instead of global targets
A global "80% coverage" rule pushes people, and assistants, to write shallow tests for easy code. Diff coverage asks a better question: are the lines this PR changed actually exercised?
# .github/workflows/quality.yml
name: quality
on: pull_request
jobs:
php:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: shivammathur/setup-php@v2
with: { php-version: '8.3', coverage: pcov }
- run: composer install --no-interaction --prefer-dist
- run: vendor/bin/phpstan analyse --no-progress --error-format=github
- run: bash ci/baseline-guard.sh
- run: vendor/bin/pest --coverage-clover=coverage.xml
- run: pip install diff-cover
- run: diff-cover coverage.xml --compare-branch=origin/main --fail-under=80
js:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run typecheck && npm run typecheck:strict
- run: npx eslint . --max-warnings=0
diff-cover reads Cobertura or Clover style reports and compares them with the git diff. For Vitest, configure the cobertura reporter and run the same command on its output.
Rolling it out without a revolt
- Week 1: add the gates in report-only mode and share the numbers.
- Week 2: block on new PHPStan errors and the baseline guard.
- Week 3: block on strict folders and diff coverage at 60%.
- Monthly: raise diff coverage by 5β10 points and the PHPStan level by one when the baseline is small.
Keep total CI time under ten minutes or people will start bypassing it. Cache Composer and npm, and run PHP and JS jobs in parallel.
Diagnostic checklist
- [ ] PHPStan + Larastan run on every PR with a committed baseline
- [ ]
reportUnmatchedIgnoredErrorsenabled so the baseline only shrinks - [ ] CI fails if a PR adds baseline entries
- [ ]
tsconfig.strict.jsonexists and covers at least one real folder - [ ]
noUncheckedIndexedAccessenabled in strict folders - [ ] Diff coverage enforced; global coverage reported, not enforced
- [ ] Gates documented in the PR template, including the AI-assisted rule
- [ ] Total CI runtime under ten minutes
FAQs
What PHPStan level should a Laravel app start at?
Generate a baseline at level 5 or 6 so the build is green on day one, then raise the level once the baseline is small. Starting at max with a giant baseline just hides problems.
Won't a baseline hide real bugs forever?
Only if nobody looks at it. Track its size as a metric, fix a few entries each sprint, and the unmatched-error setting stops it from growing quietly.
Can I ask the AI assistant to fix PHPStan errors?
Yes, and it is one of the better uses of an assistant. Review the fixes carefully: assistants sometimes silence errors with @phpstan-ignore or loose mixed types instead of fixing the cause. Ban both in review.
Is 80% diff coverage realistic?
For most business logic, yes. Allow exceptions for generated files, migrations and config, and exclude them in the coverage config rather than lowering the number.
Do these gates slow the team down?
In my experience they speed reviews up, because reviewers stop hunting for type mistakes. The first two weeks feel slower; after that, fewer hotfixes more than make up for it.
Can Loop Origin set these gates up for us?
Yes. We add them as part of our Laravel development and maintenance work; see pricing for typical engagements.
Next in this series, Part 5 covers decision records so AI context doesn't rot.
Follow & Connect
- Website: Loop Origin
- LinkedIn: Loop Origin
- X: Muhammad Ali Mughal (@AliMughal887)
- GitHub: Loop Origin (@LoopOrigin)
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.