NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

Enable receiving and set a secret per provider:

GIT_WEBHOOKS_ENABLED=true
GITHUB_WEBHOOK_SECRET=your-github-secret
GITLAB_WEBHOOK_SECRET=your-gitlab-secret
BITBUCKET_WEBHOOK_SECRET=your-bitbucket-secret

Incoming requests to POST {webhooks.path}/{provider} — /git/webhooks/github by default, route name git.webhooks — are signature-verified and dispatched as events. Inbound payloads run through the same canonical mappers as reads, so listeners get typed, provider-agnostic accessors instead of hand-parsing three raw shapes:

use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Git\Events\{WebhookReceived, PushReceived, PullRequestEventReceived};

Event::listen(PushReceived::class, function (PushReceived $event) {
    foreach ($event->commits() as $commit) {   // list<Commit>, canonical
        $commit->sha;
    }
    $event->ref();          // 'refs/heads/main' on every forge ('refs/tags/v1.0' for a tag)
    $event->repository();   // ?Repository
    $event->pusher();       // ?Author
});

Event::listen(PullRequestEventReceived::class, function (PullRequestEventReceived $event) {
    $event->pullRequest()?->state; // ResourceState
    $event->repository();          // ?Repository
    $event->action();              // raw event type
});

Event::listen(WebhookReceived::class, function (WebhookReceived $event) {
    $event->event->provider;       // ProviderName
    $event->event->type;           // e.g. 'push', 'Merge Request Hook', 'repo:push'
    $event->event->payload();      // the decoded body (also ->raw())
});
  • WebhookReceived — fires for every verified delivery, carrying the WebhookEvent (provider, type, payload).
  • PushReceived — branch and tag pushes on every forge, GitLab’s Tag Push Hook included: commits(), ref(), repository(), pusher().
  • PullRequestEventReceived — pull/merge request events: pullRequest(), repository(), action().

A WebhookEvent’s pullRequest() is null unless the delivery carries a pull request — GitHub pull request, review and review comment events, Bitbucket pull request and comment events, and on GitLab a Merge Request Hook, a Note Hook on a merge request or a merge request pipeline’s Pipeline Hook; issue, other comment, branch pipeline and push hooks give null. GitLab hooks carry less than the API: the author is the triggering user only when that user authored the merge request, a Note Hook’s merge request has no url, and a pipeline’s has no body, author or draft flag, and its createdAt is the time of mapping. repository() from a GitLab hook is a snapshot — owner->id is '' unless the hook carries a namespace_id, createdAt is the time of mapping, lastActivityAt the hook’s own event time — so fetch the repository through the API for the full record.

Verification

ProviderSignature checkEvent headerPush / PR types
GitHubX-Hub-Signature-256: sha256=<HMAC>X-GitHub-Eventpush / pull_request
GitLabX-Gitlab-Token (shared secret)X-Gitlab-EventPush Hook, Tag Push Hook / Merge Request Hook
BitbucketX-Hub-Signature: sha256=<HMAC>X-Event-Keyrepo:push / pullrequest:created, updated, fulfilled, rejected

A missing or invalid signature — or no secret configured for that provider — returns 403 and dispatches nothing; an unknown provider returns 404. The HMAC is computed over the raw request body and compared in constant time via crypto-for-laravel, so a forged payload never reaches your listeners and a partially-correct signature leaks nothing through timing.

A GitHub hook may use either content type. With application/x-www-form-urlencoded — the default for a hook added in GitHub’s UI — the JSON arrives as a payload field, which the route decodes from the signed body, never from the query string. Hooks the package creates use JSON.

Your own route

Keep GIT_WEBHOOKS_ENABLED=false and run the same check yourself:

use Illuminate\Http\Request;
use RoundlyConsulting\Git\Enums\ProviderName;
use RoundlyConsulting\Git\Facades\Git;

Route::post('/hooks/github', function (Request $request) {
    abort_unless(Git::verifyWebhook(ProviderName::Github, $request), 403);

    // … your handling
});

verifyWebhook() reads git.providers.<name>.webhook_secret; with no secret configured it answers false — nothing verifies, rather than everything.

Auto-registration

repo($path)->webhooks() ties this app’s inbound route to the provider’s create-webhook operation: it derives the URL from the git.webhooks route, defaults the secret to the configured webhook_secret — sent to every forge, so deliveries are signed and pass the route’s verification — and is idempotent: a hook with the same URL and the same events (in any order, native spellings included) is returned without a request, never created twice:

$webhooks = Git::github()->repo('acme/api')->webhooks();

$webhooks->register();                                 // returns the existing or new Webhook
$webhooks->register(events: ['push', 'pull_request']);
$webhooks->register(url: 'https://example.com/git/webhooks/github', secret: '...');
$webhooks->all();                                      // list<Webhook>
$webhooks->registered($url);                           // bool
$webhooks->delete($webhook->id);                       // numeric id; a braced {uuid} on Bitbucket
$webhooks->deleteByUrl($url);                          // bool — whether anything was deleted

register() subscribes to push by default, and all() reads every page of hooks on all three forges, so registered() and deleteByUrl() see them all. register() throws InvalidArgumentException and sends nothing when receiving is disabled and no explicit url is given (rather than guessing an address), when the package’s own route would get a hook it can’t verify — no webhook_secret configured, or a different $secret passed — and when a hook at the URL has other events (delete it, or register the events it has). An explicit url may carry any secret or none.

Event names

Events use GitHub’s names on every forge — push, pull_request, issues — and each driver subscribes to its own equivalents:

EventGitHubGitLabBitbucket
pushpushpush_eventsrepo:push
pull_requestpull_requestmerge_requests_eventspullrequest:created, :updated, :fulfilled, :rejected
issuesissuesissues_events—

A forge’s native name passes through as is (a GitLab *_events flag, a Bitbucket scope:action). GitLab refuses an event it has no flag for with InvalidArgumentException rather than dropping it, and cannot create an inactive hook (FeatureNotSupportedException). all() reads the events back under these canonical names — on Bitbucket pull_request only when the hook has all four pull request events; a partial subscription is listed by its native names (pullrequest:created).

The canonical push subscribes GitLab’s push_events only, and GitLab delivers tag pushes to hooks with tag_push_events — register ['push', 'tag_push_events'] to receive them.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.