Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Coverage Guard Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package