A Laravel suite that runs in twenty seconds can take several minutes the moment you add --coverage. The usual reaction is to blame the test suite, but the real cost is almost always the extension collecting the data. Coverage needs a driver, and for Laravel that means PCOV vs Xdebug — two extensions with very different price tags.
PCOV vs Xdebug: how PHPUnit picks a coverage driver#
You do not choose the driver on the command line. PHPUnit delegates collection to phpunit/php-code-coverage, and that package picks for you. The selection logic is thirty lines long and worth reading, because it explains most of the confusion:
// vendor/phpunit/php-code-coverage/src/Driver/Selector.php
public function select(Filter $filter, Granularity $granularity = Granularity::Line): Driver
{
$runtime = new Runtime;
// PCOV is only ever considered for plain line coverage
if ($granularity === Granularity::Line && $runtime->hasPCOV()) {
return new PcovDriver($filter);
}
if ($runtime->hasXdebug()) {
$driver = new XdebugDriver($filter);
$driver->setGranularity($granularity);
return $driver;
}
throw new NoSupportedDriverAvailableException($granularity);
}
Two things fall out of that. PCOV wins when both extensions are available — but only at line granularity. The second you set pathCoverage="true" in phpunit.xml, the granularity changes, PCOV is skipped entirely, and Xdebug becomes mandatory.
hasPCOV() is stricter than "is the extension installed":
// vendor/sebastian/environment/src/Runtime.php
public function hasPCOV(): bool
{
return $this->isPHP() && extension_loaded('pcov') && ini_get('pcov.enabled');
}
PCOV loaded with pcov.enabled=0 is invisible to PHPUnit. That is a feature, not a bug — it is how you keep the extension in your php.ini permanently and pay nothing on normal runs.
This is line coverage, incidentally. It is a different question from whether your code is typed, which is what Pest's Type Coverage plugin measures, and a different question again from whether your assertions are any good — for that you want mutation testing rather than a coverage percentage.
PCOV vs Xdebug: what each driver actually costs#
Sebastian Bergmann benchmarked this properly in PCOV or Xdebug?, running his raytracer suite on PHP 8.3.4 with Xdebug 3.3.1, PCOV 1.0.11 and PHPUnit 11.0.7. The shape of the result is what matters:
| Configuration | Suite runtime |
|---|---|
| Neither extension loaded | 13s |
Xdebug loaded, xdebug.mode=off |
no measurable overhead |
xdebug.mode=debug |
33s |
xdebug.mode=develop |
35s |
xdebug.mode=coverage |
50s |
| Xdebug, path coverage requested | 127s |
| PCOV enabled | 15s |
Three conclusions I keep coming back to.
Merely having Xdebug installed costs nothing. With xdebug.mode=off the overhead is not measurable. The version of this advice that says "Xdebug slows your tests just by existing" is wrong, and it sends people uninstalling an extension they should keep.
An active mode costs a lot. Xdebug's default is xdebug.mode=develop, so an unconfigured install lands you at roughly 2.7× on every run — including the runs that have nothing to do with coverage.
The cost is the mode, not the --coverage flag. Bergmann found it made no difference whether PHPUnit asked for data or not; xdebug.mode=coverage charges you either way, and so does pcov.enabled=1. That is why the fix is never "stop passing --coverage". The fix is to control the ini.
Install PCOV for Laravel coverage in Herd#
Herd ships Xdebug inside the application bundle but leaves it switched off, and it does not ship PCOV at all. You compile PCOV against a Homebrew PHP, then point Herd's php.ini at the resulting .so.
brew install php # only used to build the extension
pecl install pcov # writes pcov.so into the pecl directory
php -i | grep extension_dir # find where it landed
Herd's per-version ini lives at ~/Library/Application Support/Herd/config/php/<version>/php.ini. Add PCOV there and leave it disabled by default:
; Apple Silicon path shown; use /usr/local/lib/php/pecl on Intel
extension=/opt/homebrew/lib/php/pecl/20240924/pcov.so
pcov.enabled = 0
pcov.directory = app
pcov.exclude = "~(vendor|tests)~"
Then restart Herd so the CLI binary picks the change up:
herd restart
php -m | grep -i pcov # confirm it is loaded
pcov.enabled = 0 means zero cost on composer test. When you actually want a report, flip it for that one command:
php -d pcov.enabled=1 artisan test --coverage --min=80
If you leave pcov.directory unset, PCOV guesses — it looks for src, then lib, then app in the working directory, and falls back to the current directory, which means it will happily instrument your whole vendor/ tree. Set it explicitly. The rest of a sane Herd setup is covered in setting up a fast local Laravel environment with Herd.
Xdebug stays available for step debugging in the same ini, as long as you keep it parked:
; ls /Applications/Herd.app/Contents/Resources/xdebug/ for your version and arch
zend_extension=/Applications/Herd.app/Contents/Resources/xdebug/xdebug-84-arm64.so
xdebug.mode = off
xdebug.mode can only be set in an ini file read at startup — not in .user.ini, not via php_admin_value. To switch it on for one command, use the environment variable, which takes precedence:
XDEBUG_MODE=debug php artisan test --filter=CheckoutTest
Keep PCOV as the default in Docker#
In a container the same rule applies: install both, enable neither by default. Add PCOV in the build and ship a disabled ini.
FROM php:8.4-cli-alpine
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \
&& pecl install pcov \
&& apk del .build-deps
# Loaded but inert - zero cost on normal test runs
RUN printf "extension=pcov.so\npcov.enabled=0\npcov.directory=app\n" \
> /usr/local/etc/php/conf.d/pcov.ini
Enable it per-invocation rather than baking it in:
docker compose run --rm app php -d pcov.enabled=1 artisan test --coverage
If your image already uses a multi-stage build, put pecl install pcov in the stage that has the build toolchain so it never reaches the runtime layer — the pattern is the same one described in optimising Laravel Docker images with multi-stage builds.
Run coverage in GitHub Actions on one job only#
shivammathur/setup-php takes a coverage input with three values: xdebug, pcov, or none. The default installs a driver you probably do not want, so be explicit on every job.
Normal test jobs get none:
- name: Installing PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
coverage: none # nothing to slow the matrix down
tools: composer:v2
One job gets PCOV:
coverage:
name: Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
coverage: pcov # installs PCOV and disables Xdebug
tools: composer:v2
- run: composer install --no-progress --prefer-dist --optimize-autoloader
- name: Coverage report
run: php artisan test --coverage --min=80
That single change matters more on a sharded pipeline than a single-job one, because coverage: none compounds across every shard — the setup in cutting Laravel CI from 12 minutes to 3 with Pest sharding pays the driver tax once per shard if you get this wrong. Pair it with caching Composer and NPM in GitHub Actions and the coverage job stops being the slow one.
If you genuinely need Xdebug in CI — because you want branch or path coverage — use coverage: xdebug on that job alone and set the mode explicitly:
- name: Path coverage
run: XDEBUG_MODE=coverage php artisan test --coverage
Gotchas and Edge Cases#
PECL is deprecated, but pecl install pcov is still the way. PECL now points people at PIE, the PHP Foundation's replacement installer. PCOV has not published the GitHub release artifacts PIE needs — issue #131 is still open — so pie install pecl/pcov fails today. Stick with pecl install pcov and revisit later.
PCOV's maintenance story has moved on. Bergmann's 2024 article flagged PCOV as unmaintained with a 2021 release. That has since changed: pcov 1.0.12 shipped in December 2024 with PHP 8.4 compatibility, maintained by Joe Watkins and Remi Collet, and distributions have rebuilt it for PHP 8.5. It is not a dead extension, but it is a quiet one — that is a real consideration if you are picking a driver for the next five years.
Path coverage silently changes which driver you get. Setting pathCoverage="true" on the <coverage> element does not just add data, it rules PCOV out. If you have PCOV in CI and someone turns path coverage on, you get a "no code coverage driver available" failure rather than a slower run.
<source> controls what is measured, <coverage> controls how it is reported. In PHPUnit 10 and later these are separate elements. Coverage inclusion belongs in <source>:
<source>
<include>
<directory>app</directory>
</include>
</source>
Parallel runs need the driver on every worker. php artisan test --parallel --coverage works — Paratest merges the per-process data — but each worker process is a fresh PHP process reading the same ini, so a -d pcov.enabled=1 on the parent is inherited fine while an interactive XDEBUG_MODE export is easy to lose. Setting it in the command itself, not the shell, avoids a confusing "no driver" error from a single worker.
match expression coverage is unreliable in both drivers. The PHPUnit manual warns that a whole match may be reported as covered even when only one arm ran. Both extensions collect at the bytecode level, and compiled opcodes are not a one-to-one map back onto your source lines. Do not chase the last 2% on a file full of match.
PCOV and Xdebug cannot both be active. With pcov.enabled=1, PCOV overrides the Zend executor and interoperability with Xdebug, phpdbg and Blackfire is off the table. Installed together is fine; enabled together is not.
Wrapping Up#
Install both extensions, disable both by default, and turn exactly one on for the command that needs it: PCOV for the coverage percentage you check on every PR, Xdebug for the rare afternoon you want path coverage or a breakpoint. Then set coverage: none on every CI job except the one that publishes a report.
Once the driver stops being the bottleneck, the next win is usually the pipeline shape — sharding the Pest suite across parallel jobs, and deciding which PHP and database combinations actually need a matrix job.
FAQ#
Is PCOV faster than Xdebug for code coverage?
Yes, substantially. On Sebastian Bergmann's benchmark a suite that takes 13 seconds with no driver takes 15 seconds under PCOV and 50 seconds under xdebug.mode=coverage — roughly a 3× difference for the same line coverage report. The gap comes from PCOV being a purpose-built coverage extension of under a thousand lines, while Xdebug carries the machinery of a full debugger.
Which coverage driver should I use with Pest?
Pest requires Xdebug 3.0+ or PCOV and does not care which. Use PCOV for --coverage and --min on everyday runs and in CI, because that is line coverage and PCOV is faster at it. Reach for Xdebug when you want branch or path coverage, or when you are debugging rather than measuring.
Can I use PCOV and Xdebug at the same time?
You can have both installed, but only one can be active. When pcov.enabled=1 PCOV takes over the Zend executor, and interoperability with Xdebug, phpdbg and Blackfire is not possible. The workable setup is both loaded with pcov.enabled=0 and xdebug.mode=off, enabling whichever you need per command.
Why are my Laravel tests slow with Xdebug installed?
Almost certainly the mode, not the installation. Xdebug defaults to xdebug.mode=develop, which costs you on every single run whether or not you asked for anything. Set xdebug.mode=off in your php.ini and use the XDEBUG_MODE environment variable to switch it on for the one command that needs it.
Does PCOV support branch coverage?
No. PCOV collects line coverage only. Branch and path coverage require Xdebug, and php-code-coverage enforces this in its driver selection — asking for path granularity skips PCOV even when it is enabled and available. Be aware that path coverage is expensive: the same benchmark suite went from 13 seconds to 127 seconds with path coverage on.