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 — pushes: commits(), ref(), repository(), pusher().
  • PullRequestEventReceived — pull/merge request events: pullRequest(), repository(), action().

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 / 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.

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 is 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. With receiving disabled and no explicit url, it throws InvalidArgumentException rather than guessing an address.

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.

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.