Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Github Webhooks Laravel Package

spatie/laravel-github-webhooks

Handle GitHub webhooks in Laravel. The package verifies GitHub signatures on incoming requests, logs valid calls to the database, and lets you dispatch jobs or events when specific webhooks hit your app.

View on GitHub
Deep Wiki
Context7

Getting Started

The package is a thin, opinionated layer over spatie/laravel-webhook-client that adds GitHub-specific signature verification, an event/action naming convention (issues.opened), and a Route::githubWebhooks() macro. You can have a working endpoint in under ten minutes.

Minimum viable setup:

  1. composer require spatie/laravel-github-webhooks
  2. Publish both tags — config and migrations:
    php artisan vendor:publish --provider="Spatie\GitHubWebhooks\GitHubWebhooksServiceProvider" --tag="github-webhooks-config"
    php artisan vendor:publish --provider="Spatie\GitHubWebhooks\GitHubWebhooksServiceProvider" --tag="github-webhooks-migrations"
    php artisan migrate
    
  3. Set GITHUB_WEBHOOK_SECRET in .env to the same secret you configure on the GitHub webhook.
  4. Register the route in routes/api.php (not web.php):
    Route::githubWebhooks('webhook-route-configured-at-the-github-webhooks-settings');
    
  5. On GitHub, add a webhook pointing at that URL with content type application/json.

Where to look first:

  • config/github-webhooks.php — this is the entire public API surface. The jobs map is where you will spend 95% of your time.
  • The published migration — confirms the github_webhook_calls table and its payload/exception columns, which are your debugging lifeline.
  • Spatie\GitHubWebhooks\Models\GitHubWebhookCall — the three methods you'll actually call (payload(), eventActionName(), headers()).

First use case: capture issue-opened events and queue work. Create App\Jobs\GitHubWebhooks\HandleIssueOpenedWebhookJob implements ShouldQueue, take a GitHubWebhookCall in the constructor, then register it:

'jobs' => [
    'issues.opened' => \App\Jobs\GitHubWebhooks\HandleIssueOpenedWebhookJob::class,
],

Hit "Redeliver" on the webhook in GitHub, confirm a row in github_webhook_calls, confirm the job ran. That feedback loop is the whole development cycle for this package.

Implementation Patterns

Prefer jobs over event listeners. Both work, but the jobs config map is the intended happy path and gives you one file per event with a self-documenting name. The event-based approach (github-webhooks::issues.opened) requires string-keyed listeners in EventServiceProvider, which are less discoverable and easy to typo silently. Use events only when you already have a domain-event architecture and want webhooks to feed into it alongside other triggers.

Exploit the three levels of job granularity. The map supports issues.opened, issues, and *, with the more specific key winning. A practical shape:

'jobs' => [
    'pull_request.opened'  => HandlePrOpened::class,
    'pull_request.closed'  => HandlePrClosed::class,
    'issues'               => HandleAnyIssueEvent::class,   // fallback for issues.*
    '*'                    => LogUnknownWebhook::class,    // observability net
],

The '*' entry is worth having from day one — it's how you discover which events GitHub is actually sending you that you forgot to handle.

Keep jobs thin and idempotent. GitHub retries on any non-2xx and users can hit "Redeliver" manually, so the same payload may be processed more than once. Derive an idempotency key from $webhookCall->headers()->get('X-GitHub-Delivery') (a GUID per delivery) and either short-circuit or make the downstream operation naturally idempotent. Do not assume the job fires exactly once.

Read payloads defensively with dot notation. $webhookCall->payload('issue.user.login') is far cleaner than nested array access with ?? null at every level, but it returns null on a miss — validate before trusting it, especially for * handlers where the payload shape is unknown.

Use the custom model as a hook point, sparingly. Setting model to a subclass of GitHubWebhookCall lets you add casts, accessors, or a payloadAsIssue() helper that all your jobs share. That's a genuine win when you have six jobs all parsing issue payloads. Resist the urge to put business logic on the model.

Custom job class for cross-cutting pre/post logic. Override job with a subclass of ProcessGitHubWebhookJob and wrap parent::handle() if you need logging, timing, tenant resolution, or a kill switch that applies to every event. This is the cleanest place for "stop processing webhooks during deploy" style guards.

Replay is your recovery plan. When a handler throws, the exception is written to the exception column and GitHub gets a 500. After you fix the bug, don't ask GitHub to redeliver — replay locally:

dispatch(new ProcessGitHubWebhookJob(GitHubWebhookCall::find($id)));

Build a tiny artisan command for this (webhooks:replay {id} or webhooks:replay --event=issues.opened --since=...). It pays for itself the first time a deploy ships a broken handler and you have 200 failed rows.

Filter with the profile class. ProcessEverythingWebhookProfile stores and processes every valid request — noisy for a busy repo. A custom profile implementing WebhookProfile lets you ignore events from forks, bots, or irrelevant branches before a row is written and a job is queued. This is the right place for that filtering, not inside each job.

Testing: fake the queue, postJson() to your webhook route with a correctly-computed X-Hub-Signature-256 header, and assert both the database row and the dispatched job. For handler unit tests, skip HTTP entirely — construct a GitHubWebhookCall with a fixture payload and call the job's handle() directly.

Gotchas and Tips

The README contradicts itself on "custom logic." The section titled Performing custom logic tells you to set the model config key to a class extending ProcessGitHubWebhookJob. That's wrong — model must extend GitHubWebhookCall; the job class belongs under the job key. Getting this backwards produces a confusing type error at dispatch time. Trust the config file comments over that prose.

Content type must be application/json. If the GitHub webhook is configured for application/x-www-form-urlencoded, signature verification and payload parsing fail. This is set at webhook creation time in GitHub and is the single most common setup mistake.

Route placement matters. In api.php you get no session and no CSRF. If you must use web.php, add the URI to VerifyCsrfToken::$except — otherwise you'll see 419s in the log and nothing in the database, which looks exactly like a signature failure until you check the status code.

200 is returned even for unhandled events, but not for exceptions. Valid signature → row stored, job queued, 200 back. Handler throws → exception column populated, 500 back, GitHub retries later. Invalid signature → WebhookFailed thrown, nothing is logged. If a request seems to have vanished entirely, the signature is the first thing to check: verify the secret matches byte-for-byte and that your reverse proxy isn't mangling headers.

X-Hub-Signature-256 vs. X-Hub-Signature. GitHub sends SHA-256 today; older SHA-1 headers exist on legacy configs. If you see 100% WebhookFailed on an otherwise-correct setup, inspect the actual headers ($webhookCall->headers() won't help — the request never gets stored) by logging in the controller or dumping at the route level.

The ping event fires the moment you create the webhook. It lands in your table with no matching job unless you map 'ping'. Harmless but confusing during the first setup; map it to a no-op or your * handler.

Local development needs a tunnel. GitHub can't reach localhost. Use ngrok/expose/Valet share. For signature friction during development you can set GITHUB_SIGNATURE_VERIFY=false — but never ship that to staging, and be aware it means anyone can POST to the endpoint and get a row written.

Pruning is scheduled, not automatic. The model is MassPrunable, but nothing deletes rows until you add the model:prune schedule entry. On a busy repo the table grows fast — each row holds a full JSON payload. Schedule it daily and keep prune_webhook_calls_after_days modest (7–10) unless you need a long replay window for audit purposes. Note the pruning targets successfully processed calls; failed ones with exceptions stick around, which is usually what you want but worth confirming against your volume.

Re-run config cache after editing jobs. php artisan config:cache bakes the map in; forgetting to recache after adding a handler is a silent "job never dispatched" bug. Same class of problem as any Laravel config map, but here it manifests as an event simply doing nothing.

Payloads are serialized into the queue. The whole GitHubWebhookCall model goes into the job constructor, so the full payload rides along in the queue payload. Large push events with many commits can bloat your queue storage. If that matters, pass only the ID and reload in handle() — but you lose the replay-after-delete guarantee, so usually it doesn't.

No ordering guarantees. Jobs are queued and processed concurrently. A pull_request.opened and pull_request.synchronize can land out of order. If your handler depends on sequence (state machines, status transitions), check the payload's updated_at/created_at or the PR head SHA rather than assuming arrival order.

Small ecosystem (93 stars, 0 dependents). The package is a Spatie-maintained wrapper with a narrow surface, so the risk is low — but when something behaves unexpectedly, the answer is usually in spatie/laravel-webhook-client, which does the actual signature verification and dispatch. Read that package's source when debugging profile or signature behavior; this one just configures it.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core
nodisrupt/laravel-agent
sierratecnologia/media-manager