pestphp/pest-plugin-coverage
Official Pest plugin for code coverage: generate coverage reports from your Pest test suite, enforce minimum coverage thresholds, and integrate with PHPUnit coverage tooling through simple Pest commands and configuration.
Step 0 — decide whether you should install this at all. This is an archived package (9 stars, 0 dependents). It is a thin wrapper that registered PHPUnit's coverage engine with the Pest CLI back when Pest had no coverage flags. Modern Pest has them natively. So the first command you run is not composer require — it's a version check:
composer show pestphp/pest | head -5
| Resolved Pest | What to do |
|---|---|
| 2.x / 3.x | Do not install. Use vendor/bin/pest --coverage --min=80. Skip to "Implementation Patterns" — everything below maps 1:1 onto native flags. |
| 1.x, upgrade not feasible this cycle | This is the only legitimate use. Install it, and open a dated removal ticket the same day. |
Step 1 — install a driver. Coverage is a PHP engine feature; the plugin only wires reporting. Without a driver you get a fatal error or, worse, an empty report and a green build. PCOV is the correct default for line coverage (fast, no config):
pecl install pcov
php -m | grep pcov # must print "pcov"
Xdebug works but is 3–10× slower and on Xdebug 3 needs an explicit opt-in:
XDEBUG_MODE=coverage php vendor/bin/pest --coverage
XDEBUG_MODE=coverage is not a suggestion — Xdebug 3 runs in develop mode by default and coverage instrumentation is simply off.
Step 2 — install the plugin (Pest 1.x only).
composer require pestphp/pest-plugin-coverage --dev
Pest auto-discovers plugins through extra.pest.plugins; there is no service provider to register and no publishable config file. Do not go looking for config/coverage.php — it does not exist.
Step 3 — tell the tool what to measure. All configuration lives in phpunit.xml / phpunit.xml.dist. Get this block right or every number you produce is a lie (files not listed here are never counted, so untested files silently vanish rather than reporting 0%).
PHPUnit 10+ / Pest 2+ schema:
<source>
<include>
<directory suffix=".php">app</directory>
</include>
<exclude>
<directory>app/Console</directory>
<directory>app/Exceptions</directory>
</exclude>
</source>
PHPUnit 9 schema (what a Pest 1.x project will have):
<coverage>
<include>
<directory suffix=".php">./app</directory>
</include>
<exclude>
<directory>./app/Console</directory>
</exclude>
</coverage>
Step 4 — first real use case. A CI gate. This is the whole value proposition; everything else is reporting plumbing.
./vendor/bin/pest --coverage --min=80
Non-zero exit below threshold. That single command is what you put in the pipeline.
Where to look first when the number looks wrong: phpunit.xml, not the plugin. Wrong numbers are almost always an <include> path that misses a directory, a driver that isn't loaded, or a PHPUnit schema mismatch — in that order.
Pattern: the local dev loop — fast feedback, --compact text.
You do not want an HTML report on every save. Run a scoped text report over the suite you're actually editing:
vendor/bin/pest tests/Feature/Billing --coverage --compact
--compact (Pest 2+) reduces the text table to uncovered lines only, which is the useful part. On Pest 1.x with the plugin, drop --compact and read the full table.
Pattern: CI gate + machine-readable artifact.
Two flags in one invocation — threshold enforcement plus a Clover file for the downstream consumer (Codecov, Sonar, GitLab):
vendor/bin/pest \
--coverage \
--coverage-clover build/coverage/clover.xml \
--min=80
# .github/workflows/tests.yml (excerpt)
- name: Coverage
run: ./vendor/bin/pest --coverage --coverage-clover build/coverage/clover.xml --min=80
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage
path: build/coverage/
Two rules: if: always() on the upload so a failing gate still gives you the artifact to debug, and create build/coverage/ beforehand or some versions refuse to write Clover.
Pattern: HTML report for dead-code mining, run weekly not per-PR.
vendor/bin/pest --coverage --coverage-html build/coverage/html
open build/coverage/html/index.html
The HTML view is the only one that shows you which branches are unreachable, not just which lines are red. That's how you find the if that can never be false and delete it. Add build/ to .gitignore before someone commits 4,000 generated HTML files.
Pattern: identical local and pipeline commands via Composer scripts.
The most common failure in coverage tooling is drift between what devs run and what CI runs. Funnel both through one entry point:
{
"scripts": {
"test": "pest",
"coverage": "pest --coverage --coverage-clover build/coverage/clover.xml --min=80",
"coverage:html": "pest --coverage --coverage-html build/coverage/html"
}
}
Now CI runs composer coverage and a developer reproduces a red build with one command. Never let the two diverge.
Pattern: risk-module ratchets instead of one global number.
A repo-wide percentage is a vanity metric that lets 95%-covered billing code hide behind 20%-covered reporting code. Scope runs to the modules that hurt when they break:
vendor/bin/pest --coverage --min=90 --testsuite=Billing
vendor/bin/pest --coverage --min=60 --testsuite=Reports
Two CI steps, two thresholds. This is what you actually argue refactor budget with. Note --min applies to the run it's attached to, so per-suite invocations give you per-suite gates.
Pattern: baseline snapshot before a migration.
Before a Laravel major upgrade, a monolith split, or a team handover, capture a durable artifact you can diff later:
vendor/bin/pest --coverage --coverage-clover build/coverage/baseline-$(date +%F).xml
Commit the summary (line %, branch %, method %) to an ADR or the upgrade ticket. When the migration lands and the number "feels worse," you have a file to compare against instead of an anecdote.
Pattern: #[CoversClass] metadata discipline.
Attribute-based coverage metadata ties a test to the class it exercises, which makes reports attributable per class rather than per file path:
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(InvoiceCalculator::class)]
final class InvoiceCalculatorTest extends TestCase
{
public function test_applies_tier_discount(): void
{
// ...
}
}
On PHPUnit 9 / Pest 1 the equivalent is @covers docblocks. This pays off twice: cleaner per-class coverage attribution now, and easier migration off the plugin later because the intent is recorded in the tests rather than in the tooling.
Pattern: the parity migration (the one you should actually plan).
When you're ready to leave the plugin behind, treat the swap as a verification exercise rather than a dependency edit:
git checkout -b chore/drop-pest-coverage-plugin
composer remove pestphp/pest-plugin-coverage
vendor/bin/pest --coverage --coverage-clover build/coverage/new.xml --min=<current>
# diff new.xml against the last known-good artifact — require parity, not "close enough"
Diff line/branch/method percentages and file counts. A file-count drop means your <source> block changed semantics between schemas, not that coverage improved.
The silent empty report is the headline risk. Missing driver → no coverage; no <include> block → only files a test already loads are measured, so your percentage looks excellent and means nothing. If you ever see a suspiciously high number or a suspiciously empty table, check in this order: php -m | grep -E 'pcov|xdebug', XDEBUG_MODE=coverage, then phpunit.xml <source>/<coverage>.
--min only works if coverage actually ran. pest --min=80 without --coverage is a no-op that can exit 0. Always pair them, and put them in one Composer script so nobody splits them by accident.
Both drivers loaded is worse than one. PCOV takes precedence when enabled; the symptom is confusing or contradictory output when someone assumes Xdebug is doing the work. Disable one (pcov.enabled=0 or xdebug.mode=off) rather than guessing.
PHPUnit 9 vs 10 config is a breaking change, not a tweak. <coverage><include> becomes <source><include>, forceCoversAnnotation becomes requireCoverageMetadata, and --coverage-filter is replaced by <source> config. Pasting a PHPUnit 10 block into a PHPUnit 9 project produces no coverage and no error message. Since this plugin is archived and won't track PHPUnit majors, this is exactly the kind of failure that shows up on your next framework bump.
Parallel + coverage is where reports get truncated. Pest's parallel runner and php-code-coverage have historically interacted badly. If you use --parallel, re-run the same suite serially once and compare the file/line totals before trusting the CI number. Cheaper still: run coverage on a single unsharded matrix leg and let the other legs run the fast non-coverage suite.
Memory and runtime will bite before accuracy does. Coverage instrumentation multiplies both. Set memory_limit=-1 (or 1G+) on the coverage job specifically, and expect the coverage stage to dominate CI wall-clock. Mitigations are engine-level and independent of this plugin: PCOV over Xdebug, path/testsuite filtering, one shard only.
Path arguments are relative to cwd, and the directory must exist. --coverage-clover build/coverage.xml fails or misplaces silently if build/ isn't there. mkdir -p build/coverage in the CI step.
Exclude deliberately, or you'll gate on code you can never cover. Migrations, service providers, generated stubs, config factories, and app/Console are typical. Use <exclude><directory> in config rather than @codeCoverageIgnore scattered through files — the config is reviewable in one place.
For inline exclusions, be precise. @codeCoverageIgnoreStart / @codeCoverageIgnoreEnd around a block, @codeCoverageIgnore on a single line, and skip logic for environment-dependent code. Blanket-ignoring a whole class is the beginning of a coverage number that means nothing.
--process-isolation will destroy coverage performance. If someone adds it for test-pollution reasons, move coverage off that job. Isolation spawns a PHP process per test; coverage instrumentation compounds it.
Strict coverage mode turns uncovered code into failures, not just red lines. If beStrictAboutCoverageMetadata / --strict-coverage is on, any #[CoversClass] test that executes code outside its declared scope is a risky test. Useful as a ratchet, brutal as a surprise during a migration.
There is no extension point worth building on. The plugin is a CLI shim over php-code-coverage; it exposes no report hooks, no formatters, no events. If you need a bespoke report, consume the Clover XML with a script or use php-code-coverage's API directly — don't try to extend this package.
Debugging a red gate locally:
composer show pestphp/pest pestphp/pest-plugin-coverage phpunit/phpunit
php -v | head -1 # confirms PCOV / Xdebug are loaded
XDEBUG_MODE=coverage vendor/bin/pest --coverage --debug tests/Feature/InvoiceTest.php
vendor/bin/pest --help # confirm which flags your version actually has
--debug shows the actual CLI invocation Pest built, which resolves most "the flag was ignored" mysteries in one run.
If you are on this plugin, record the debt in the repo. A single line in README or CONTRIBUTING — "coverage runs through pestphp/pest-plugin-coverage, which is archived; replaced by native --coverage on the next Pest upgrade" — plus a dated ticket and a named owner. Otherwise the next developer discovers it via composer audit and spends a sprint assuming it's load-bearing. Composer's abandoned-package audit will flag it in governance review regardless; better that the answer is already written down.
How can I help you explore Laravel packages today?