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
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
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:
{
"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
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
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
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:
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.
{
"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.