Anatomy of a Composer Package: A Maintainer's Practical Guide

8 min read

A Composer package starts as a promise. Someone installs a name they have never seen before and gives its code a place in production. They need to know which PHP versions it supports, which classes are safe to call, what happens on failure, and how to upgrade it. composer.json is not administrative noise: it is the first executable part of that promise.

This article builds a framework-free library that verifies an inbound webhook signature. Keeping the core free of Laravel makes the boundary visible; a Laravel adapter can be added later without making every consumer a Laravel consumer. The objective is not a fashionable directory tree. It is a library that can be installed, understood, tested and released without hidden assumptions.

Begin with one stable operation

Do not extract a package because a class looks reusable. A package adds public API, semantic-versioning obligations, issue triage and a release process. It earns that cost when more than one application needs the same policy and that policy has a small contract.

For webhook verification, the contract is deliberately narrow: given the raw payload, a supplied signature and a secret, report whether the signature is valid. It must not accept an HTTP request, read $_SERVER, log anything, or choose a secret from application configuration. Those are integration decisions, not cryptography.

php
<?php

declare(strict_types=1);

namespace DominikDev\WebhookTap;

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

An implementation owns the algorithm and normalisation rules. hash_equals() matters: ordinary comparison can leak how much of a signature matched through timing. The constructor rejects an empty secret early, where a configuration mistake is easier to diagnose than a stream of false verification results.

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;
    }
}

This is a class rather than a helper function for useful reasons. Consumers can type against an interface, an adapter can inject a configured secret, and tests can replace it with a fake. It does not need a service locator or a static facade to be useful.

Let namespace, files and manifest agree

The smallest credible library has source code, tests, a license, a README and a precise manifest. This is a complete starting composer.json, not a fragment to paste into an application:

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
}

With that mapping, src/HmacSha256SignatureVerifier.php contains DominikDev\WebhookTap\HmacSha256SignatureVerifier. Composer discovers it without per-file require calls. After changing mappings run composer dump-autoload; a class-not-found error after moving a file is usually a namespace/path mismatch, not a Composer bug.

Put runtime code only in autoload. A consumer installs with --no-dev, so a class needed at runtime cannot live in autoload-dev. Conversely, test helpers do not belong in a consumer autoloader. This prevents test dependencies from becoming accidental API.

composer.lock belongs in applications, where it freezes a deployable build. For a published library it normally does not belong in the repository: every consumer resolves it alongside its own graph. A tool-specific fixture or an explicit contributor policy can be an exception; name the policy instead of committing a lock file by habit.

Test behaviour and failure boundaries

Tests should describe what a consumer observes. These Pest examples cover a valid provider header, a changed payload and the constructor guard. They do not assert private method names or duplicate PHP's HMAC implementation.

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.');

The second test catches a real integration error: parsing JSON and signing a re-encoded payload changes whitespace and key order. Document that callers pass the raw request body. A correct HMAC algorithm is still useless if every adapter signs different bytes.

Keep Laravel at the edge

Laravel needs configuration and an HTTP boundary, but neither belongs in the core. A provider is the composition root that turns an app secret into the framework-free contract:

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'),
            );
        });
    }
}

The controller receives the interface and rejects invalid deliveries before it queues any work. It neither knows HMAC details nor resolves the container.

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();
    }
}

That division gives the core non-Laravel consumers and gives Laravel ordinary dependency injection. A separate *-laravel package is justified only when the adapter itself has users and a release cadence worth maintaining.

Version the public surface

Semantic versioning applies to behaviour a consumer can observe. Removing a public method, narrowing accepted input, changing the default algorithm, or changing an exception type can be a breaking release even when PHPStan is green. A new optional method is normally minor. A bug fix that makes invalid input fail correctly is usually patch-level, but surprising fixes belong in the changelog.

Use @internal only for classes that are genuinely not extension points, then avoid them in examples. It is a signal, not a force field: a public PHP class can still be instantiated. Better design exposes a small interface and explicit factories rather than a large src/ directory consumers are told not to touch.

Before widening a constraint, ask Composer why it exists:

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

--prefer-lowest matters because a broad ^ constraint is a support claim, not an optimistic suggestion. Test the lowest resolved versions in CI as well as current versions, or narrow the range honestly.

Release a reproducible artifact

Use a path repository while integrating locally rather than copying src/ into an application. The symlink makes the consumer execute the same metadata a Packagist user receives.

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

Run tests, static analysis and composer validate --strict on a clean checkout. Review the archive before tagging. .gitattributes may exclude screenshots, local tooling and CI files with export-ignore, but never src, composer.json, the license or runtime configuration. Tag the tested commit and publish release notes naming compatibility and breaking changes.

The mistakes that make a small library expensive

The most expensive package problems are usually mundane. Reading environment variables in src/ makes configuration caching and non-Laravel consumers awkward; pass configuration through a constructor instead. Catching every Throwable and returning false hides a programming error as an invalid signature; return false for an expected mismatch and let impossible state fail loudly. Making every class final can be equally unhelpful when the documented extension point is inheritance—prefer a small interface when substitution is intended.

Also resist convenience methods that accept a Request, an array, JSON, and a string in one parameter. They create ambiguous normalisation and a support matrix that tests rarely cover. A narrow raw-payload method, followed by a small framework adapter, is more verbose at the edge but far safer inside the library. Finally, do not promise an algorithm or provider you did not test against a known vector. Compatibility copy is part of the API contract.

When a package is the wrong answer

Do not publish a package to avoid a five-line class in one application. Do not turn an application's Eloquent models, configuration keys or queue names into a supposed generic library. They are application decisions with application tests. A private namespace or an app/Support class is honest until a second consumer reveals a stable shared boundary.

Likewise, do not split a small core and Laravel adapter because “clean architecture” says so. A single Laravel package with discovery and a provider is often easier to install, document and upgrade. Split only when framework-free consumers exist or Laravel dependencies make the core contract awkward. The alternative to a package is not poor engineering; it is often the smallest boundary that tells the truth about who owns the code.

Related articles

Existing system support

Need help with a live application?

I help companies improve live systems, clean up delivery workflows, and ship new features without adding avoidable complexity.

Comments (0)
Sign in to leave a comment

You need to be signed in to add a comment.

Login

Need someone to take responsibility for the next step?

Let’s talk about your project and define a scope that actually makes sense for your goals.