- 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=debugit supports breakpoints and step-through debugging from an IDE. Withxdebug.mode=coverageit 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=neverA few notes on this script:
bin/phpunitis the Symfony PHPUnit Bridge entry point in many Symfony projects. If your project callsvendor/bin/phpunitdirectly, 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=srclimits instrumentation to your application code and keeps overhead down. If it is not set, PCOV tries to detect asrc,lib, orappdirectory 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-coberturarequires 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.xmlIn 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=debugwithxdebug.start_with_request=trigger, so a debug session starts only when a trigger is present, such as a browser extension, anXDEBUG_SESSIONcookie, orXDEBUG_TRIGGER=1for console commands and queue consumers.Use
XDEBUG_MODE=offas the default for routine test runs, and switch toXDEBUG_MODE=debugorXDEBUG_MODE=coveragethrough an environment variable when needed.In Docker-based setups, point
xdebug.client_hostat the host machine (for examplehost.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=0while 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.
