Jak zmienić bibliotekę Composer w pakiet Laravel

7 min. czytania

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
<?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
<?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.

json
{
  "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ą.

bash
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 zawod­ne.

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
<?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
<?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
<?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
<?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,
];
bash
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
<?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
<?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żą.

yaml
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ę po config: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.

Powiązane artykuły

Wsparcie istniejącego systemu

Potrzebujesz pomocy z działającą aplikacją?

Pomagam firmom rozwijać działające systemy, porządkować wdrożenia i dodawać nowe funkcje bez dokładania chaosu do projektu.

Komentarze (0)
Zaloguj się, aby dodać komentarz

Musisz być zalogowany, aby dodać komentarz.

Zaloguj się

Potrzebujesz kogoś, kto weźmie odpowiedzialność za kolejny krok?

Porozmawiajmy o Twoim projekcie i określmy zakres, który ma sens dla Twoich celów.