Anatomia paczki Composer: praktyczny przewodnik maintenera

8 min. czytania

Paczka Composer zaczyna się od obietnicy. Ktoś instaluje nazwę, której wcześniej nie widział, i wpuszcza jej kod do produkcji. Musi wiedzieć, jakie wersje PHP wspierasz, które klasy wolno wywoływać, co dzieje się przy błędzie i jak bezpiecznie aktualizować zależność. composer.json nie jest papierologią: to pierwsza wykonywalna część tej obietnicy.

Zbudujemy bibliotekę bez zależności od frameworka, która weryfikuje podpis przychodzącego webhooka. Taki rdzeń wyraźnie pokazuje granicę; adapter Laravel można dodać później bez zmuszania każdego użytkownika do używania Laravel. Nie chodzi o modną strukturę katalogów, ale o bibliotekę, którą da się zainstalować, zrozumieć, przetestować i wydać bez ukrytych założeń.

Zacznij od jednej stabilnej operacji

Nie wyciągaj paczki tylko dlatego, że klasa wygląda na wielokrotnego użytku. Paczka oznacza publiczne API, obowiązki semver, obsługę zgłoszeń i proces wydań. Zasługuje na ten koszt, gdy co najmniej dwie aplikacje potrzebują tej samej polityki, a polityka ma mały kontrakt.

Dla weryfikacji webhooka kontrakt jest celowo wąski: dla surowego payloadu, podanego podpisu i sekretu ma odpowiedzieć, czy podpis jest prawidłowy. Nie powinien przyjmować żądania HTTP, czytać $_SERVER, logować ani sam wybierać sekretu z konfiguracji. To decyzje integracyjne, nie kryptografia.

php
<?php

declare(strict_types=1);

namespace DominikDev\WebhookTap;

interface SignatureVerifier
{
    public function verifies(string $payload, string $providedSignature): bool;
}

Implementacja jest właścicielem algorytmu i normalizacji. hash_equals() jest ważne: zwykłe porównanie może zdradzić przez czas wykonania, jak duża część podpisu pasowała. Konstruktor odrzuca też pusty sekret od razu, kiedy błąd konfiguracji łatwiej zdiagnozować niż po serii fałszywych wyników.

php
<?php

declare(strict_types=1);

namespace DominikDev\WebhookTap;

use InvalidArgumentException;

final readonly class HmacSha256SignatureVerifier implements SignatureVerifier
{
    public function __construct(private string $secret)
    {
        if ($secret === '') {
            throw new InvalidArgumentException('Webhook secret cannot be empty.');
        }
    }

    public function verifies(string $payload, string $providedSignature): bool
    {
        $expectedSignature = hash_hmac('sha256', $payload, $this->secret);

        return hash_equals($expectedSignature, $this->normalise($providedSignature));
    }

    private function normalise(string $providedSignature): string
    {
        return str_starts_with($providedSignature, 'sha256=')
            ? substr($providedSignature, 7)
            : $providedSignature;
    }
}

To klasa, nie helper, z praktycznych powodów. Konsument może type-hintować interfejs, adapter wstrzyknie skonfigurowany sekret, a test łatwo podmieni ją na fake. Nie potrzebuje service locatora ani statycznej fasady, żeby być użyteczna.

Niech namespace, pliki i manifest mówią to samo

Najmniejsza wiarygodna biblioteka ma kod źródłowy, testy, licencję, README i precyzyjny manifest. To kompletny początkowy composer.json, nie fragment do wklejenia do aplikacji:

json
{
  "name": "dominik-dev/webhook-signatures",
  "description": "Small, framework-free HMAC webhook signature verification.",
  "type": "library",
  "license": "MIT",
  "require": {"php": "^8.3"},
  "require-dev": {"pestphp/pest": "^4.0", "phpstan/phpstan": "^2.0"},
  "autoload": {"psr-4": {"DominikDev\\WebhookTap\\": "src/"}},
  "autoload-dev": {"psr-4": {"DominikDev\\WebhookTap\\Tests\\": "tests/"}},
  "scripts": {"test": "pest", "analyse": "phpstan analyse src tests"},
  "minimum-stability": "stable",
  "prefer-stable": true
}

Przy takim mapowaniu src/HmacSha256SignatureVerifier.php zawiera klasę DominikDev\WebhookTap\HmacSha256SignatureVerifier. Composer odnajdzie ją bez osobnego require dla pliku. Po zmianie mapowania uruchom composer dump-autoload; błąd „class not found” po przeniesieniu pliku zwykle oznacza rozjazd ścieżki i namespace'u, a nie problem Composer.

Kod runtime umieszczaj tylko w autoload. Konsument instaluje z --no-dev, więc klasa potrzebna produkcyjnie nie może mieszkać w autoload-dev. Z drugiej strony helpery testowe nie powinny lądować w autoloaderze konsumenta. Dzięki temu zależności testowe nie stają się przypadkowym API.

composer.lock należy do aplikacji, gdzie zamraża wdrażalny build. W publikowanej bibliotece zazwyczaj nie należy do repozytorium: każdy konsument rozwiązuje ją razem z własnym grafem. Fixture narzędzia lub jawna polityka dla contributorów mogą być wyjątkiem; nazwij tę zasadę zamiast commitować lockfile z przyzwyczajenia.

Testuj zachowanie i granice błędu

Testy powinny opisywać to, co widzi konsument. Te przykłady Pest obejmują prawidłowy nagłówek dostawcy, zmieniony payload i ochronę konstruktora. Nie asercjują prywatnych metod ani nie powtarzają implementacji HMAC z PHP.

php
<?php

declare(strict_types=1);

use DominikDev\WebhookTap\HmacSha256SignatureVerifier;

it('accepts a provider prefixed signature for the original payload', function (): void {
    $verifier = new HmacSha256SignatureVerifier('top-secret');
    $payload = '{"event":"invoice.paid","id":"evt_123"}';
    $signature = hash_hmac('sha256', $payload, 'top-secret');

    expect($verifier->verifies($payload, "sha256={$signature}"))->toBeTrue();
});

it('rejects a signature when a byte of the payload changes', function (): void {
    $verifier = new HmacSha256SignatureVerifier('top-secret');
    $signature = hash_hmac('sha256', '{"amount":1000}', 'top-secret');

    expect($verifier->verifies('{"amount":1001}', $signature))->toBeFalse();
});

it('rejects an empty secret at construction time', function (): void {
    new HmacSha256SignatureVerifier('');
})->throws(InvalidArgumentException::class, 'Webhook secret cannot be empty.');

Drugi test łapie realny błąd integracyjny: sparsowanie JSON i podpisanie go po ponownym zakodowaniu zmienia białe znaki oraz kolejność kluczy. Udokumentuj, że wywołujący przekazuje surowe body żądania. Poprawny HMAC nic nie daje, jeśli każdy adapter podpisuje inne bajty.

Trzymaj Laravel na brzegu

Laravel potrzebuje konfiguracji i granicy HTTP, ale żadna z nich nie należy do rdzenia. Provider jest composition rootem, który zamienia sekret aplikacji w bezframeworkowy kontrakt:

php
<?php

declare(strict_types=1);

namespace App\Providers;

use DominikDev\WebhookTap\HmacSha256SignatureVerifier;
use DominikDev\WebhookTap\SignatureVerifier;
use Illuminate\Support\ServiceProvider;

final class WebhookServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(SignatureVerifier::class, function (): SignatureVerifier {
            return new HmacSha256SignatureVerifier(
                secret: (string) config('services.billing.webhook_secret'),
            );
        });
    }
}

Kontroler otrzymuje interfejs i odrzuca błędne dostawy przed zleceniem pracy. Nie zna detali HMAC ani nie rozwiązuje kontenera samodzielnie.

php
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use DominikDev\WebhookTap\SignatureVerifier;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

final class BillingWebhookController
{
    public function __invoke(Request $request, SignatureVerifier $verifier): Response
    {
        $signature = (string) $request->header('X-Billing-Signature');

        abort_unless($verifier->verifies($request->getContent(), $signature), 401);

        // Dispatch an application job using the verified raw payload.

        return response()->noContent();
    }
}

Taki podział daje rdzeniowi konsumentów spoza Laravel, a aplikacji Laravel zwykłe dependency injection. Osobna paczka *-laravel ma sens tylko wtedy, gdy sam adapter ma użytkowników i tempo wydań warte utrzymania.

Wersjonuj publiczną powierzchnię

Semver dotyczy zachowania, które konsument może zaobserwować. Usunięcie metody, zawężenie wejścia, zmiana domyślnego algorytmu lub typu wyjątku może być breaking change, choć PHPStan jest zielony. Nowa opcjonalna metoda to zwykle minor. Naprawa, która poprawnie odrzuca błędne dane, jest często patchem, ale zaskakujące poprawki nazwij w changelogu.

Używaj @internal tylko dla klas, które naprawdę nie są punktami rozszerzeń, i nie pokazuj ich w przykładach. To sygnał, nie pole siłowe: publiczną klasę PHP nadal da się utworzyć. Lepszy projekt pokazuje mały interfejs i jawne fabryki zamiast dużego src/, którego konsument ma „nie dotykać”.

Przed poszerzeniem ograniczenia zapytaj Composer, skąd ono wynika:

bash
composer why symfony/console
composer why-not php 8.5
composer update --prefer-lowest --prefer-stable
composer test

--prefer-lowest ma znaczenie, bo szerokie ograniczenie ^ to deklaracja wsparcia, a nie optymistyczna sugestia. Testuj w CI najniższe rozwiązywalne wersje oraz obecne wersje albo uczciwie zawęź zakres.

Wydaj odtwarzalny artefakt

W lokalnej integracji użyj repozytorium path zamiast kopiować src/ do aplikacji. Symlink sprawia, że konsument wykonuje te same metadane, które otrzyma użytkownik Packagist.

json
{
  "repositories": [{"type": "path", "url": "../webhook-signatures", "options": {"symlink": true}}],
  "require": {"dominik-dev/webhook-signatures": "*@dev"}
}

Uruchom testy, analizę statyczną i composer validate --strict na czystym checkoutcie. Przed tagiem przejrzyj archiwum. .gitattributes może wykluczać zrzuty ekranu, lokalne narzędzia i CI przez export-ignore, ale nigdy src, composer.json, licencję ani konfigurację runtime. Otaguj przetestowany commit i opublikuj release notes z informacją o zgodności oraz breaking changes.

Błędy, przez które mała biblioteka robi się droga

Najdroższe problemy paczek są zwykle przyziemne. Czytanie zmiennych środowiska w src/ psuje cache konfiguracji i konsumentów spoza Laravel; konfigurację przekazuj przez konstruktor. Łapanie każdego Throwable i zwracanie false ukrywa błąd programistyczny jako błędny podpis; false zostaw dla oczekiwanej niezgodności, a stan niemożliwy niech przerwie wykonanie. Równie niepomocne bywa oznaczanie każdej klasy jako final, gdy udokumentowanym punktem rozszerzeń ma być dziedziczenie — gdy przewidujesz podmianę, lepszy jest mały interfejs.

Oprzyj się też metodom „dla wygody”, które przyjmują w jednym parametrze Request, tablicę, JSON i string. Tworzą niejednoznaczną normalizację oraz macierz wsparcia, której testy zwykle nie obejmują. Wąska metoda przyjmująca surowy payload i mały adapter frameworka są bardziej rozwlekłe na brzegu, ale bezpieczniejsze wewnątrz biblioteki. Nie obiecuj w dokumentacji algorytmu lub dostawcy, którego nie sprawdziłeś na znanym wektorze. Tekst o kompatybilności jest częścią kontraktu API.

Kiedy paczka jest złą odpowiedzią

Nie publikuj paczki, aby uniknąć pięcioliniowej klasy w jednej aplikacji. Nie zamieniaj modeli Eloquent, kluczy konfiguracji ani nazw kolejek konkretnej aplikacji w pozornie generyczną bibliotekę. To decyzje aplikacyjne z testami aplikacji. Prywatny namespace lub app/Support są uczciwe, dopóki drugi konsument nie ujawni stabilnej wspólnej granicy.

Nie dziel też małego rdzenia i adaptera Laravel tylko dlatego, że tak mówi „clean architecture”. Jedna paczka Laravel z discovery i providerem bywa łatwiejsza do instalacji, dokumentowania i aktualizacji. Dziel dopiero, gdy istnieją użytkownicy bez Laravel lub zależności Laravel krępują kontrakt. Alternatywą dla paczki nie jest słaba inżynieria: często jest nią najmniejsza granica, która uczciwie mówi, kto jest właścicielem kodu.

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.