Three of our Laravel applications verified webhook signatures from payment and messaging providers with copies of the same few classes. Each copy had drifted, and one had a timing-safe comparison bug fixed only in the other two. We extracted them into a package, first private, then public on Packagist.

This post walks through the steps from a folder of classes to a package anyone can install with composer require, including the parts that tutorials often skip: testing against several Laravel versions, versioning, and what to do on release day.

Package structure

laravel-webhook-signatures/
  composer.json
  config/webhook-signatures.php
  src/
    WebhookSignaturesServiceProvider.php
    Verifier.php
    Contracts/SignatureScheme.php
    Schemes/HmacSha256Scheme.php
    Http/Middleware/VerifyWebhookSignature.php
    Exceptions/InvalidSignature.php
  tests/
    TestCase.php
    VerifierTest.php
    MiddlewareTest.php
  .github/workflows/tests.yml
  CHANGELOG.md
  LICENSE.md
  README.md

Keep the package focused. Ours does one thing: verify signed webhooks. Everything else stayed in the apps.

composer.json

{
  "name": "acme/laravel-webhook-signatures",
  "description": "Verify signed webhooks in Laravel applications",
  "type": "library",
  "license": "MIT",
  "require": {
    "php": "^8.2",
    "illuminate/support": "^11.0|^12.0",
    "illuminate/http": "^11.0|^12.0"
  },
  "require-dev": {
    "orchestra/testbench": "^9.0|^10.0",
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": { "Acme\\WebhookSignatures\\": "src/" }
  },
  "autoload-dev": {
    "psr-4": { "Acme\\WebhookSignatures\\Tests\\": "tests/" }
  },
  "extra": {
    "laravel": {
      "providers": ["Acme\\WebhookSignatures\\WebhookSignaturesServiceProvider"]
    }
  },
  "minimum-stability": "stable",
  "prefer-stable": true
}

Notes:

  • Depend on illuminate/* components, not laravel/framework. It keeps the dependency precise and lets non-Laravel projects using Illuminate components install the package.
  • Version constraints list the Laravel major versions you test against. Do not use *.
  • extra.laravel.providers enables package auto-discovery: Laravel registers the service provider automatically when the package is installed.

The service provider

namespace Acme\WebhookSignatures;

use Illuminate\Support\ServiceProvider;

class WebhookSignaturesServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->mergeConfigFrom(__DIR__.'/../config/webhook-signatures.php', 'webhook-signatures');

        $this->app->singleton(Verifier::class, fn ($app) => new Verifier(
            $app['config']->get('webhook-signatures.providers', [])
        ));
    }

    public function boot(): void
    {
        if ($this->app->runningInConsole()) {
            $this->publishes([
                __DIR__.'/../config/webhook-signatures.php' => config_path('webhook-signatures.php'),
            ], 'webhook-signatures-config');
        }

        $this->app['router']->aliasMiddleware('webhook.signed', Http\Middleware\VerifyWebhookSignature::class);
    }
}
  • register() binds services and merges default config, so the package works without publishing anything.
  • boot() registers publishable files and the middleware alias.
  • Publishing tags (webhook-signatures-config) let users publish only what they need: php artisan vendor:publish --tag=webhook-signatures-config.

The config file

// config/webhook-signatures.php
return [
    'providers' => [
        'payments' => [
            'scheme' => \Acme\WebhookSignatures\Schemes\HmacSha256Scheme::class,
            'secret' => env('PAYMENTS_WEBHOOK_SECRET'),
            'header' => 'X-Signature',
            'tolerance_seconds' => 300,
        ],
    ],
];

Secrets come from the environment, never from defaults in the package.

Testing with Orchestra Testbench

Testbench boots a minimal Laravel application around your package, so you can test service providers, middleware, routes and config as they behave in a real app:

namespace Acme\WebhookSignatures\Tests;

use Acme\WebhookSignatures\WebhookSignaturesServiceProvider;
use Orchestra\Testbench\TestCase as Orchestra;

abstract class TestCase extends Orchestra
{
    protected function getPackageProviders($app): array
    {
        return [WebhookSignaturesServiceProvider::class];
    }

    protected function defineEnvironment($app): void
    {
        $app['config']->set('webhook-signatures.providers.payments.secret', 'whsec_test');
    }
}
class MiddlewareTest extends TestCase
{
    protected function defineRoutes($router): void
    {
        $router->post('/hook', fn () => 'ok')->middleware('webhook.signed:payments');
    }

    public function test_rejects_invalid_signature(): void
    {
        $this->postJson('/hook', ['id' => 'evt_1'], ['X-Signature' => 't=1,v1=bad'])->assertStatus(401);
    }

    public function test_accepts_valid_signature(): void
    {
        $body = json_encode(['id' => 'evt_1']);
        $ts = time();
        $sig = hash_hmac('sha256', "{$ts}.{$body}", 'whsec_test');
        $this->call('POST', '/hook', [], [], [], ['HTTP_X_SIGNATURE' => "t={$ts},v1={$sig}",
            'CONTENT_TYPE' => 'application/json'], $body)->assertOk();
    }
}

CI across PHP and Laravel versions

name: tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        php: ["8.2", "8.3", "8.4"]
        laravel: ["11.*", "12.*"]
        include:
          - laravel: "11.*"
            testbench: "9.*"
          - laravel: "12.*"
            testbench: "10.*"
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with: { php-version: "${{ matrix.php }}", coverage: none }
      - run: composer require "illuminate/support:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-update
      - run: composer update --prefer-dist --no-interaction
      - run: vendor/bin/phpunit

The matrix catches incompatibilities before users do. Add a --prefer-lowest run for the oldest supported combination if you claim support for it.

Documentation that answers the first questions

The README is the package's landing page. Ours answers, in order: what the package does in one sentence, installation, the minimal configuration, a complete example (route plus middleware), how to add a custom signature scheme, supported PHP and Laravel versions, and how to report security issues privately. A SECURITY.md file with a contact address keeps vulnerability reports out of public issues.

Versioning and releases

Composer resolves versions from git tags. Use semantic versioning:

  • Patch (1.2.3 to 1.2.4): bug fixes, no API changes.
  • Minor (1.2.0 to 1.3.0): new features, backwards compatible.
  • Major (1.x to 2.0.0): breaking changes, such as dropping a Laravel version or changing a public method signature.
git tag v1.0.0
git push origin v1.0.0

Keep a CHANGELOG.md with every release and an "Unreleased" section during development.

Publishing on Packagist

  1. Make the repository public on GitHub (or another git host).
  2. Sign in to Packagist, submit the repository URL. Packagist reads composer.json and lists the tags as versions.
  3. Set up automatic updates: with the Packagist GitHub integration, new tags appear within a minute. Without it, Packagist crawls periodically, which can take a long time.

After that, composer require acme/laravel-webhook-signatures works for everyone.

Maintaining it after release

Publishing is the start, not the end. A few habits keep a package healthy:

  • Respond to issues with a label and a first reply within a week, even if the fix takes longer.
  • Add new Laravel versions to the CI matrix when they are released, and release a compatible version quickly; this is the most common request.
  • Deprecate before removing: mark old methods with @deprecated and a log notice in one minor release, remove them in the next major.
  • Write upgrade notes for every major version.
  • Enable Dependabot or Renovate for the package's own dev dependencies and GitHub Actions.

Common mistakes

Mistake Effect Do this instead
Requiring laravel/framework Heavy, conflicts with some setups Require specific illuminate/* packages
No tags, only branches Users must use dev-main and lower stability Tag releases with semantic versions
Secrets in default config Leaks or insecure defaults Read secrets from env() with no default
Testing in a host app only Package breaks in clean installs Orchestra Testbench in the package repo
No CI matrix Breaks on Laravel or PHP versions you claim Test every supported combination

Key takeaways

  • Keep packages small and focused; extract only what several apps share.
  • Use auto-discovery, mergeConfigFrom and publish tags so the package works out of the box.
  • Test with Orchestra Testbench across every PHP and Laravel version you support.
  • Release with semantic version tags and a changelog.
  • Connect Packagist to the repository so tags appear immediately.

References