A package test suite must prove integration, not just unit methods. Orchestra Testbench boots a small Laravel application without requiring a consumer repository.
<?php
declare(strict_types=1);
namespace DominikDev\WebhookTap\Tests;
use DominikDev\WebhookTap\WebhookTapServiceProvider;
use Orchestra\Testbench\TestCase;
abstract class PackageTestCase extends TestCase
{
protected function getPackageProviders($app): array
{
return [WebhookTapServiceProvider::class];
}
}
Write Pest tests for the observable contract: a delivery is stored, the published config changes behaviour, the facade resolves the configured service, and webhook:replay exits successfully. Define database migrations in the base test case rather than assuming a developer has migrated a local app.
CI should test the support promise. Matrix PHP 8.3–8.5 with supported Laravel releases, run the normal lockfile and a --prefer-lowest job to catch overly optimistic constraints. Add Pint, PHPStan/Larastan and Rector only if their configuration is maintained; a red badge from an abandoned static-analysis baseline is worse than no badge.
Keep tests free of real network credentials. Fake the gateway behind a contract and include failure/retry cases: webhook packages in particular must make signature failures and duplicate deliveries testable.
Boot the boundary a consumer receives
A package needs two kinds of confidence. Unit tests protect a parser or signature algorithm quickly. Integration tests prove that a consuming application can discover the provider, merge configuration, load migrations, resolve bindings and execute a command. A green unit suite cannot detect a provider typo, a missing publish tag or a migration omitted from the archive.
Use one Testbench base case that declares all package dependencies. Do not inherit an application test case: hidden application setup can make a package pass for the wrong reason. Testbench starts a small Laravel application while keeping the integration boundary visible.
<?php
declare(strict_types=1);
namespace DominikDev\WebhookTap\Tests;
use DominikDev\WebhookTap\WebhookTapServiceProvider;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Orchestra\Testbench\TestCase;
abstract class PackageTestCase extends TestCase
{
protected function getPackageProviders($app): array
{
return [WebhookTapServiceProvider::class];
}
protected function defineDatabaseMigrations(): void
{
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
Schema::create('webhook_deliveries', function (Blueprint $table): void {
$table->id();
$table->string('external_id')->unique();
$table->text('payload');
$table->timestamps();
});
}
}
The migration hook is a release check, not decoration. Without it, a local database may make tests green while a clean CI process fails. Load real package migrations whenever they are public assets; only build small harness tables in the base case. SQLite is appropriate for fast contracts, but it cannot prove MySQL-specific JSON, index or locking semantics. Add a focused database job when those semantics are part of the promise.
Test observable contracts rather than internals
Choose tests from the public surface: the service resolves, a valid delivery is stored, configuration changes behaviour, an invalid signature fails, and a command reports an actionable exit status. Those names can become the package promise in the README. Avoid assertions about private properties or provider method order; they make harmless refactors painful and still miss broken adoption.
<?php
declare(strict_types=1);
use DominikDev\WebhookTap\Exceptions\InvalidSignature;
use DominikDev\WebhookTap\WebhookRecorder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(\DominikDev\WebhookTap\Tests\PackageTestCase::class, RefreshDatabase::class);
it('stores a verified delivery through the container binding', function (): void {
config()->set('webhook-tap.secret', 'test-secret');
$payload = '{"id":"evt_123","type":"invoice.paid"}';
app(WebhookRecorder::class)->record(
payload: $payload,
signature: hash_hmac('sha256', $payload, 'test-secret'),
);
$this->assertDatabaseHas('webhook_deliveries', [
'external_id' => 'evt_123',
'payload' => $payload,
]);
});
it('rejects a delivery with an invalid signature', function (): void {
config()->set('webhook-tap.secret', 'test-secret');
app(WebhookRecorder::class)->record('{"id":"evt_123"}', 'not-a-signature');
})->throws(InvalidSignature::class);
it('does not store the same external delivery twice', function (): void {
config()->set('webhook-tap.secret', 'test-secret');
$payload = '{"id":"evt_123"}';
$signature = hash_hmac('sha256', $payload, 'test-secret');
$recorder = app(WebhookRecorder::class);
$recorder->record($payload, $signature);
$recorder->record($payload, $signature);
expect(\DB::table('webhook_deliveries')->count())->toBe(1);
});
Duplicate delivery is not an edge case; providers retry. Deterministic payloads and calculated HMAC prove the contract without network flakiness or exposed credentials. For an HTTP adapter, use Laravel request testing and fake the downstream contract. For queues, assert dispatching and separately test handler failure and retry behaviour. Dispatching a job is not proof that its handler is correct.
Make local commands and CI agree
A contributor should not reverse-engineer CI. Put stable commands in Composer scripts and call those scripts from workflows. The example shows the shape only: the exact Testbench, Pest and Larastan versions must match the Laravel and PHP range that the package really supports.
{
"require-dev": {
"orchestra/testbench": "^9.0",
"pestphp/pest": "^3.0",
"larastan/larastan": "^3.0",
"laravel/pint": "^1.0"
},
"scripts": {
"test": "pest --compact",
"test:coverage": "XDEBUG_MODE=coverage pest --coverage --min=80",
"analyse": "phpstan analyse --memory-limit=512M",
"lint": "pint --test"
}
}
Coverage is a signal, not proof. A percentage can rise while signature failures, migrations and configuration branches stay untested. Start with consumer contracts and add a coverage floor only when it prevents regression. Pint and Pest are a compact baseline. Add PHPStan or Larastan when someone will maintain their configuration; a permanently red baseline is worse than no badge.
CI verifies the support statement
The matrix must mirror the package manifest and README. When a package claims PHP 8.3 through 8.5 and Laravel 11 through 13, every release line needs deliberate coverage. Formatting and analysis can run once on the newest runtime; duplicating them in every matrix cell wastes time without testing another compatibility boundary. Disable fail-fast so one failure does not hide another.
name: tests
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.3', '8.4', '8.5']
laravel: ['11.*', '12.*', '13.*']
exclude:
- php: '8.3'
laravel: '13.*'
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: \${{ matrix.php }}
coverage: none
- run: composer require "illuminate/support:\${{ matrix.laravel }}" --no-update
- run: composer update --prefer-dist --no-interaction
- run: composer test
Do not copy a matrix without resolving it. Composer resolves a whole graph and each Laravel line has its own PHP constraints. Separate constraint files or a matrix tool are sensible when inline requirements become hard to review. The essential property is evidence that every advertised line installs and boots.
Add a prefer-lowest job for the oldest supported combination. It catches code that started relying on a transitive version newer than the declared minimum. It can be slow and may expose upstream resolver defects, so a scheduled required workflow is often better than every push. Do not raise a constraint automatically: first decide whether the support promise has really changed.
Know when not to add more tooling
A class-not-found error only in CI can mean an archive rule excluded a class. A fresh migration failure can mean Testbench never loaded the package migration. An analysis out-of-memory error deserves a bounded higher-memory rerun before triage; reducing the scan only hides the issue. A flaky HTTP test means a real network boundary leaked into the suite, not that CI should retry until green.
Do not add mutation testing, browser tests, Docker databases and every quality tool by reflex. They help when a specific risk justifies their runtime and maintenance. A private package released with one application may need that application integration suite more than a public compatibility matrix. A reusable package needs deterministic Testbench contracts and an honest support matrix. That is the credible alternative to saying it worked on one maintainer machine.
A release gate that people can repeat
Before tagging, run the normal suite on a clean checkout, run the oldest supported dependency job, inspect the Composer archive and install the tag in a disposable Laravel application. Record the PHP, Laravel and database versions used by that verification in the release notes or CI artefact. This is the smallest useful evidence that the published package, not merely the repository, works.
Do not turn every release into a week of ceremony. A documentation-only change does not need an exhaustive database matrix, while a provider, migration or constraint change does. The point is proportional verification: tests closest to a consumer path run most often, slower compatibility checks run before release or on a schedule, and failures remain visible until their scope is understood. That rhythm keeps a package trustworthy without making maintenance unaffordable.
Review a failure as product feedback
When CI goes red, preserve the failing command, dependency versions and database driver in the report. A test that passes after a blind retry is still evidence of an uncontrolled dependency. Reproduce it in a clean container or a fresh Composer install, then decide whether the defect is package code, a declared compatibility error, or test infrastructure. This distinction matters because turning off a failing check makes a badge green while leaving adopters exposed.
Release testing also has a time boundary. Run the full matrix before a tag and again when a security update changes the resolved graph. Pinning every transitive dependency permanently is not the answer: it delays security fixes. Instead, retain a lockfile for reproducible development, exercise a fresh resolution in CI, and publish constraints that match the combinations actually verified. The result is less glamorous than a huge dashboard, but it gives a maintainer a concrete answer when a user asks whether a particular Laravel upgrade is supported.
The opposite extreme is testing only the latest framework release. It is reasonable for an application with one deployed version, and it may be the right scope for an internal package. It is not a credible policy for a public library advertising older versions. State the narrower policy honestly, remove unsupported constraints, and let the matrix document the promise that remains.