swop/github-webhook
PHP package for receiving and handling GitHub webhooks. Validate webhook signatures, route GitHub events to your own handlers, and access payload data cleanly so you can react to pushes, pull requests, issues, and other repository activity.
Install
composer require swop/github-webhook
The package is framework-agnostic — it does not ship a Laravel service provider, so there is nothing to publish or register. It is a small library: a Config object, a GitHubWebHook dispatcher, an Event value object, a signature validator, and a set of exceptions.
First use case: a push webhook that hits a Laravel route
config/services.php:'github' => [
'webhook_secret' => env('GITHUB_WEBHOOK_SECRET'),
],
use Swop\GitHubWebHook\Config\Config;
use Swop\GitHubWebHook\GitHubWebHook;
public function __invoke(Request $request)
{
$config = new Config();
$config->setSecret(config('services.github.webhook_secret'));
$config->addListener('push', new \App\Webhooks\PushListener());
$webhook = new GitHubWebHook($config);
try {
$webhook->handle(/* headers + raw body per README */);
} catch (\Swop\GitHubWebHook\Exception\InvalidSignatureException $e) {
abort(401);
}
return response('ok');
}
web middleware group (or add it to VerifyCsrfToken::$except) — GitHub will never send a CSRF token:Route::post('/webhooks/github', GitHubWebhookController::class)
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
Where to look first
Config — secret, listeners, and any global options.Event — how the payload is exposed (raw array vs. typed getters). This determines how much you write yourself.Exception\* — these are your HTTP status codes; map them once in the controller.One listener class per event, resolved from the container
Rather than closures, implement a small ListenerInterface (or just a handle(Event $event) method) per event and register them in a dedicated GitHubWebhookServiceProvider or a config array:
$config->addListener('push', app(PushListener::class));
$config->addListener('pull_request', app(PullRequestListener::class));
This keeps the controller a three-line adapter and makes listeners unit-testable in isolation.
Listener → job, not listener → work
GitHub waits roughly 10–25 seconds for a 2xx. Treat the listener as a router: validate, extract identifiers, dispatch a queued job, return. Never clone, deploy, or call slow APIs synchronously inside the listener — GitHub will retry the delivery and you will do the work twice.
Idempotency on X-GitHub-Delivery
GitHub retries failed deliveries with the same delivery UUID. Persist it (cache or a webhook_deliveries table with a unique index) and short-circuit duplicates:
$id = $request->header('X-GitHub-Delivery');
if (! Cache::add("gh:{$id}", true, now()->addDay())) {
return response('duplicate', 200);
}
This is the single highest-value addition on top of the library, because the library does not do it for you.
Verify before you parse
Call the signature validation path first and let InvalidSignatureException short-circuit. If you log the payload before validating, you are writing attacker-controlled data into your logs.
A catch-all listener for observability
Register a wildcard handler (if the package’s Config supports *, otherwise register your known event types) that increments a counter or logs X-GitHub-Event + X-GitHub-Delivery. This is how you discover you are silently dropping pull_request_review events.
Testing
tests/Fixtures/push.json) — the library takes the raw body, so tests are just file_get_contents.$sig = 'sha1=' . hash_hmac('sha1', $body, $secret);
Local development
Use smee.io or ngrok to forward to http://localhost:8000/webhooks/github. Set the secret identically in GitHub and in .env, otherwise you will spend an hour debugging a “signature mismatch” that is really a config mismatch. Replay a real delivery from Recent Deliveries → Redeliver instead of hand-crafting payloads.
The raw body is the signature input — never the parsed array
$request->all() or json_decode(...) re-serialised will not match the bytes GitHub signed. Always use $request->getContent() (Laravel) or file_get_contents('php://input'). This is the cause of ~90% of “invalid signature” issues with this class of library.
Header format is sha1=<hex>, not bare hex
GitHub sends X-Hub-Signature (sha1=…) and, for newer hooks, X-Hub-Signature-256 (sha256=…). If you pass the header through unchanged, make sure you are not stripping the prefix — and confirm which algorithm the library expects. Prefer configuring SHA-256 if the package version supports it.
If setSecret() is omitted, validation is skipped
Many implementations treat a missing secret as “no validation.” That means a misconfigured env var turns your endpoint into an unauthenticated public write API. Fail loudly: assert the secret is non-empty at boot.
The ping event fires the moment you save the hook
GitHub sends a ping payload immediately. If you have no listener for it, make sure the library/your controller still returns 200 — a 4xx/5xx on ping shows a red “last delivery failed” badge and confuses everyone during setup.
Exceptions are not HTTP responses
The library throws on invalid payloads/signatures. Unless your framework maps them, an uncaught exception becomes a 500, GitHub marks the delivery failed, and it retries. Wrap handle() once and translate: InvalidSignatureException → 401, malformed payload → 400, everything else → 500 with an error log.
This package’s last release is from late 2020
Practical implications: verify it installs and passes tests on your PHP version (8.1+), watch for dynamic-property deprecations and loose null handling, and pin it in composer.json. Budget half a day to vendor a fix if you hit an incompatibility — the codebase is tiny and readable, so forking a class is realistic. Long term, the two behaviours you actually need (HMAC verification + json_decode) are ~20 lines; keep that in mind if maintenance becomes a problem.
It parses; it does not model
Do not expect typed event classes for every GitHub event. You will get an Event with the type and an array payload, and you will build your own value objects per event. That is fine — but it means schema drift is on you. Add isset()/null checks on fields you rely on (pull_request.merged, ref, after) because payload shapes change and the library will not warn you.
Content type matters
Modern GitHub hooks are application/json. Older configurations use application/x-www-form-urlencoded with a payload field. Check what your hook is set to; the library likely assumes JSON, and a form-encoded hook will decode to an empty array (or throw) with no obvious error message.
Extension points
Config is the seam: custom listeners, the secret, and any event-type mapping live there. If you need to validate against multiple secrets (zero-downtime rotation), the cleanest path is a thin wrapper that tries the new secret, falls back to the old, then calls handle() — or a fork of the validator. Do not scatter hash_hmac calls through your app.
Do not rely on IP allowlisting
GitHub’s published hook IP ranges change and were deprecated in favour of the X-GitHub-Hook-IP header in 2023. Signature verification is the real control; treat IP checks as defence in depth only.
How can I help you explore Laravel packages today?