Biblioteka Composer niezależna od frameworka nie powinna znać Laravela. Jej
klasy muszą działać w CLI, Symfony i innym frameworku bez containera, facade,
migracji ani globalnego helpera. Pakiet Laravel jest adapterem wokół biblioteki:
daje Laravelowi miejsce do discovery, a aplikacji — znajomy mechanizm
konfiguracji i obsługi. Dodanie illuminate/support do każdej małej biblioteki
tworzy zależność, którą trudniej testować i ponownie wykorzystać. Z kolei ręczne
bindingi i migracje w każdym projekcie nie są integracją pakietu.
Budujemy dominik-dev/laravel-webhook-tap: provider będzie composition rootem,
a właściwa praca zostanie w zwykłym PHP. Taki podział ma sens dla adaptera
płatności, audytu lub SDK. Nie twórz jednak pakietu -core tylko dla pozoru —
dwa wydania i dwie macierze wsparcia są kosztem.
Zacznij od kontraktu poza Laravel
Kontrakt nie odczytuje requestu, nie woła config() i nie zakłada bazy danych.
<?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;
}
Laravelowa warstwa może potem związać ten kontrakt z adapterem Eloquent. Core
może mieszkać w osobnym repozytorium albo w packages/core; pakiet Laravel w
packages/laravel. Jeżeli nie ma drugiego realnego odbiorcy, jedno repozytorium
Laravel jest prostsze i uczciwsze.
Uczyń Composerową granicę jawną
Pakiet ma nadal typ library, a provider odkrywa Laravel przez extra.laravel.
require-dev należy do Testbench, Pest i narzędzi, nie do zależności aplikacji.
{
"name": "dominik-dev/laravel-webhook-tap",
"type": "library",
"require": {
"php": "^8.4 || ^8.5",
"illuminate/contracts": "^12.0 || ^13.0",
"illuminate/database": "^12.0 || ^13.0",
"illuminate/support": "^12.0 || ^13.0"
},
"autoload": {"psr-4": {"DominikDev\\WebhookTap\\": "src/"}},
"extra": {"laravel": {"providers": ["DominikDev\\WebhookTap\\WebhookTapServiceProvider"]}}
}
Ograniczaj wersje każdego używanego komponentu Illuminate. Nie używaj *: nowy
major może usunąć API. Testuj wersje, które deklarujesz, i wydaj nowy major, gdy
zakres wsparcia staje się przełomową obietnicą.
composer require dominik-dev/laravel-webhook-tap
php artisan about
Konsument może wyłączyć discovery przez dont-discover w swoim
composer.json, np. aby włączyć zastępczy provider tylko w wybranym środowisku.
To wspierana furtka, nie powód, by domyślne discovery było zawodne.
Provider jako composition root
register() definiuje bindingi i scala config; nie powinien rozwiązywać serwisu,
pytać bazy ani rejestrować rout. Takie prace są zależne od kolejności bootowania.
boot() rejestruje publikację zasobów i komendy.
<?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() daje wartości domyślne w runtime, a vendor:publish tworzy
edytowalny plik projektu; pakiet konfigurowalny potrzebuje zwykle obu. Scalanie
dotyka tylko pierwszego poziomu tablicy. Gdy opcja jest zagnieżdżona,
udokumentuj, że aplikacja musi powtórzyć całą wartość lub scalić ją sama.
Migracje opublikowane przekazują aplikacji własność nad schematem: zespół może
je zrecenzować, ostemplować i wdrożyć ze swoim wydaniem. loadMigrationsFrom()
jest alternatywą dla kontrolowanego pakietu wewnętrznego, ale nie używaj obu dla
tej samej tabeli.
<?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() nie zastępuje indeksu unikalnego. Migracja musi wymuszać
niezmiennik przy równoległych żądaniach.
<?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');
}
};
Publikuj tylko zasoby, którymi ma zarządzać aplikacja
Jawny tag jest kontraktem instalacji. Pozwala opublikować config lub migracje, nie kopiując wszystkiego z pakietu.
<?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
env() żyje w pliku configu. Klasy runtime czytają
config('webhook-tap.table'), inaczej config:cache zmieni produkcyjne
zachowanie. Routy i widoki są opcjonalne: rejestruj je w boot() za flagą
enabled, namespace'uj je i nie dodawaj po cichu publicznego endpointu ani
middleware. Dla widoków użyj loadViewsFrom() oraz nazwy
webhook-tap::deliveries.show.
Testuj integrację, nie tylko klasę
Test jednostkowy recordera nie udowodni discovery providera, scalenia configu, migracji ani komend. Orchestra Testbench bootuje minimalną aplikację Laravel.
<?php
declare(strict_types=1);
namespace DominikDev\WebhookTap\Tests;
use DominikDev\WebhookTap\WebhookTapServiceProvider;
use Illuminate\Foundation\Application;
use Orchestra\Testbench\TestCase;
abstract class PackageTestCase extends TestCase
{
/** @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' => '',
]);
}
}
Test integracyjny powinien sprawdzać kontrakt, nie tylko to, że klasa daje się utworzyć. Poniższy przypadek bootuje provider, zapisuje ten sam delivery dwa razy i potwierdza bazowy niezmiennik idempotencji.
<?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 a replay only once', function (): void {
$webhook = new RecordedWebhook(
provider: 'stripe',
deliveryId: 'evt_123',
rawBody: '{"type":"invoice.paid"}',
headers: ['stripe-signature' => ['t=1,v1=abc']],
signatureValid: true,
);
app(WebhookRecorder::class)->record($webhook);
app(WebhookRecorder::class)->record($webhook);
$this->assertDatabaseCount('webhook_deliveries', 1)
->assertDatabaseHas('webhook_deliveries', [
'provider' => 'stripe',
'delivery_id' => 'evt_123',
'signature_valid' => 1,
]);
});
Wersja Testbench musi pasować do wszystkich wersji Laravel deklarowanych przez
Composer. Uruchamiaj w CI najniższy wspierany zestaw i najnowszy.
--prefer-lowest wykrywa przypadkowe zależności od API z późniejszego minora;
zwykłe testy z lockfilem tego nie pokażą.
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
Package Tools od Spatie ogranicza boilerplate publishes() i komend; użyj go,
gdy konwencja oszczędza znaczący kod, ale najpierw zrozum provider napisany
ręcznie. Narzędzie nie podejmuje za autora decyzji o tym, co jest publiczne,
czy migracje są publikowane, ani które bindingi należą do containera.
Kiedy nie robić pakietu i gdzie są pułapki
Framework-neutralny SDK powinien pozostać neutralny, gdy jego istotą są HTTP,
parsowanie, kryptografia albo value objects. Aplikacja może związać go w swoim
providerze w kilku liniach. Nie wydzielaj też kodu nierozerwalnego z domeną
jednej aplikacji, np. lokalnych ról albo InvoiceApproved. Alternatywą jest
wewnętrzny moduł: prostszy teraz, możliwy do wydzielenia, gdy publiczne API
wyłoni się z rzeczywistego użycia. Granice pakietu są kontraktami, nie folderami.
- Config bez
mergeConfigFrom()psuje świeżą instalację przed publikacją. env()w recorderze działa lokalnie i psuje się poconfig:cache.- Automatyczne ładowanie i publikowanie tej samej migracji uruchamia DDL dwa razy; wybierz jeden cykl życia.
- Routu, alias facade, cache key i publish tag muszą mieć prefiks pakietu, aby nie kolidować z hostem.
- Dla zmiany schematu wydaj nową addytywną migrację, nie edytuj już wykonanej.
- Szeroki zakres Laravel bez pokrycia CI przenosi test kompatybilności na użytkowników. Wąski i zweryfikowany zakres jest lepszy niż fikcyjny.
Dobry adapter Laravel jest prawie niewidoczny: instalujesz go, sprawdzasz
php artisan about, publikujesz zasoby, którymi projekt ma zarządzać, i używasz
kontraktu zrozumiałego bez wiedzy o frameworku.