shipmonk/coverage-guard
Enforce code coverage in CI by targeting core functionality instead of percentages. Legacy-friendly and extensible, it lets you require coverage only for chosen methods and, with a patch, only for new code. Lightweight and easy to start.
The package is a standalone CLI binary, not a Laravel service provider — there is nothing to register and no config to publish.
composer require --dev shipmonk/coverage-guard
vendor/bin/coverage-guard init # writes coverage-guard.php in cwd
Before anything else, make sure your suite can actually emit a clover report. In phpunit.xml:
<coverage processUncoveredFiles="true">
<include>
<directory>app</directory>
</include>
<report>
<clover outputFile="clover.xml"/>
</report>
</coverage>
processUncoveredFiles="true" matters more here than with percentage-based checks: without it, files that have zero tests are simply absent from clover.xml, and the guard will happily pass because it never sees an uncovered method. Then:
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-clover clover.xml
vendor/bin/coverage-guard check clover.xml
Note that php artisan test --coverage prints a table but does not write clover by default; call vendor/bin/phpunit directly in CI, or add --coverage-clover=clover.xml and verify the file appears (flag forwarding varies across Laravel versions — check the artifact exists before debugging the guard).
First use case to adopt: run check --patch on new code only. Generate the diff in CI, then:
git diff origin/main...HEAD > changes.patch
vendor/bin/coverage-guard check clover.xml --patch changes.patch
Start with an intentionally weak rule so the first PR doesn't fail for unrelated reasons:
$config->addRule(new EnforceCoverageForMethodsRule(
requiredCoveragePercentage: 50,
minMethodChangePercentage: 50,
minExecutableLines: 5,
));
Look at vendor/bin/coverage-guard check --help and the sample config init generates before writing custom rules — most day-to-day needs are covered by EnforceCoverageForMethodsRule plus path mapping.
Tiered rules by namespace. Reflection is available inside a CoverageRule, so encode Laravel conventions directly instead of one global threshold:
$config->addRule(new EnforceCoverageForMethodsRule(50, minExecutableLines: 5)); // default
$config->addRule(new class implements CoverageRule {
public function check(CodeBlock $block, Errors $errors): void {
$class = $block->getClassReflection()?->getName();
if ($class && str_starts_with($class, 'App\\Http\\Controllers\\')) {
// require full coverage on controller entry points
}
}
});
The realistic Laravel targets are: App\Http\Controllers\* public methods, App\Jobs\*::handle(), App\Console\Commands\*::handle(), FormRequest rules(), Policies, and service classes registered as singletons. Facades and models are usually poor targets — see the gotchas.
Gradual rollout via patch mode. Phase 1: --patch only, so legacy code is untouched and every new controller method ships tested. Phase 2: drop --patch for one namespace (Jobs, Commands) using a namespace-scoped rule. Phase 3: global minExecutableLines rule. Bumping requiredCoveragePercentage from 50 → 80 later is a one-line diff, which is the real advantage over a global percentage gate.
Escape hatch attribute. The README's #[NoCoverageAllowed] example is not shipped — you implement it. In your rule:
$reflection = $block->getMethodReflection();
if ($reflection?->getAttributes(NoCoverageAllowed::class)) {
return;
}
This is the pressure valve that keeps DTOs, trivial getters, and __toString() from generating noise and eroding trust in the gate.
Parallel/sharded CI. Laravel's --parallel doesn't merge coverage, so shard by hand and merge:
vendor/bin/coverage-guard merge coverage/*.xml > merged.xml
vendor/bin/coverage-guard check merged.xml
merge and convert are also handy when one job emits cobertura (for GitLab) and the guard wants clover.
MR annotations. Run patch-coverage alongside check — it exits 0 and prints a percentage you can regex with GitLab's coverage: keyword:
vendor/bin/coverage-guard patch-coverage clover.xml --patch changes.patch
# Coverage: 84.44%
Local DX. init and wire a composer script so nobody forgets the env var:
"scripts": {
"coverage:guard": [
"XDEBUG_MODE=coverage phpunit --coverage-clover clover.xml",
"coverage-guard check clover.xml"
]
}
Set setEditorUrl('phpstorm://open?file={file}&line={line}') so violations are click-through in the terminal — this is the difference between a gate people fix and a gate people ignore.
CI ordering. Coverage generation → guard → artifact upload. Run the guard as its own step after the test step and before you publish results; a non-zero exit fails the job, which is the point. With shivammathur/setup-php, set coverage: pcov (PCOV is much faster than Xdebug on Laravel-sized suites) and tools: none.
processUncoveredFiles is effectively mandatory. If your phpunit.xml doesn't set it, untested files never enter the clover report, and a controller with zero tests passes silently. Always include <include><directory>app</directory></include> too — coverage is collected only from included paths, and a wrong filter makes the guard look at nothing.
Path mismatches are the #1 failure. Clover records absolute paths from the machine that generated it. CI containers, Docker bind mounts, and macOS/Windows checkouts all differ. Symptom: "no matching files" or rules that never fire. Fix:
$config->addCoveragePathMapping('/builds/group/app', __DIR__);
$config->setGitRoot(__DIR__);
setGitRoot is autodetected when cwd is inside a repo, but autodetection is unreliable in shallow clones and some CI images — set it explicitly. Related: --patch needs a diff file, and git diff origin/main...HEAD in a fetch-depth: 1 clone produces an empty or fatal: diff. Use fetch-depth: 0 or diff against HEAD~1.
The config file is standalone PHP. coverage-guard.php is executed by a composer/bin binary, outside the framework boot. base_path(), config(), env() and app() are not available; __DIR__ is. Anything Laravel-specific must be read from a plain file or hardcoded.
Facades and Eloquent magic are invisible. Facade methods are @method docblocks delegated through __call — there is no executable line to cover, and dynamic Eloquent calls (scopeX, relationships via __call) similarly don't produce coverable lines. Coverage is attributed to the concrete method in the concrete class. Don't write rules expecting App\Facades\* to be checkable; target the underlying services instead. The same applies to Blade templates (no PHP lines in clover) and route closures / routes/web.php (procedural, but each route closure is a separate pseudo-method — usually better excluded than enforced).
minExecutableLines cuts both ways. Setting it to 5 silently exempts every small-but-critical method, e.g. a 3-line handle() that dispatches a job. Lower it for high-risk namespaces and keep the high default globally. Conversely, on very thin methods it produces noise — that's what #[NoCoverageAllowed] is for.
Extension requirements bite on slim images. check and merge need ext-dom; XML parsing needs ext-libxml/ext-simplexml; --patch needs sebastian/diff (optional dependency — it won't be installed automatically). phpdocker-io/php and alpine images frequently ship without dom. If you see a confusing parse error, check php -m first.
Avoid the .cov format here. The php format is 8–40× larger than clover, and 100 MB artifacts are normal on a covered Laravel app. It also needs phpunit/php-code-coverage installed as an optional dependency. Use clover; convert to cobertura only for GitLab visualization.
merge/convert are lossy by design. They emit a minimal document that preserves what PHPStorm, GitLab, and the guard need — not branch coverage or full per-line metadata. If you merge for the guard and also feed the merged file to an IDE or a coverage-trend service, verify the numbers still line up.
--patch scope surprises. When a patch is provided only changed files and methods are analysed — violations elsewhere disappear from the report. That's the feature, but it means CI and local runs can disagree; always reproduce with the same diff. Also note minMethodChangePercentage filters at the method level: a method touched by one line is often skipped, so small edits to a big uncovered method won't fail the build.
Exit codes. check returns non-zero on violations; patch-coverage does not (it's a reporting command). Don't put patch-coverage in a step you expect to gate on, and don't wrap check in || true while debugging — you'll spend an hour wondering why the pipeline is green.
Version/pinning. Releases are recent and dependents are near-zero, so pin an exact version in composer.json and read the changelog on bump. PHP 8.1+ is required — a Laravel 9 app still on PHP 8.0 cannot install it at all.
How can I help you explore Laravel packages today?