"We use types everywhere" is a claim nobody on the team can actually verify. Code coverage tells you which lines a test touched — it says nothing about whether a method's parameters and return are typed. Pest type coverage measures exactly that: the percentage of your code carrying explicit type declarations, and it needs no tests to do it. Point it at the codebase, set a floor, and CI fails the moment someone adds an untyped parameter.
What Pest type coverage actually measures#
Type coverage counts declarations, not executions. For every function parameter, return type, and typed property, it asks one question: is there an explicit type here? Your score is the percentage that answer yes. It's the same instinct behind measuring test quality with mutation testing instead of raw line coverage — measure the thing you actually care about, not a proxy for it.
Run the report and every file that isn't fully typed is flagged with the line number and the kind of declaration missing. rt31 means the return type on the function at line 31 is absent; pa31 means a parameter on line 31 has no type. Missing property types show up the same way.
Because it reads the syntax tree rather than running your suite, it doesn't need Xdebug or PCOV. Unlike code coverage, there's no coverage driver to install and no test run to sit through — it's static analysis, so it's fast enough to run on every push.
Installing and running the plugin#
Pull the plugin in as a dev dependency:
composer require pestphp/pest-plugin-type-coverage --dev
Then generate a report with the --type-coverage flag:
./vendor/bin/pest --type-coverage
You get a per-file breakdown with the missing declarations called out by line:
App\Models\Invoice .......................................... 100%
App\Services\InvoiceReporter ................ pa14 rt14 rt29 82%
App\Http\Controllers\ReportController ................. rt22 94%
────────────────────────────────────────────────────────────────
Total: 96.7%
On a large codebase that list is noisy. Add --compact to hide everything already at 100% and show only the files that still need work:
./vendor/bin/pest --type-coverage --compact
Fixing a flagged line is usually a two-second change. A method that trips pa and rt just needs its signature filled in:
// Before: pa + rt flagged — no parameter or return type
public function totalFor($customer)
{
return $customer->invoices()->sum('total');
}
// After: both declarations present, the line clears
public function totalFor(Customer $customer): int
{
return $customer->invoices()->sum('total');
}
Enforcing type coverage in CI with --min#
Reading a report is optional; a threshold is not. Pass --min and Pest exits non-zero — failing the build — whenever coverage drops below the number you set:
./vendor/bin/pest --type-coverage --min=100 --compact
In GitHub Actions that's one step alongside your existing test job:
- name: Type coverage
run: ./vendor/bin/pest --type-coverage --min=100 --compact
Don't set --min=100 on day one of a legacy app — you'll paint the board red and nobody will fix it. Set the floor at whatever the codebase reports today, then raise it as declarations go in. It's the same ratchet you'd use to adopt PHPStan on a legacy app with a baseline and burn it down: freeze the current state, block regressions, improve deliberately.
If you want the number tracked over time, write it out as JSON with --type-coverage-json=coverage.json and feed it into a dashboard. And since this runs as part of the Pest suite, it benefits from the same CI plumbing — if you're already sharding Pest across parallel runners, the type-coverage check rides along for free.
Type coverage vs PHPStan#
This is the question everyone asks, and the confusion is understandable — the plugin actually leans on PHPStan under the hood. But it answers a different question. Type coverage asks "is there a declaration here?" PHPStan asks "given the declarations that are here, does this code actually make sense?"
They don't overlap; they stack. A file can hit 100% type coverage and still fail PHPStan level 10 with the usual Laravel errors — a present type can still be the wrong type. Equally, code that passes PHPStan can carry parameters PHPStan only inferred, which type coverage will still flag as undeclared. More explicit declarations narrow what static analysis has to guess, so higher type coverage makes PHPStan sharper. If you want that same rigor inside your test files, PestStan wires PHPStan generics into the Pest layer.
Gotchas and edge cases#
Presence is not correctness. Type coverage will happily give you 100% on a method typed int that should return string. It measures that a declaration exists, not that it's right — that's exactly the gap PHPStan fills, which is why you run both.
Some lines genuinely can't be typed — a framework property with a mixed shape, a magic column. Silence those with the @pest-ignore-type annotation rather than dropping your threshold for the whole file:
protected $except = [ // @pest-ignore-type
'updated_at',
];
Watch the PHPStan version. Because the plugin depends on phpstan/phpstan, a mismatched PHPStan point release has broken the type-coverage run before. If --type-coverage suddenly errors right after a composer update, check what moved — pin PHPStan if you need to.
Mind memory in CI. On a big codebase the static analysis pass can hit PHP's memory_limit and fall over with an out-of-memory error. Raise memory_limit for that step if the runner kills it.
Wrapping up#
Add --type-coverage --min next to your other quality gates and let CI hold the line. If you already run Duster to fire Pint, PHPStan, and Rector in one command, type coverage slots in as one more check in the same job. Start at today's percentage, ratchet toward 100, and you've turned "we use types" from a vibe into a number the build enforces.
FAQ#
What is type coverage in Pest?
Type coverage is a metric that reports the percentage of your code carrying explicit type declarations — parameters, return types, and typed properties. Pest's pest-plugin-type-coverage calculates it by analysing your source statically, so unlike code coverage it needs no tests and no Xdebug or PCOV driver to produce a number.
How is type coverage different from code coverage?
Code coverage measures which lines your tests execute; it requires a test suite and a coverage driver. Type coverage measures which declarations exist in your source and requires neither. One tells you how well-tested a line is, the other tells you how well-typed it is — they answer completely different questions and you can usefully track both.
How do I fail CI when type coverage is too low?
Run ./vendor/bin/pest --type-coverage --min=100, substituting whatever floor you want for 100. When coverage falls below that threshold Pest exits with a non-zero status, which fails the CI job. On a legacy codebase, set --min to the current percentage and raise it over time rather than starting at 100.
Does type coverage replace PHPStan or Larastan?
No. Type coverage checks whether declarations are present; PHPStan and Larastan check whether the types you've declared are actually sound. A file at 100% type coverage can still fail PHPStan, and the plugin even uses PHPStan internally. Run them together — type coverage gets declarations onto the code, PHPStan reasons about them.
How do I ignore a line that can't be typed?
Add a // @pest-ignore-type comment on the line the plugin flags, and it's excluded from the calculation. Reach for it only on genuinely untypeable code — a mixed-shape framework property, for example — so you can hold a strict --min without one awkward line dragging the whole file down.