A framework-agnostic Composer library should not know that Laravel exists. Its classes should be usable in a CLI script, a Symfony application, or another framework without pulling in the container, facades, migrations, or a global helper. A Laravel package is an adapter around that library: it gives Laravel a clear place to discover the integration and gives the consumer familiar ways to configure and operate it.
That distinction matters. Adding illuminate/support and a facade to every
small library produces a dependency that is harder to test and impossible to
reuse outside Laravel. Conversely, asking every Laravel user to create bindings,
copy a migration, and wire a command by hand is not really package integration.
This article turns a small webhook-recording library into
dominik-dev/laravel-webhook-tap. The same shape applies to a payment adapter,
an audit package, or an SDK integration: a thin service provider is the
composition root; the actual business work stays in ordinary PHP classes.
Start with a library Laravel does not own
Assume the original library already has a narrow contract. It records a verified
inbound delivery; it does not read the HTTP request, call config(), or decide
which database Laravel happens to use.
<?php
declare(strict_types=1);
namespace DominikDev\WebhookTap;
final readonly class RecordedWebhook
{
/** @param array<string, list<string>> $headers */
public function __construct(
public string $provider,
public string $deliveryId,
public string $rawBody,
public array $headers,
public bool $signatureValid,
) {
}
}
<?php
declare(strict_types=1);
namespace DominikDev\WebhookTap;
interface WebhookRecorder
{
public function record(RecordedWebhook $webhook): void;
}
At this point the package may have an in-memory recorder for a command-line tool, or an adapter for a generic PSR connection. Neither choice must leak into Laravel. The Laravel layer adds an Eloquent-backed implementation and binds the contract only when a Laravel application installs the adapter.
This split can live in two repositories (webhook-tap and
laravel-webhook-tap) or in a monorepo as packages/core and
packages/laravel. For a package that is useful nowhere else, a single
Laravel-specific repository is fine. Do not create a -core package merely to
look architectural; two release trains and two support matrices are real cost.
Draw the dependency boundary in Composer
The package type is still library. Laravel discovers the provider through the
extra.laravel metadata; it does not need a made-up Composer type. Keep the
runtime requirements honest. require-dev is for Testbench, Pest, and tooling,
not for the application that installs the package.
{
"name": "dominik-dev/laravel-webhook-tap",
"description": "Records verified inbound webhooks in Laravel applications.",
"type": "library",
"license": "MIT",
"require": {
"php": "^8.4 || ^8.5",
"illuminate/contracts": "^12.0 || ^13.0",
"illuminate/database": "^12.0 || ^13.0",
"illuminate/support": "^12.0 || ^13.0"
},
"require-dev": {
"orchestra/testbench": "^10.0 || ^11.0",
"pestphp/pest": "^4.0",
"pestphp/pest-plugin-laravel": "^4.0"
},
"autoload": {
"psr-4": {
"DominikDev\\WebhookTap\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"DominikDev\\WebhookTap\\Tests\\": "tests/"
}
},
"extra": {
"laravel": {
"providers": [
"DominikDev\\WebhookTap\\WebhookTapServiceProvider"
]
}
},
"minimum-stability": "stable",
"prefer-stable": true
}
The Laravel version constraint belongs on each Illuminate component the package
uses. Depending on laravel/framework is practical for application-only
packages, but it needlessly makes the package unusable in partial Illuminate
consumers. Do not use *: a new major release can remove the API you compiled
against. Test the versions you claim to support and publish a new major when the
support range itself becomes a breaking promise.
Auto-discovery means a normal installation is enough:
composer require dominik-dev/laravel-webhook-tap
php artisan about
A consumer can deliberately disable discovery in its application composer.json:
{
"extra": {
"laravel": {
"dont-discover": [
"dominik-dev/laravel-webhook-tap"
]
}
}
}
That is useful for an application that wants to register a replacement provider or boot the package only in selected environments. Treat it as a supported escape hatch, not a reason to make auto-discovery unreliable.
Make the provider the composition root
The provider is where Laravel-specific decisions belong: default configuration,
container bindings, publish tags, command registration, and resource locations.
register() runs while providers are being registered, so it must only define
bindings and merge configuration. Resolving a service, querying a database, or
registering a route there creates order-dependent boot failures.
<?php
declare(strict_types=1);
namespace DominikDev\WebhookTap;
use DominikDev\WebhookTap\Commands\ReplayWebhookCommand;
use DominikDev\WebhookTap\Infrastructure\DatabaseWebhookRecorder;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
final class WebhookTapServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__.'/../config/webhook-tap.php', 'webhook-tap');
$this->app->singleton(WebhookRecorder::class, function (Application $app): WebhookRecorder {
return new DatabaseWebhookRecorder(
connection: $app['db']->connection((string) config('webhook-tap.connection')),
table: (string) config('webhook-tap.table'),
);
});
}
public function boot(): void
{
$this->publishes([
__DIR__.'/../config/webhook-tap.php' => config_path('webhook-tap.php'),
], 'webhook-tap-config');
$this->publishesMigrations([
__DIR__.'/../database/migrations/create_webhook_deliveries_table.php' =>
database_path('migrations/'.date('Y_m_d_His').'_create_webhook_deliveries_table.php'),
], 'webhook-tap-migrations');
if ($this->app->runningInConsole()) {
$this->commands([ReplayWebhookCommand::class]);
}
}
}
mergeConfigFrom() gives an application defaults at runtime, while
vendor:publish creates an editable project file. They solve different
problems, so a configurable package generally needs both. It only merges the
first configuration level: if providers is a nested array, document that an
application must repeat the whole nested value or merge it in its own config.
Publishing migrations gives the application ownership of its schema. That is
usually safer for a reusable package: teams can review, timestamp, and deploy
the migration with their release. loadMigrationsFrom() is a legitimate
alternative for a tightly controlled internal package, but never both for the
same table. Two paths to the same DDL create confusing duplicate behaviour.
The recorder itself is just an adapter. Notice that it receives everything it needs; it has no hidden facade call and can be constructed in a unit test.
<?php
declare(strict_types=1);
namespace DominikDev\WebhookTap\Infrastructure;
use DominikDev\WebhookTap\RecordedWebhook;
use DominikDev\WebhookTap\WebhookRecorder;
use Illuminate\Database\ConnectionInterface;
final readonly class DatabaseWebhookRecorder implements WebhookRecorder
{
public function __construct(
private ConnectionInterface $connection,
private string $table,
) {
}
public function record(RecordedWebhook $webhook): void
{
$this->connection->table($this->table)->updateOrInsert(
['provider' => $webhook->provider, 'delivery_id' => $webhook->deliveryId],
[
'raw_body' => $webhook->rawBody,
'headers' => json_encode($webhook->headers, JSON_THROW_ON_ERROR),
'signature_valid' => $webhook->signatureValid,
'updated_at' => now(),
'created_at' => now(),
],
);
}
}
updateOrInsert() makes replay safe at the application level, but it is not a
substitute for a unique database index. The migration must enforce the same
invariant under concurrent requests.
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('webhook_deliveries', function (Blueprint $table): void {
$table->id();
$table->string('provider', 80);
$table->string('delivery_id', 191);
$table->longText('raw_body');
$table->json('headers');
$table->boolean('signature_valid');
$table->timestamps();
$table->unique(['provider', 'delivery_id']);
});
}
public function down(): void
{
Schema::dropIfExists('webhook_deliveries');
}
};
Publish only what the consumer should own
An explicit tag is an installation contract. It lets an application publish one kind of resource without copying every asset a package happens to contain.
<?php
declare(strict_types=1);
return [
'connection' => env('WEBHOOK_TAP_DB_CONNECTION', env('DB_CONNECTION', 'sqlite')),
'table' => env('WEBHOOK_TAP_TABLE', 'webhook_deliveries'),
'retention_days' => 30,
];
php artisan vendor:publish --tag=webhook-tap-config
php artisan vendor:publish --tag=webhook-tap-migrations
php artisan migrate
The env() calls above live in the package config file, which is the one place
Laravel evaluates environment variables directly. Runtime classes must read
config('webhook-tap.table'); otherwise config:cache will make production
behave differently from local development.
Routes and views are not compulsory package features. Add them only if they are part of the product. A diagnostic read endpoint can be opt-in, namespaced, and protected by a consumer-defined middleware stack:
<?php
declare(strict_types=1);
use DominikDev\WebhookTap\Http\Controllers\WebhookDeliveryController;
use Illuminate\Support\Facades\Route;
Route::middleware(config('webhook-tap.route_middleware', ['web']))
->prefix((string) config('webhook-tap.route_prefix', 'webhook-tap'))
->name('webhook-tap.')
->group(function (): void {
Route::get('/deliveries/{deliveryId}', WebhookDeliveryController::class)
->whereNumber('deliveryId')
->name('deliveries.show');
});
Register that route file in boot() behind an enabled config flag. Do not
silently add a public /webhooks endpoint or assume the host application's
authentication model. For views, use loadViewsFrom() plus a namespaced name
such as webhook-tap::deliveries.show; publishing views is for intentional
customisation, not a substitute for a stable API.
Give the package a Laravel-shaped test suite
Unit tests for DatabaseWebhookRecorder catch local logic, but they cannot
prove provider discovery, configuration merging, migrations, and console
commands. Orchestra Testbench boots a real, minimal Laravel application for
those integration tests.
<?php
declare(strict_types=1);
namespace DominikDev\WebhookTap\Tests;
use DominikDev\WebhookTap\WebhookTapServiceProvider;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Orchestra\Testbench\TestCase;
abstract class PackageTestCase extends TestCase
{
use LazilyRefreshDatabase;
/** @return array<int, class-string> */
protected function getPackageProviders(Application $app): array
{
return [WebhookTapServiceProvider::class];
}
protected function defineEnvironment(Application $app): void
{
$app['config']->set('database.default', 'testing');
$app['config']->set('database.connections.testing', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
}
protected function defineDatabaseMigrations(): void
{
$this->loadLaravelMigrations();
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
}
}
<?php
declare(strict_types=1);
use DominikDev\WebhookTap\RecordedWebhook;
use DominikDev\WebhookTap\WebhookRecorder;
use DominikDev\WebhookTap\Tests\PackageTestCase;
uses(PackageTestCase::class);
it('binds a recorder and stores one delivery only once', function (): void {
$recorder = app(WebhookRecorder::class);
$webhook = new RecordedWebhook(
provider: 'stripe',
deliveryId: 'evt_123',
rawBody: '{"type":"invoice.paid"}',
headers: ['stripe-signature' => ['t=1,v1=abc']],
signatureValid: true,
);
$recorder->record($webhook);
$recorder->record($webhook);
$this->assertDatabaseCount('webhook_deliveries', 1)
->assertDatabaseHas('webhook_deliveries', [
'provider' => 'stripe',
'delivery_id' => 'evt_123',
'signature_valid' => 1,
]);
});
Testbench versioning must match the Laravel versions in your Composer range;
check its compatibility table before claiming a matrix. In CI, run the lowest
supported dependency set as well as the newest one. --prefer-lowest catches a
package that accidentally depends on an API added in a later minor release.
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.4', '8.5']
dependencies: [locked, lowest]
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none
- run: composer update --prefer-dist ${{ matrix.dependencies == 'lowest' && '--prefer-lowest' || '' }}
- run: vendor/bin/pest
Hand-written provider or Package Tools?
Spatie's spatie/laravel-package-tools expresses the same provider operations
as a declarative fluent API. It reduces repeated publishes() and command
registration code, and is usually the sensible choice for a production package
with config, migrations, views, commands, and translations. It does not erase
the design decisions: you still decide what becomes public, whether migrations
are published or loaded, and what the container owns.
Hand-write a provider once when learning or when the package has one or two resources. Use Package Tools when convention saves meaningful boilerplate. Avoid adding it solely because every package in your vendor directory uses it; another dependency and an abstraction are not free.
When not to make it a Laravel package
Do not add Laravel integration because a library has a PHP class. Keep a framework-neutral SDK neutral when its meaningful work is HTTP, parsing, crypto, or value objects. A Laravel application can bind that SDK in its own provider in five lines, and the SDK avoids a broad support commitment.
Also avoid a package when the code is inseparable from one application's domain:
InvoiceApproved rules, local roles, and project-specific table columns are not
reusable just because they have a service provider. Extract only when two
consumers need the same stable behaviour and you are willing to document,
version, test, and support it.
The alternative is an application service provider plus an internal module. It is simpler, permits direct access to local models, and can later be extracted once its public API has emerged from real use. Package boundaries are contracts, not folders.
Production traps worth preventing early
The failure modes tend to appear after installation, not while writing the provider:
- Publishing a config file without merging defaults makes a fresh installation fail until the consumer runs a command.
- Calling
env()from a recorder or controller works locally and breaks afterconfig:cache; read configuration throughconfig()outside config files. - Auto-loading a migration and publishing it can run incompatible schema changes twice. Pick one lifecycle and document the upgrade path.
- Registering routes, a facade alias, or global middleware without an opt-in can collide with an application. Namespace every public route, view, config key, cache key, and publish tag with the package name.
- Treating a published migration as immutable means a package cannot upgrade a table. Release a new additive migration for each schema change; never ask a consumer to edit an already-executed file.
- Promising Laravel versions in
composer.jsonwithout CI coverage shifts the compatibility test to your users. A narrow, tested range is kinder than a broad fictional one.
The finished Laravel adapter should feel almost invisible: install it, inspect
php artisan about, publish the resources the project wants to own, and use a
contract that is still understandable without knowing Laravel. That is the
right amount of framework magic.