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, notlaravel/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.providersenables 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.3to1.2.4): bug fixes, no API changes. - Minor (
1.2.0to1.3.0): new features, backwards compatible. - Major (
1.xto2.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
- Make the repository public on GitHub (or another git host).
- Sign in to Packagist, submit the repository URL. Packagist reads
composer.jsonand lists the tags as versions. - 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
@deprecatedand 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,
mergeConfigFromand 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
- Laravel, Package development: https://laravel.com/docs/12.x/packages
- Orchestra Testbench: https://packages.tools/testbench
- Packagist, About and submission: https://packagist.org/about
- Semantic Versioning 2.0.0: https://semver.org/
- Composer, The composer.json schema: https://getcomposer.org/doc/04-schema.md
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.