Skip to content
Article

CI Test Coverage for Symfony Integration Middleware: PCOV in CI, Xdebug Locally

When a Symfony middleware sits between your storefront and your ERP or order systems, every change carries cross-system risk. Here is how to collect fast coverage in CI with PCOV, publish JUnit and Cobertura reports, and keep Xdebug available for local debugging.
TLDR
  • Use PCOV in CI for low-overhead line coverage on every pipeline; it is coverage-only and should not be loaded alongside Xdebug.
  • Publish both JUnit and Cobertura artifacts: one shows test outcomes, the other shows which code those tests exercised.
  • Keep the coverage command in a committed script so CI and developers run exactly the same thing.
  • Keep Xdebug installed locally in trigger mode so it stays idle by default and is ready for step debugging when a sync misbehaves.
  • Treat production deployment as a separate, manual step restricted to the main branch.

Many ecommerce teams eventually add a middleware layer: a Symfony application that sits between the storefront and the ERP, order management, or inventory systems behind it. It translates products, prices, stock levels, customers, and orders from one system's model into another's, usually through a mix of webhooks, scheduled jobs, and asynchronous queues.

That position makes the middleware one of the riskiest pieces of code in the stack. A change to one mapping can quietly affect inventory, pricing, variants, and order flow at the same time. The practical question is how to get trustworthy regression signals on every change without making day-to-day development slower or giving up the ability to step through a tricky sync locally.

A pattern that works well is to split the job in two: collect coverage in CI with PCOV, and keep Xdebug for interactive debugging on developer machines. This article explains why, and how to set it up.

Why integration middleware needs stronger regression signals

Middleware concentrates business rules and data translation between platforms. A single product update may involve inventory conditions, price lists, storefront metafields or attributes, variant mapping, and messages dispatched to a queue. An order workflow may touch customer records, notifications, and downstream fulfillment requirements.

Because so many workflows share the same service layer, "the deployment succeeded" is an incomplete signal. Teams need to know whether the tests passed, which code those tests actually exercised, and which branch produced that evidence. This is especially true for storefront-to-ERP integrations, such as a Shopify and STORIS integration, where one change can cross storefront, ERP, and warehouse concerns.

PCOV vs. Xdebug: use each where it is strongest

PHPUnit can collect code coverage through either PCOV or Xdebug. They overlap, but they are built for different jobs.

  • PCOV is a coverage-only extension. It does one thing, collecting line coverage, with low overhead, which makes it a good fit for running the full suite on every pipeline. It does not provide step debugging, and it does not report branch or path coverage.

  • Xdebug is a full debugging toolkit. With xdebug.mode=debug it supports breakpoints and step-through debugging from an IDE. With xdebug.mode=coverage it can also collect coverage, including branch and path coverage, but it typically adds noticeably more runtime overhead than PCOV.

PCOV's own documentation notes that it is not designed to run alongside Xdebug, so pick one driver per environment rather than loading both. When both happen to be available, PHPUnit's coverage library prefers PCOV, which can be confusing if you expected Xdebug to be collecting coverage.

Need

Recommended workflow

Why it matters

Routine pipeline feedback

Run the test suite with PCOV enabled in CI.

Coverage becomes part of every automated check without slowing pipelines the way a full debugger would.

Test result visibility

Publish JUnit XML as a CI artifact.

The CI interface can show which tests ran, failed, or slowed down.

Coverage visibility

Publish a Cobertura XML report as a CI artifact.

Merge request views and coverage tools can relate executed lines back to source files.

Complex local investigation

Use Xdebug in debug mode during local development.

Developers can pause execution and inspect payloads, service state, and API responses while diagnosing a mapping, event, or sync problem.

Occasional deeper coverage analysis

Run Xdebug in coverage mode locally when you need branch or path data.

Branch coverage is useful for dense mapping logic, but it does not need to run on every pipeline.

This split avoids treating coverage collection and interactive debugging as the same activity. CI needs fast, consistent, automated evidence. Local diagnosis needs an engineer who can inspect inputs and state in detail.

Put the coverage command behind a repeatable script

Rather than burying a long PHPUnit command inside pipeline YAML, keep it in a small script committed to the repository, for example bin/run_tests_with_coverage.sh. CI calls one command, developers can run exactly the same thing locally, and when paths, report formats, or PHP settings change, there is one focused place to update.

#!/usr/bin/env bash
set -euo pipefail

# Fail fast if the coverage driver is missing instead of silently skipping coverage.
php -m | grep -qi '^pcov$' || { echo "PCOV extension is not loaded" >&2; exit 1; }

mkdir -p var/reports

bin/phpunit \
  --log-junit var/reports/junit.xml \
  --coverage-cobertura var/reports/cobertura.xml \
  --coverage-text --colors=never

A few notes on this script:

  • bin/phpunit is the Symfony PHPUnit Bridge entry point in many Symfony projects. If your project calls vendor/bin/phpunit directly, use that instead.

  • PCOV settings belong in the PHP configuration of the CI image rather than on the command line, because the PHPUnit Bridge may launch PHPUnit in a separate PHP process. Setting pcov.directory=src limits instrumentation to your application code and keeps overhead down. If it is not set, PCOV tries to detect a src, lib, or app directory on its own.

  • Define which code belongs in the report in phpunit.xml.dist (the coverage include/source section), so vendor code and generated files stay out of the numbers.

  • --coverage-cobertura requires PHPUnit 9.4 or later. The text summary is printed so CI can parse an overall percentage from the job log.

Wire the reports into CI

The examples here use GitLab CI, but the same structure carries over to GitHub Actions or any CI system that accepts JUnit and Cobertura reports. Install PCOV in the CI image or job, run the script, and publish both reports as artifacts.

test:
  stage: test
  image: registry.example.com/php-ci:8.1   # PHP 8.1 CLI image with Composer
  before_script:
    - pecl install pcov && docker-php-ext-enable pcov
    - echo "pcov.directory=src" > "$PHP_INI_DIR/conf.d/zz-pcov.ini"
    - composer install --no-interaction --prefer-dist
  script:
    - bin/run_tests_with_coverage.sh
  coverage: '/^\s*Lines:\s*\d+\.\d+\%/'
  artifacts:
    when: always
    reports:
      junit: var/reports/junit.xml
      coverage_report:
        coverage_format: cobertura
        path: var/reports/cobertura.xml

In practice you will usually bake PCOV and its settings into the CI image rather than installing them on every run, but the shape of the job stays the same. Setting when: always means the JUnit report is still uploaded when tests fail, which is exactly when you need it.

Why keep both JUnit and Cobertura

JUnit and Cobertura answer different questions. JUnit reports the outcome of the tests: which ran, which failed, and how long they took. Cobertura reports coverage: which application code was executed while those tests ran.

During a release review, the difference matters. A green test run shows the defined checks passed. Coverage output shows whether a new or changed area of the middleware was meaningfully exercised at all. Neither proves every integration scenario is safe, but together they give reviewers a much clearer starting point for judging risk.

Keep Xdebug for local debugging, without the slowdown

Integration bugs are often easiest to understand by pausing on a real payload: an unexpected variant structure, a price list with a missing field, or a queue message that arrives out of order. That is where Xdebug earns its place in the local environment.

To keep it from slowing down everything else, leave it installed but idle by default and turn it on only when you need it:

  • Set xdebug.mode=debug with xdebug.start_with_request=trigger, so a debug session starts only when a trigger is present, such as a browser extension, an XDEBUG_SESSION cookie, or XDEBUG_TRIGGER=1 for console commands and queue consumers.

  • Use XDEBUG_MODE=off as the default for routine test runs, and switch to XDEBUG_MODE=debug or XDEBUG_MODE=coverage through an environment variable when needed.

  • In Docker-based setups, point xdebug.client_host at the host machine (for example host.docker.internal) so the IDE can receive the connection.

  • Do not enable PCOV in the same local PHP runtime. If your local image includes it for parity with CI, set pcov.enabled=0 while debugging.

The result is a local environment that runs at normal speed most of the time and still lets an engineer step through a sync handler, message consumer, or API client when something does not add up.

Make the deployment boundary explicit

Coverage and test reports are evidence that a build passed defined checks. They are not a release decision. For middleware that writes to commerce and operational systems, it is worth keeping a clear line between verification and production deployment.

A common approach is to run tests and coverage on every branch and merge request, then make the production deploy a manual job that is only available on the main branch. That preserves a deliberate release decision, and it makes the exact production source easy to identify during incident review or rollback planning.

What business stakeholders should ask for

A coverage pipeline is most useful when it supports decisions that people outside engineering also need to make. For middleware that carries operational data, stakeholders should be able to get clear answers to these questions:

  • Did the automated test suite pass for the code proposed for release?

  • Can the team inspect structured test results when a pipeline fails?

  • Is there a coverage report that helps identify lightly exercised changes?

  • Can engineers still debug a difficult synchronization issue locally?

  • Is production release controlled and traceable to the main branch?

None of these require reading PHP or CI configuration. They describe the conditions for safe, ongoing change in a system that sits between the storefront and core operations.

Coverage is part of a broader integration discipline

Test coverage does not replace reconciliation, logging, retry design, or production support. It strengthens the feedback loop around the code that implements those practices. Teams maintaining sync processes can pair it with targeted recovery patterns such as those described in recoverable Shopify sync design.

It also fits the long-term operating model of a custom integration. Symfony applications stay maintainable when their test suite, deployment process, and support practices evolve along with the systems they connect. Learn more about Endertech's Symfony development for long-lived applications and integrations, or talk with us about an application support approach covering release health, production care, and continuous improvement.

A practical next step

Pick one recent change to your middleware that touched inventory, pricing, variants, or order data. Find the automated tests that cover it, the pipeline artifacts produced when it ran, and the steps required to deploy it. That small review will quickly show where PCOV-based CI coverage, structured test reports, or a clearer release boundary would reduce uncertainty.

Drag to pan. Use +/− or Ctrl/Cmd + scroll to zoom. Pinch to zoom on touch devices.

Symfony Middleware CI Coverage: PCOV in GitLab, Xdebug Locally