DEV Community

Cover image for Static Analysis Gates: PHPStan, TypeScript Strict & Coverage in CI
Muhammad Ali Mughal
Muhammad Ali Mughal

Posted on

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 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

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.

Desk lamps lighting an inspection workbench, AI-generated

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
Enter fullscreen mode Exit fullscreen mode
# phpstan.neon
includes:
    - vendor/larastan/larastan/extension.neon
    - phpstan-baseline.neon

parameters:
    level: 6
    paths:
        - app
        - routes
    reportUnmatchedIgnoredErrors: true
    treatPhpDocTypesAsCertain: false
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/**/*"]
}
Enter fullscreen mode Exit fullscreen mode
// package.json scripts
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
"typecheck:strict": "vue-tsc --noEmit -p tsconfig.strict.json"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  1. Week 1: add the gates in report-only mode and share the numbers.
  2. Week 2: block on new PHPStan errors and the baseline guard.
  3. Week 3: block on strict folders and diff coverage at 60%.
  4. 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
  • [ ] reportUnmatchedIgnoredErrors enabled so the baseline only shrinks
  • [ ] CI fails if a PR adds baseline entries
  • [ ] tsconfig.strict.json exists and covers at least one real folder
  • [ ] noUncheckedIndexedAccess enabled 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

Top comments (0)