dvdoug/behat-code-coverage
Behat Code Coverage is a Behat extension that generates PHP code coverage reports while running your test suite. It wraps php-code-coverage—the same library used by PHPUnit—so reports are familiar and interoperable with existing tooling.
composer require --dev dvdoug/behat-code-coverage
You need three things before the extension does anything: a Behat suite, a coverage driver in PHP, and an entry in behat.yml. The driver is the part people forget — the extension is a thin wrapper over phpunit/php-code-coverage, so it will refuse to run without Xdebug (xdebug.mode=coverage), PCOV (pcov.enabled=1), or phpdbg -qrr.
Register the extension under the profile you want to instrument. The current namespace is LeanPHP\Behat\CodeCoverage (the old DVDoug\... name was dropped in 4.0.0):
default:
extensions:
LeanPHP\Behat\CodeCoverage\Extension:
filter:
include:
directories:
'app': ~
reports:
text: ~
html:
target: build/coverage
Then just run vendor/bin/behat. If you'd rather not pay the coverage cost on every run, put the block in a coverage profile and invoke it explicitly with vendor/bin/behat --profile=coverage.
First use case for a Laravel dev: you have (or are inheriting) a Behat acceptance suite driving your app through HTTP contexts, and you want to know which parts of app/ the scenarios actually exercise. Start with the text report to get a summary line, then switch to html to click through app/Http/Controllers, app/Actions, etc. and see what is dead code.
Where to look first: https://www.behat.cc/ for the full config schema, since the README is effectively a badge wall. The filter and reports keys are the only two you'll touch on day one.
Profile-per-report. Keep the default profile clean (CI test runs on every push) and define coverage and coverage-ci profiles. Locally you want html + text; in CI you want clover only, because HTML generation in a pipeline is wasted I/O.
Include only what you own. The filter block replaces the old whitelist terminology. Include app/ (and src/ if you have a package), exclude app/Console, app/Providers, database/migrations, and generated code. Because php-code-coverage computes percentages over the filtered set, an over-broad include silently depresses your number with thousands of framework-adjacent lines.
Enable includeUncoveredFiles. Without it, files that were never loaded during the run don't appear at all, and a controller that is 0% covered looks identical to one that doesn't exist. Turn it on for an honest denominator:
filter:
include:
directories:
'app': ~
exclude:
directories:
'app/Console': ~
'database/migrations': ~
Multiple report formats in one pass. reports (plural, since 4.1.0) lets you emit text, html, clover, cobertura, openclover, and serialized simultaneously. The practical combination is text for the console summary, clover for CI upload (Codecov, SonarQube, GitLab all ingest Clover), and html only when someone is actively investigating.
Threshold gating lives outside the extension. There is no failOnCoverageBelow option. If you want a red build on regression, parse build/coverage/clover.xml in a follow-up step, or generate a serialized report and merge/compare with phpcov. A common pattern is a tiny Artisan command or a Composer script that reads the Clover line-rate attribute and exits non-zero.
Pair with Laravel's PHPUnit/Pest coverage, don't replace it. Laravel's own php artisan test --coverage covers unit and feature tests; this extension covers Behat only. Run them as separate CI jobs with separate output directories, and merge with phpcov merge if you need a single number for a dashboard. Trying to make one report serve both suites is where people burn a day.
Cache the static analysis. Since 5.1.0 you can point the cache key at a directory, otherwise it defaults to sys_get_temp_dir() . '/behat-code-coverage-cache'. On CI, cache that path between runs — it visibly cuts the first-report latency on larger app/ trees.
Coverage only sees the process it is in. This is the single biggest trap for Laravel developers. If your Behat suite uses Mink with the Selenium/Chrome driver, your app is executing inside PHP-FPM or artisan serve — a different process — and Xdebug in the Behat process records nothing useful. The removed RemoteXDebug driver used to paper over this and was dropped in 5.0.0. For meaningful coverage you need in-process request handling: the Goutte / Symfony BrowserKit driver, or contexts that dispatch requests through Laravel's kernel directly. If your suite is browser-driven, this package is the wrong tool.
Xdebug must be in coverage mode. xdebug.mode=develop alone will leave the extension warning that no driver was found. It writes those warnings to stderr (since 5.3.5), so a CI log filter on stdout may hide them. PCOV is roughly 2–3× faster than Xdebug for this workload and is what shivammathur/setup-php users should set via coverage: pcov, with pcov.directory=app to keep the scope tight.
PCOV and Xdebug loaded together used to be a mess; 5.3.1/5.3.2.1 fixed the driver selection, but if you see erratic percentages, check php -m | grep -E 'pcov|xdebug' and disable one.
processUncoveredFiles is gone in php-code-coverage v10. It is deprecated here and no-ops upstream. If you're copying an old behat.yml from a blog post, drop it; use includeUncoveredFiles instead.
whitelist, report, forceCoversAnnotation, mapTestClassNameToCoveredClassName are all dead keys. They were removed in 5.0.0. The extension validates the schema, so a stale key fails loudly rather than silently — helpful, but only after you've found it.
The php report was renamed to serialized in 5.4.2. Old configs still work for now, but write serialized in new configs; the upstream terminology shift (and the new default HTML colours) came from php-code-coverage v14.
Version lockstep. The package tracks phpunit/php-code-coverage majors (v10 → 5.3, v11 → 5.3.3, v12 → 5.3.7, v13 → 5.4.1, v14 → 5.4.2) and supports Symfony 5–7. If you're on an older Laravel (and therefore an older Symfony), Composer will resolve you to a much older release of this package without complaint — pin explicitly and read the changelog when you upgrade Laravel.
Relative paths were only fixed in 5.3.4. If report targets resolve into bizarre locations, upgrade before debugging your own config. Targets are relative to the directory Behat is invoked from, not the config file.
Use --no-coverage for non-instrumented runs. The flag is honoured (after being broken and fixed twice, in 5.0.1 and 5.1.1) and since 5.3.6 it suppresses the driver-missing warnings too. Useful when the same pipeline runs Behat without a coverage driver on a matrix leg.
How can I help you explore Laravel packages today?