PHP Test Coverage with Xdebug and PCOV: Complete Setup Guide

PHP Test Coverage with Xdebug and PCOV: Complete Setup Guide

Code coverage is one of those metrics that developers either obsess over or dismiss entirely. The truth sits between those extremes: coverage data is genuinely useful for finding untested code paths, but a 90% coverage number tells you nothing about whether those tests are actually meaningful. Understanding what coverage measures — and what it doesn't — is the starting point for using it well.

This guide covers the full setup for measuring PHP test coverage with both Xdebug and PCOV, reading and acting on coverage reports, setting enforcement thresholds, and integrating coverage into CI pipelines.

Why Code Coverage Matters (and Its Limits)

Coverage tools instrument your code and record which lines, branches, and paths are executed during a test run. The primary use case is finding gaps — code that has no tests at all. A function that processes refund calculations and never appears in any test is a risk. Coverage makes that gap visible.

What coverage cannot tell you:

  • Whether your assertions are correct
  • Whether edge cases within covered lines are tested (a line can be "covered" by a test that doesn't assert anything meaningful about it)
  • Whether the behavior tested matches the business requirement
  • Whether integration between components works correctly

Use coverage as a gap-finder, not a quality indicator. A project with 60% meaningful coverage is healthier than one with 95% coverage from tests that only call methods without asserting results.

Xdebug: Installation and Configuration

Xdebug is the most widely used PHP debugging and profiling extension. It supports code coverage as one of its operating modes, though it carries overhead from its debugger infrastructure.

Install via PECL:

pecl install xdebug

On Ubuntu/Debian:

apt-get install php8.3-xdebug

On macOS with Homebrew:

pecl install xdebug
# or use a version-pinned package:
brew install php && pecl install xdebug

php.ini configuration for coverage:

[xdebug]
zend_extension=xdebug.so

; Set mode to coverage only — disables debugger overhead
xdebug.mode=coverage

; Do NOT set xdebug.mode=debug,coverage in CI — adds latency
; Use xdebug.mode=off in production

Xdebug 3.x changed the configuration model significantly. The old xdebug.remote_enable and xdebug.profiler_enable settings are gone. Everything is controlled through xdebug.mode. Valid modes: off, develop, coverage, debug, profile, trace, gcstats. Combine with commas: debug,coverage for local development where you want both.

Verify Xdebug is active:

php -v
# Should show: with Xdebug v3.x.x

php -r <span class="hljs-string">"var_dump(xdebug_info());"
<span class="hljs-comment"># Should show mode and coverage status

Performance note: Even with xdebug.mode=coverage, Xdebug adds 2x–5x overhead to test suite execution compared to a baseline PHP run without any extension. For large test suites this is significant. PCOV exists specifically to address this.

PCOV: Faster Coverage for CI

PCOV (PHP Code Coverage) is a self-contained coverage driver that does one thing: measure line coverage. It has no debugger, no profiler, no remote debugging support. This makes it 2x–10x faster than Xdebug for coverage-only runs, which is exactly what CI pipelines need.

Install via PECL:

pecl install pcov

On Ubuntu/Debian:

apt-get install php8.3-pcov

php.ini configuration:

[pcov]
extension=pcov.so
pcov.enabled=1

; Limit coverage to your source directory — dramatically improves performance
; by not tracking vendor/ and other directories
pcov.directory=/var/www/html/src

The pcov.directory setting is important. Without it, PCOV instruments every PHP file loaded during the test run, including all of Composer's autoloader files and vendor packages. Pointing it at your src/ directory limits instrumentation to code you actually own.

PCOV does not support:

  • Branch coverage
  • Path coverage
  • Remote debugging
  • Function/method tracing

If you need branch or path coverage data, you must use Xdebug. PCOV only measures line coverage.

PHPUnit Coverage Reports

PHPUnit (8.x and later) supports coverage through any installed driver — it auto-detects Xdebug or PCOV. You do not configure the driver in phpunit.xml; you configure which reports to generate and where.

phpunit.xml coverage configuration:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true">

    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory>tests/Feature</directory>
        </testsuite>
    </testsuites>

    <source>
        <include>
            <directory suffix=".php">src</directory>
        </include>
        <exclude>
            <directory>src/generated</directory>
            <file>src/bootstrap.php</file>
        </exclude>
    </source>

    <coverage>
        <report>
            <clover outputFile="coverage/clover.xml"/>
            <html outputDirectory="coverage/html" lowUpperBound="50" highLowerBound="90"/>
            <text outputFile="php://stdout" showUncoveredFiles="false" showOnlySummary="true"/>
        </report>
    </coverage>

</phpunit>

Generate coverage from the CLI:

# Text summary to stdout
php vendor/bin/phpunit --coverage-text

<span class="hljs-comment"># HTML report
php vendor/bin/phpunit --coverage-html coverage/html

<span class="hljs-comment"># Clover XML (for CI upload)
php vendor/bin/phpunit --coverage-clover coverage/clover.xml

<span class="hljs-comment"># All three at once (use phpunit.xml report config)
php vendor/bin/phpunit --coverage-text --coverage-html coverage/html --coverage-clover coverage/clover.xml

If neither Xdebug nor PCOV is loaded, PHPUnit prints:

No code coverage driver is available

Install one of the extensions and ensure the correct php.ini is loaded (php --ini shows which files are active).

Reading HTML Coverage Reports

The HTML report (coverage/html/index.php) breaks down coverage across three dimensions:

Line coverage — the percentage of executable lines executed at least once during the test run. This is the most commonly cited metric and what most thresholds enforce.

Branch coverage — whether both the true and false paths through conditional statements were executed. A line like $x = $condition ? 'a' : 'b' is line-covered if it runs once, but branch-covered only if both outcomes were reached.

Path coverage — all unique sequences through a function's control flow. Rarely enforced because the number of paths grows exponentially with the number of conditions.

In the HTML report:

  • Green lines — covered
  • Red lines — not covered (these need tests)
  • Orange lines — partially covered branches (Xdebug only)
  • Grey lines — not executable (comments, blank lines, class/function declarations)

Click into individual files to see exactly which lines are uncovered. A function showing zero coverage is the most actionable finding — it means the feature has no tests at all.

What to look for first:

  1. Entire files with 0% coverage — untested classes or modules
  2. Methods that drop to 0% while their class is otherwise covered — error paths and edge cases missed
  3. Files with very low branch coverage but high line coverage — conditional logic not fully exercised

Setting Minimum Coverage Thresholds

PHPUnit 10+ enforces coverage thresholds directly in phpunit.xml. Tests fail if coverage drops below the configured minimum:

<coverage>
    <report>
        <clover outputFile="coverage/clover.xml"/>
        <html outputDirectory="coverage/html"/>
        <text outputFile="php://stdout" showOnlySummary="true"/>
    </report>
</coverage>

<!-- PHPUnit 10+ threshold enforcement -->
<coverage>
    <include>
        <directory suffix=".php">src</directory>
    </include>
</coverage>

For PHPUnit 10+, use the --coverage-filter CLI option or the source block and pass --min-coverage (if your version supports it), or use a coverage checking package:

# Using infection/codecoverage-checker or roave/you-are-using-it-wrong alternatives
<span class="hljs-comment"># The direct PHPUnit approach in v10+ via phpunit.xml:
<!-- phpunit.xml — works with PHPUnit 10.x -->
<phpunit>
    <!-- ... -->
    <coverage>
        <report>
            <clover outputFile="coverage/clover.xml"/>
        </report>
    </coverage>
    <!-- Fail build if lines < 80% -->
    <coverage processUncoveredFiles="true">
        <include>
            <directory suffix=".php">src</directory>
        </include>
    </coverage>
</phpunit>

For enforcing a threshold as a CI gate, the most reliable cross-version approach uses a dedicated checker:

composer require --dev richardregeer/phpunit-coverage-check

# Add to CI after phpunit run:
php vendor/bin/coverage-check coverage/clover.xml 80

This command exits with code 1 if line coverage is below 80%, failing the CI job.

A pragmatic threshold strategy:

  • New projects: Start at 70%, raise by 5% each sprint
  • Legacy projects: Set the threshold at current coverage minus 2% — prevents regression without requiring a big-bang testing effort
  • Critical paths (payments, auth): Enforce 90%+ in specific directories using directory-level analysis

Pest PHP Coverage Integration

Pest, the modern PHP testing framework built on PHPUnit, uses the same coverage drivers and reports. Run coverage the same way:

# With Pest
php vendor/bin/pest --coverage

<span class="hljs-comment"># With minimum threshold
php vendor/bin/pest --coverage --min=80

<span class="hljs-comment"># Generate clover
php vendor/bin/pest --coverage-clover coverage/clover.xml

<span class="hljs-comment"># HTML report
php vendor/bin/pest --coverage-html coverage/html

The --min flag in Pest is cleaner than PHPUnit's configuration approach — it exits non-zero if coverage is below the value and prints a clear message:

  Coverage: 84.3% (minimum: 80%)  ✓

Pest's coverage output in the terminal is also more readable, showing coverage per file inline with test results.

Codecov and Coveralls CI Integration

Uploading coverage data to a service like Codecov or Coveralls gives you coverage trending, PR comments showing coverage changes, and badges for README files.

Codecov setup:

  1. Sign in at codecov.io with your GitHub/GitLab account
  2. Add your repository
  3. Copy the upload token from the Codecov dashboard

In CI:

- name: Upload coverage to Codecov
  uses: codecov/codecov-action@v4
  with:
    token: ${{ secrets.CODECOV_TOKEN }}
    files: coverage/clover.xml
    flags: unittests
    name: codecov-umbrella
    fail_ci_if_error: true

Coveralls setup:

composer require --dev php-coveralls/php-coveralls
- name: Upload coverage to Coveralls
  env:
    COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
  run: php vendor/bin/php-coveralls --coverage_clover=coverage/clover.xml --json_path=coverage/coveralls.json -v

Both services show coverage diffs per pull request, which makes it easy to catch PRs that add code without tests.

GitHub Actions Workflow: Full Coverage Pipeline

# .github/workflows/coverage.yml
name: Test Coverage

on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  coverage:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP with PCOV
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: pcov, pdo_mysql, mbstring
          ini-values: pcov.directory=src
          coverage: pcov

      - name: Install Composer dependencies
        run: composer install --no-interaction --prefer-dist --no-progress

      - name: Run tests with coverage
        run: |
          php vendor/bin/phpunit \
            --coverage-clover coverage/clover.xml \
            --coverage-html coverage/html \
            --coverage-text \
            --colors=never

      - name: Enforce minimum coverage
        run: php vendor/bin/coverage-check coverage/clover.xml 80

      - name: Upload HTML coverage report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/html/
          retention-days: 14

      - name: Upload to Codecov
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          files: coverage/clover.xml
          fail_ci_if_error: false

      - name: Post coverage comment on PR
        if: github.event_name == 'pull_request'
        uses: davelosert/vitest-coverage-report-action@v2
        with:
          coverage-file: coverage/clover.xml

The shivammathur/setup-php action is the recommended way to configure PHP in GitHub Actions. The coverage: pcov parameter activates PCOV and sets the correct php.ini directives automatically.

Adding a coverage badge to README:

Once Codecov is set up:

[![Coverage](https://codecov.io/gh/yourorg/yourrepo/branch/main/graph/badge.svg)](https://codecov.io/gh/yourorg/yourrepo)

When to Use Xdebug vs PCOV

Scenario Use
Local development with step debugging Xdebug (xdebug.mode=debug,coverage)
CI pipeline, line coverage only PCOV
Branch and path coverage reports Xdebug
Large test suite where CI time matters PCOV
Profiling slow tests Xdebug (xdebug.mode=profile)
Docker-based CI PCOV (simpler, single-purpose)

A common pattern is to use Xdebug locally (for debugging) and PCOV in CI (for speed). The shivammathur/setup-php action makes this easy via the coverage: parameter — set it to pcov in the CI workflow and developers use whatever they have locally.

Common Pitfalls

Abstract classes and interfaces show as uncovered. These have no executable code — they cannot be directly instantiated and their methods are implemented by concrete classes. Do not include them in your coverage source unless you have concrete implementations being tested.

Static analysis tools conflict with coverage. PHPStan and Psalm with strict mode sometimes fail on coverage-instrumented code or throw false positives related to Xdebug's internal functions. Run static analysis in a separate CI job that does not load Xdebug or PCOV.

Vendor packages inflate coverage numbers. Always configure the source block in phpunit.xml to include only your src/ directory. Without this, PHPUnit may count covered vendor code toward your project's coverage percentage.

Tests that use @covers annotations. PHPUnit supports @covers ClassName::methodName annotations to attribute test methods to specific production code. Without @covers, PHPUnit counts any code executed during a test (including called dependencies) as covered by that test. This inflates coverage. With @covers, only the annotated method is attributed. Enforce @covers in phpunit.xml with forceCoversAnnotation="true" for the most accurate measurement (though this is often too strict for projects starting out).

Coverage with final classes. Mocking final classes (for isolation) requires reflection or proxy-based mocks. If tests use createMock() on a final class without a proper mock library, the real class runs, which is actually better for coverage accuracy but may complicate test isolation.

Cached coverage data. PHPUnit caches coverage data in .phpunit.cache/. If coverage numbers seem wrong after adding tests, delete this directory:

rm -rf .phpunit.cache

Environment-specific code paths. Code that only runs in production (feature flags, environment checks) is excluded by definition from test coverage because tests run in a testing environment. Mark these explicitly with @codeCoverageIgnore or @codeCoverageIgnoreStart/@codeCoverageIgnoreEnd:

// @codeCoverageIgnoreStart
if (getenv('APP_ENV') === 'production') {
    // Production-only initialization
}
// @codeCoverageIgnoreEnd

Wrapping Up

PHP test coverage measurement is a two-tool problem: Xdebug for local development where you also need debugging capabilities, and PCOV for CI where you need coverage data fast. Both integrate identically with PHPUnit and Pest — the only difference is the extension you load.

The actionable workflow: install PCOV in CI, generate clover.xml on every push, upload to Codecov for PR visibility, and enforce a minimum threshold that prevents regression. Use the HTML report locally to identify specific uncovered paths and write targeted tests to close them.

Coverage is a floor, not a ceiling. A project at 80% with meaningful assertions in every test is in better shape than one at 95% with tests that just instantiate classes and check that no exception is thrown.

HelpMeTest adds runtime monitoring and AI-powered test generation on top of your coverage metrics — catching regressions in production that static coverage data can never surface. Try free at helpmetest.com.

Read more