DEV Community

Cover image for PCOV vs Xdebug for Coverage: The CI Speed Difference Nobody Measures
Gabriel Anhaia
Gabriel Anhaia

Posted on

PCOV vs Xdebug for Coverage: The CI Speed Difference Nobody Measures


You open the CI logs to find out why a pull request took nine minutes to go green. The test suite itself runs in 40 seconds locally. In CI it's a different story. You scroll up and find the culprit: the coverage job. Same tests, but wrapped in Xdebug, and now every assertion crawls.

Most teams never separate those two numbers. They see "tests: 9 minutes" and assume the tests are slow. They aren't. The coverage driver is. And the fix is a one-line change to your CI config that almost nobody makes because they've never measured where the time goes.

PCOV is a coverage-only driver that does one job and skips everything Xdebug does on top.

Why Xdebug coverage is slow

Xdebug is a debugger first. Step debugging, stack traces, profiling, variable inspection. To do that, it hooks deep into the Zend engine. It installs its own opcode handlers and gets called on function entry, function exit, and on many individual operations.

Code coverage is one mode among several. When you run PHPUnit with XDEBUG_MODE=coverage, Xdebug records which lines executed by riding the same instrumentation machinery it uses for everything else. That machinery is general-purpose. It carries the cost of being able to do far more than count lines.

The result: every function call in your suite pays an Xdebug tax, whether or not that call has anything to do with coverage. On a small suite you won't notice. On a suite with tens of thousands of assertions and a deep object graph, the tax compounds into minutes.

There's a second cost that bites in CI specifically. If Xdebug is loaded at all and its mode isn't off, it slows down the whole PHP process, not just the coverage run. A lot of Docker base images ship with Xdebug enabled by default. Your tests are paying for a debugger you never asked for.

PCOV: a driver that does one thing

PCOV is a coverage-only extension, written by Joe Watkins. It records line coverage and nothing else. No step debugging, no profiler, no stack traces. Because it isn't trying to be a debugger, it doesn't install the heavy per-operation hooks Xdebug needs.

That narrow scope is the whole point. PCOV watches the files you tell it to watch, records which lines ran, and stays out of the way of everything else. PHPUnit already knows how to talk to it. The phpunit/php-code-coverage library detects an available driver and uses it, so you don't change a line of test code.

Install it:

pecl install pcov
Enter fullscreen mode Exit fullscreen mode

Then load it and point it at your source directory:

; pcov.ini
extension=pcov.so
pcov.enabled=1
pcov.directory=src
Enter fullscreen mode Exit fullscreen mode

The pcov.directory setting matters for speed. It scopes collection to your source tree. Without it, PCOV also inspects vendor code and test files it has no reason to measure. Set it to the directory your <source> element in phpunit.xml already covers.

Measure it on your own suite

Here's the part the title promises. Nobody measures this, so measure it. You don't need a benchmark harness. You need the time builtin and two runs.

First, the Xdebug run. Xdebug 3 gates coverage behind a mode, so set it explicitly:

XDEBUG_MODE=coverage \
  time vendor/bin/phpunit --coverage-text
Enter fullscreen mode Exit fullscreen mode

Then the PCOV run, with Xdebug's mode turned off so it can't interfere:

XDEBUG_MODE=off \
  time vendor/bin/phpunit --coverage-text
Enter fullscreen mode Exit fullscreen mode

Run each a couple of times to warm the filesystem cache and take the lower number. Compare the wall-clock time. On a suite large enough to matter, the PCOV run finishes in a fraction of the Xdebug time. The exact ratio depends on how call-heavy your code is, which is why measuring your suite beats trusting a number from someone else's.

While you're there, run a third time with coverage off entirely:

XDEBUG_MODE=off \
  time vendor/bin/phpunit --no-coverage
Enter fullscreen mode Exit fullscreen mode

That's your floor: the cost of the tests themselves. The gap between this and the PCOV run is what coverage actually costs you. The gap between this and the Xdebug run is what you've been paying by default.

Wiring PCOV into CI

If you use shivammathur/setup-php, this is a single field. The action installs and configures the driver for you.

# .github/workflows/tests.yml
name: Tests

on:
  pull_request:
    paths:
      - '**.php'
      - 'phpunit.xml'
      - 'composer.lock'

jobs:
  tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          coverage: pcov

      - name: Install
        run: composer install --no-progress --prefer-dist

      - name: Tests with coverage
        run: >
          vendor/bin/phpunit
          --coverage-clover=coverage.xml
Enter fullscreen mode Exit fullscreen mode

The one line that does the work is coverage: pcov. Swap it for coverage: xdebug when you need Xdebug's extra modes, or coverage: none on jobs that don't produce a report at all. Most matrix legs don't need coverage; give them none and they run at full speed.

A common shape: one job on your primary PHP version runs PCOV and uploads the report. The rest of the matrix runs coverage: none and only checks that the tests pass. You get one coverage number without paying the driver tax on every leg.

When you still need Xdebug

PCOV records line coverage. That's the number most teams report and most gates enforce. But line coverage isn't the only kind, and this is where PCOV stops.

Xdebug also collects branch and path coverage. Branch coverage asks whether both sides of every if ran. Path coverage asks whether every route through a method ran. PCOV can't produce either. If your phpunit.xml has pathCoverage="true", or you generate an HTML report that shows branch percentages, you need Xdebug for that run.

You also keep Xdebug for the obvious reason: step debugging. PCOV is no help when you want to set a breakpoint and inspect a variable. The two extensions solve different problems, and a healthy setup has both installed but only one active at a time.

The practical split:

  • CI coverage gate on line percentage: PCOV. It's the fast path and the common case.
  • Branch or path coverage reports: Xdebug, on a dedicated job that runs less often (nightly, or on merge to main).
  • Local step debugging: Xdebug, mode set to debug, never coverage during normal test runs.

Mutation testing tools like Infection also prefer PCOV for the coverage pass because the speed gain is large across the many runs a mutation session performs. Check your tool's docs; most detect PCOV automatically.

The one gotcha: don't load both at once

If both extensions are loaded and Xdebug's mode includes coverage, the coverage library has to pick one, and you can end up paying the Xdebug tax even though you installed PCOV. The rule is simple to state: when you want PCOV, make sure XDEBUG_MODE=off for that run.

You can check what's active before you trust a benchmark:

php -m | grep -i 'pcov\|xdebug'
php -i | grep 'xdebug.mode'
Enter fullscreen mode Exit fullscreen mode

If PCOV shows up and Xdebug's mode is off, PCOV is doing the work. That two-second check has saved more than one person from concluding "PCOV didn't help" when Xdebug was quietly running the whole time.

The coverage driver is a boundary concern. It's tooling that sits at the edge of your build, measuring your code without being part of it. Keeping that kind of decision at the edge, swappable with one config line and invisible to the code under test, is the same instinct that keeps a database or a queue from leaking into your domain. That instinct, applied to the shape of a whole application, is what Decoupled PHP is about: the ports and adapters that let you change the outside without touching the inside.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)