LaraWebhook is an open-source Laravel package for handling incoming webhooks in a secure, reliable, and simple way. Validate signatures, manage retries, log events, and integrate popular services (Stripe, GitHub, Slack, etc.) in minutes.
LaraWebhook is currently an experimental side project and MVP-like Laravel package.
It explores webhook reliability patterns such as signature validation, idempotency, logging, replay, provider-specific parsing, and privacy-conscious payload handling.
The package is usable for experimentation, internal tools, prototypes, and non-critical workloads. However, it should not be considered production-hardened by default for sensitive or business-critical webhook flows.
Before using LaraWebhook in production, you should carefully review and configure:
- dashboard and API access control;
- payload storage strategy;
- sensitive data redaction;
- log retention policy;
- replay permissions;
- provider secrets and signature validation;
- idempotency behavior.
The long-term direction is to evolve LaraWebhook into a safer, stricter, privacy-first webhook toolkit for Laravel, potentially as part of the broader Sovra ecosystem.
LaraWebhook may eventually become the Laravel integration layer for Sovra, a broader exploration around sovereign, privacy-first and self-hostable webhook infrastructure.
For now, LaraWebhook remains an experimental Laravel package focused on webhook validation, logging, replay and safer payload handling.
LaraWebhook can be used as a foundation for production webhook handling, but it should not be deployed blindly with default settings for sensitive or business-critical flows.
Before using LaraWebhook in production, review the following checklist. You can also read PRODUCTION_CHECKLIST.md
Make sure the dashboard and API routes are not publicly exposed.
Recommended actions:
- disable the dashboard if you do not need it;
- protect dashboard routes with authentication middleware;
- protect API routes with token-based authentication such as Laravel Sanctum;
- restrict replay endpoints to trusted users only.
Webhook payloads may contain sensitive or personal data such as emails, names, addresses, tokens, payment references, customer identifiers, or internal metadata.
Recommended actions:
- avoid storing full payloads unless strictly required;
- prefer redacted payloads or metadata-only storage;
- document why payloads are stored;
- avoid exposing raw payloads in logs, dashboards, notifications or error messages.
If payloads are stored, sensitive fields should be masked before persistence.
Recommended actions:
- redact fields such as
email,phone,address,token,secret,authorization,client_secret,password; - review provider-specific payloads;
- test redaction rules with real-world-like examples;
- never rely on manual cleanup after storage.
Webhook logs should not be kept forever by default.
Recommended actions:
- configure a retention period;
- prune old webhook logs regularly;
- keep shorter retention for full or redacted payloads;
- document your retention policy according to your application requirements.
Replay is useful for recovery and debugging, but it can trigger business actions again.
Recommended actions:
- restrict replay access;
- log replay attempts;
- avoid replaying non-idempotent handlers;
- make sure your application handlers are safe to execute more than once;
- consider disabling replay for sources where payloads are not stored.
Webhook validation depends on provider secrets.
Recommended actions:
- store secrets in environment variables or a secret manager;
- never commit secrets to the repository;
- rotate secrets when needed;
- use different secrets per environment;
- verify that invalid signatures are rejected.
Webhook providers may send the same event more than once.
Recommended actions:
- configure idempotency behavior per provider;
- use stable provider event IDs when available;
- fallback to payload hashes only when appropriate;
- make downstream handlers idempotent too.
Validation and delivery failures should be visible.
Recommended actions:
- monitor failed validations;
- monitor failed processing attempts;
- alert on repeated failures;
- distinguish invalid signatures from downstream processing errors.
LaraWebhook can help implement safer webhook handling practices, but compliance depends on your own application, configuration, data, infrastructure, contracts and operational procedures.
For more details, see SECURITY_AND_PRIVACY.md.
The dashboard is disabled by default.
To enable it:
LARAWEBHOOK_DASHBOARD_ENABLED=trueBefore enabling the dashboard in production, make sure dashboard routes are protected with authentication middleware.
The dashboard is disabled by default.
When enabling it, you should protect it with authentication middleware:
'dashboard' => [
'enabled' => env('LARAWEBHOOK_DASHBOARD_ENABLED', false),
'path' => env('LARAWEBHOOK_DASHBOARD_PATH', 'larawebhook/dashboard'),
'middleware' => ['web', 'auth'],
],You may also use authorization gates or custom middleware:
'middleware' => ['web', 'auth', 'can:viewLaraWebhookDashboard'],The LaraWebhook API is disabled by default.
To enable it:
LARAWEBHOOK_API_ENABLED=trueWhen enabling API routes in production, protect them with authentication middleware such as Laravel Sanctum or a custom token-based middleware.
The replay endpoint should be restricted to trusted users only, as replaying webhook events may trigger business actions again.
The LaraWebhook API is disabled by default.
When enabling API routes in production, you should protect them with authentication middleware:
'api' => [
'enabled' => env('LARAWEBHOOK_API_ENABLED', false),
'path' => env('LARAWEBHOOK_API_PATH', 'api/larawebhook'),
'middleware' => ['api', 'auth:sanctum'],
],The replay endpoint should be restricted to trusted users only, as replaying webhook events may trigger business actions again.
You may also use authorization gates or custom middleware:
'middleware' => ['api', 'auth:sanctum', 'can:manageLaraWebhook'],Webhook payloads may contain sensitive or personal data.
LaraWebhook supports three payload storage modes:
'payload_storage' => [
'mode' => env('LARAWEBHOOK_PAYLOAD_STORAGE_MODE', 'redacted'),
],Supported modes:
| Mode | Description |
|---|---|
none |
Do not store the webhook payload. Only metadata should be persisted. |
redacted |
Store a sanitized version of the payload. Sensitive fields are masked before persistence. |
full |
Store the full payload. This is useful for debugging and replay, but should be explicitly enabled only when required. |
The full mode may store personal or sensitive data depending on the provider payload. Use it carefully and configure a retention policy.
Redacted payload storage is being hardened progressively. Until redaction rules are configured, avoid assuming that all provider-specific sensitive fields are covered. In the current implementation,
redactedmode avoids storing raw payloads until the redaction engine is fully available.
Webhook logs should not be kept forever by default.
LaraWebhook provides a retention configuration that will be used by the prune command:
'retention' => [
'enabled' => env('LARAWEBHOOK_RETENTION_ENABLED', true),
'days' => (int) env('LARAWEBHOOK_RETENTION_DAYS', 30),
],By default, webhook logs become eligible for pruning after 30 days.
You should adjust this value according to your debugging needs, payload storage mode, legal requirements and internal
data retention policies.
The actual pruning command is handled separately by:
php artisan larawebhook:pruneA prune command will use this configuration to determine which logs are eligible for deletion.
LaraWebhook provides a prune command to delete old webhook logs according to your retention policy.
php artisan larawebhook:pruneBy default, the command uses:
'retention' => [
'enabled' => true,
'days' => 30,
],You can override the retention period at runtime:
php artisan larawebhook:prune --older-than=7dSupported duration units:
| Unit | Meaning |
|---|---|
| d | days |
| h | hours |
| m | minutes |
To preview how many logs would be deleted without deleting them:
php artisan larawebhook:prune --older-than=30d --dry-runYou can schedule pruning in your Laravel application:
use Illuminate\Support\Facades\Schedule;
Schedule::command('larawebhook:prune')->daily();LaraWebhook includes a payload redaction service that can mask sensitive fields before payloads are stored.
Default sensitive fields include:
[
'email',
'phone',
'address',
'token',
'secret',
'authorization',
'client_secret',
'password',
'api_key',
'access_token',
'refresh_token',
]When redaction is applied, matching fields are replaced with:
[REDACTED]
Matching is case-insensitive and recursive.
- Signature Validation: Verify webhook authenticity (Stripe, GitHub, Slack, Shopify)
- Automatic Idempotency: Duplicate webhooks are automatically rejected with
200 OK - Async Retry Management: Queue failed webhooks for background retry (returns 202 Accepted)
- Detailed Logging: Database logs + Laravel logs (
Log::info/error) for debugging - Failure Notifications: Get alerted via Email and Slack when webhooks fail repeatedly
- Interactive Dashboard: Modern UI with Alpine.js and Tailwind CSS for log management
- REST API: Programmatic access to webhook logs with filtering and pagination
- Replay Webhooks: Re-process failed webhooks from dashboard or API
- Fluent Facade API: Simple and expressive API via
Larawebhookfacade - Type-Safe Services:
WebhookServiceenum for IDE autocompletion and type safety - Easy Integration: Minimal configuration, compatible with Laravel 9+
- Extensible Architecture: Strategy Pattern for parsers and validators - add new services in minutes
-
Install the package via Composer:
composer require proxynth/larawebhook
-
Publish the configuration:
php artisan vendor:publish --provider="Proxynth\LaraWebhook\LaraWebhookServiceProvider" -
Configure your signature keys in
config/larawebhook.php:'stripe' => [ 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), 'tolerance' => 300, // Tolerance in seconds ],
The easiest way to validate webhooks is using the validate-webhook middleware:
// routes/web.php
Route::post('/stripe-webhook', function () {
// Webhook is automatically validated and logged
// Process your webhook here
$payload = json_decode(request()->getContent(), true);
// Handle the event
event(new \App\Events\StripeWebhookReceived($payload));
return response()->json(['status' => 'success']);
})->middleware('validate-webhook:stripe');
Route::post('/github-webhook', function () {
// Webhook is automatically validated and logged
$payload = json_decode(request()->getContent(), true);
// Handle the event
event(new \App\Events\GithubWebhookReceived($payload));
return response()->json(['status' => 'success']);
})->middleware('validate-webhook:github');What the middleware does:
- ✅ Validates the webhook signature
- ✅ Automatically logs the event to the database
- ✅ Rejects duplicate webhooks (returns
200 OKwithalready_processed) - ✅ Returns 403 for invalid signatures
- ✅ Returns 400 for missing headers or malformed payloads
For more control, you can manually validate webhooks:
// app/Http/Controllers/WebhookController.php
use Proxynth\Larawebhook\Facades\Larawebhook;
use Proxynth\Larawebhook\Ingestion\Domain\ValueObjects\Signature;
use Illuminate\Http\Request;
public function handleWebhook(Request $request)
{
$payload = $request->getContent();
$signature = Signature::fromString(
$request->header('Stripe-Signature')
);
$secret = config('larawebhook.services.stripe.webhook_secret');
try {
// Validate and log in one call
$log = Larawebhook::validateAndLog(
$payload,
$signature,
'stripe',
'payment_intent.succeeded'
);
// Process the event
event(new \App\Events\StripeWebhookReceived(json_decode($payload, true)));
return response()->json(['status' => 'success']);
} catch (\Exception $e) {
return response($e->getMessage(), 403);
}
}LaraWebhook provides a powerful Facade and an Enum for type-safe service handling.
The Larawebhook facade provides a fluent API for all webhook operations:
use Proxynth\Larawebhook\Shared\Infrastructure\Laravel\Facades\Larawebhook;
// Validate a webhook
Larawebhook::validate($payload, $signature, 'stripe');
// Validate and log
$log = Larawebhook::validateAndLog($payload, $signature, 'github', 'push');
// Log webhooks manually
Larawebhook::logSuccess('stripe', 'payment.succeeded', $payload);
Larawebhook::logFailure('stripe', 'payment.failed', $payload, 'Card declined');
// Query logs
$allLogs = Larawebhook::logs();
$stripeLogs = Larawebhook::logsForService('stripe');
$failedLogs = Larawebhook::failedLogs();
$successLogs = Larawebhook::successfulLogs();
// Notifications
Larawebhook::sendNotificationIfNeeded('stripe', 'payment.failed');
Larawebhook::notificationsEnabled(); // true/false
Larawebhook::getNotificationChannels(); // ['mail', 'slack']
// Configuration helpers
Larawebhook::getSecret('stripe'); // Returns webhook secret
Larawebhook::isServiceSupported('stripe'); // true
Larawebhook::supportedServices(); // ['stripe', 'github']The WebhookService enum centralizes all service-related configuration:
use Proxynth\Larawebhook\Enums\WebhookService;
// Available services
WebhookService::Stripe; // 'stripe'
WebhookService::Github; // 'github'
// Get signature header for a service
WebhookService::Stripe->signatureHeader(); // 'Stripe-Signature'
WebhookService::Github->signatureHeader(); // 'X-Hub-Signature-256'
// Get secret from config
WebhookService::Stripe->secret(); // Returns configured secret
// Get the payload parser (for extracting event types and metadata)
WebhookService::Stripe->parser(); // StripePayloadParser
WebhookService::Github->parser(); // GithubPayloadParser
// Get the signature validator (for verifying webhook authenticity)
WebhookService::Stripe->signatureValidator(); // StripeSignatureValidator
WebhookService::Github->signatureValidator(); // GithubSignatureValidator
// Check if a service is supported
WebhookService::isSupported('stripe'); // true
WebhookService::isSupported('unknown'); // false
// Convert from string
$service = WebhookService::tryFromString('stripe'); // WebhookService::Stripe
$service = WebhookService::fromString('stripe'); // WebhookService::Stripe (throws on invalid)
// Get all values (useful for validation rules)
WebhookService::values(); // ['stripe', 'github']
WebhookService::validationRule(); // ['stripe', 'github']All facade methods accept both strings and the enum:
use Proxynth\Larawebhook\Shared\Infrastructure\Laravel\Facades\Larawebhook;
use Proxynth\Larawebhook\Enums\WebhookService;
// Both are equivalent
Larawebhook::validate($payload, $signature, 'stripe');
Larawebhook::validate($payload, $signature, WebhookService::Stripe);
// Type-safe service handling
$service = WebhookService::Stripe;
$log = Larawebhook::validateAndLog($payload, $signature, $service, 'payment.succeeded');- Type Safety: IDE autocompletion and static analysis support
- Centralized Configuration: All service-related config in one place
- DRY Principle: No more duplicated service strings across the codebase
- Easy Extension: Add a new service by adding a case to the enum
LaraWebhook uses the Strategy Pattern for maximum extensibility. Each webhook service has its own:
- PayloadParser: Extracts event types and metadata from the webhook payload
- SignatureValidator: Validates the webhook signature according to the provider's format
src/
├── Contracts/
│ ├── PayloadParserInterface.php # Strategy interface for parsing
│ └── SignatureValidatorInterface.php # Strategy interface for validation
├── Parsers/
│ ├── StripePayloadParser.php # Stripe payload parsing
│ └── GithubPayloadParser.php # GitHub payload parsing
├── Validators/
│ ├── StripeSignatureValidator.php # Stripe signature validation
│ └── GithubSignatureValidator.php # GitHub signature validation
└── Enums/
└── WebhookService.php # Central delegation point
Adding a new webhook service requires just 4 steps:
Step 1: Create the Payload Parser
// src/Parsers/PaypalPayloadParser.php
namespace Proxynth\Larawebhook\Parsers;
use Proxynth\Larawebhook\Contracts\PayloadParserInterface;
class PaypalPayloadParser implements PayloadParserInterface
{
public function extractEventType(array $data): string
{
return $data['event_type'] ?? 'unknown';
}
public function extractMetadata(array $data): array
{
return [
'event_id' => $data['id'] ?? null,
'resource_type' => $data['resource_type'] ?? null,
'summary' => $data['summary'] ?? null,
];
}
public function serviceName(): string
{
return 'paypal';
}
}Step 2: Create the Signature Validator
// src/Validators/PaypalSignatureValidator.php
namespace Proxynth\Larawebhook\Validators;
use Proxynth\Larawebhook\Contracts\SignatureValidatorInterface;
use Proxynth\Larawebhook\Exceptions\InvalidSignatureException;
class PaypalSignatureValidator implements SignatureValidatorInterface
{
public function validate(string $payload, string $signature, string $secret, int $tolerance = 300): bool
{
// PayPal uses base64-encoded HMAC-SHA256
$expected = base64_encode(hash_hmac('sha256', $payload, $secret, true));
if (! hash_equals($expected, $signature)) {
throw new InvalidSignatureException('Invalid PayPal webhook signature.');
}
return true;
}
public function serviceName(): string
{
return 'paypal';
}
}Step 3: Register in the Enum
// src/Enums/WebhookService.php
enum WebhookService: string
{
case Stripe = 'stripe';
case Github = 'github';
case Paypal = 'paypal'; // Add the new case
public function parser(): PayloadParserInterface
{
return match ($this) {
self::Stripe => new StripePayloadParser,
self::Github => new GithubPayloadParser,
self::Paypal => new PaypalPayloadParser, // Add mapping
};
}
public function signatureValidator(): SignatureValidatorInterface
{
return match ($this) {
self::Stripe => new StripeSignatureValidator,
self::Github => new GithubSignatureValidator,
self::Paypal => new PaypalSignatureValidator, // Add mapping
};
}
public function signatureHeader(): string
{
return match ($this) {
self::Stripe => 'Stripe-Signature',
self::Github => 'X-Hub-Signature-256',
self::Paypal => 'PAYPAL-TRANSMISSION-SIG', // Add header
};
}
}Step 4: Add Configuration
// config/larawebhook.php
'services' => [
'paypal' => [
'webhook_secret' => env('PAYPAL_WEBHOOK_SECRET'),
'tolerance' => 300,
],
],That's it! Your new service is now fully integrated:
// Use with middleware
Route::post('/paypal-webhook', [PaypalController::class, 'handle'])
->middleware('validate-webhook:paypal');
// Or with the facade
Larawebhook::validate($payload, $signature, WebhookService::Paypal);You can access parsers directly for custom payload processing:
use Proxynth\Larawebhook\Enums\WebhookService;
$payload = json_decode($request->getContent(), true);
// Extract event type
$eventType = WebhookService::Stripe->parser()->extractEventType($payload);
// Returns: 'payment_intent.succeeded'
// Extract metadata
$metadata = WebhookService::Github->parser()->extractMetadata($payload);
// Returns: ['delivery_id' => '...', 'action' => 'opened', 'sender' => 'octocat', ...]For advanced use cases, you can use validators directly:
use Proxynth\Larawebhook\Enums\WebhookService;
$isValid = WebhookService::Stripe->signatureValidator()->validate(
payload: $rawPayload,
signature: $signatureHeader,
secret: config('larawebhook.services.stripe.webhook_secret'),
tolerance: 300
);Complete integration guides with real-world examples for popular webhook providers.
Add your Stripe webhook secret to .env:
STRIPE_WEBHOOK_SECRET=whsec_your_stripe_webhook_secret_hereThen configure the service in config/larawebhook.php:
'services' => [
'stripe' => [
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
'tolerance' => 300, // 5 minutes tolerance for timestamp validation
],
],Define the webhook route in routes/web.php:
use App\Http\Controllers\StripeWebhookController;
Route::post('/stripe-webhook', [StripeWebhookController::class, 'handle'])
->middleware('validate-webhook:stripe');Create the controller at app/Http/Controllers/StripeWebhookController.php:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
class StripeWebhookController extends Controller
{
public function handle(Request $request): JsonResponse
{
// Webhook is already validated by the middleware
$payload = json_decode($request->getContent(), true);
$event = $payload['type'] ?? 'unknown';
// Route to specific event handlers
match ($event) {
'payment_intent.succeeded' => $this->handlePaymentIntentSucceeded($payload),
'payment_intent.payment_failed' => $this->handlePaymentIntentFailed($payload),
'charge.succeeded' => $this->handleChargeSucceeded($payload),
'charge.failed' => $this->handleChargeFailed($payload),
'customer.subscription.created' => $this->handleSubscriptionCreated($payload),
'customer.subscription.updated' => $this->handleSubscriptionUpdated($payload),
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($payload),
'invoice.paid' => $this->handleInvoicePaid($payload),
'invoice.payment_failed' => $this->handleInvoicePaymentFailed($payload),
default => $this->handleUnknownEvent($event, $payload),
};
return response()->json(['status' => 'success']);
}
private function handlePaymentIntentSucceeded(array $payload): void
{
$paymentIntent = $payload['data']['object'];
Log::info('Stripe: Payment intent succeeded', [
'payment_intent_id' => $paymentIntent['id'],
'amount' => $paymentIntent['amount'],
'currency' => $paymentIntent['currency'],
'customer' => $paymentIntent['customer'],
]);
// Example: Update order status in your database
// Order::where('stripe_payment_intent_id', $paymentIntent['id'])
// ->update(['status' => 'paid']);
}
private function handlePaymentIntentFailed(array $payload): void
{
$paymentIntent = $payload['data']['object'];
Log::error('Stripe: Payment intent failed', [
'payment_intent_id' => $paymentIntent['id'],
'last_payment_error' => $paymentIntent['last_payment_error'],
]);
// Example: Notify customer of payment failure
// $order = Order::where('stripe_payment_intent_id', $paymentIntent['id'])->first();
// Mail::to($order->customer->email)->send(new PaymentFailedMail($order));
}
private function handleChargeSucceeded(array $payload): void
{
$charge = $payload['data']['object'];
Log::info('Stripe: Charge succeeded', [
'charge_id' => $charge['id'],
'amount' => $charge['amount'],
]);
}
private function handleChargeFailed(array $payload): void
{
$charge = $payload['data']['object'];
Log::error('Stripe: Charge failed', [
'charge_id' => $charge['id'],
'failure_message' => $charge['failure_message'],
]);
}
private function handleSubscriptionCreated(array $payload): void
{
$subscription = $payload['data']['object'];
Log::info('Stripe: Subscription created', [
'subscription_id' => $subscription['id'],
'customer' => $subscription['customer'],
'status' => $subscription['status'],
]);
// Example: Grant access to premium features
// User::where('stripe_customer_id', $subscription['customer'])
// ->update(['subscription_status' => 'active']);
}
private function handleSubscriptionUpdated(array $payload): void
{
$subscription = $payload['data']['object'];
Log::info('Stripe: Subscription updated', [
'subscription_id' => $subscription['id'],
'status' => $subscription['status'],
]);
}
private function handleSubscriptionDeleted(array $payload): void
{
$subscription = $payload['data']['object'];
Log::info('Stripe: Subscription deleted', [
'subscription_id' => $subscription['id'],
]);
// Example: Revoke access to premium features
// User::where('stripe_customer_id', $subscription['customer'])
// ->update(['subscription_status' => 'cancelled']);
}
private function handleInvoicePaid(array $payload): void
{
$invoice = $payload['data']['object'];
Log::info('Stripe: Invoice paid', [
'invoice_id' => $invoice['id'],
'amount_paid' => $invoice['amount_paid'],
]);
}
private function handleInvoicePaymentFailed(array $payload): void
{
$invoice = $payload['data']['object'];
Log::error('Stripe: Invoice payment failed', [
'invoice_id' => $invoice['id'],
'attempt_count' => $invoice['attempt_count'],
]);
}
private function handleUnknownEvent(string $event, array $payload): void
{
Log::warning('Stripe: Unknown event type received', [
'event_type' => $event,
]);
}
}┌─────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
│ │ │ │ │ │
│ Stripe Server │────────▶│ LaraWebhook │────────▶│ Your Application │
│ │ POST │ - Validates │ Valid │ - Process event │
│ (Webhook) │ │ signature │ │ - Update database │
│ │ │ - Logs event │ │ - Send emails │
└─────────────────┘ │ - Returns response │ │ │
└──────────────────────┘ └─────────────────────┘
│
│ Invalid signature
▼
┌──────────────────────┐
│ Returns 403 │
│ Forbidden │
└──────────────────────┘
Successful webhook processing creates a log entry:
{
"id": 1,
"service": "stripe",
"event": "payment_intent.succeeded",
"status": "success",
"payload": {
"id": "evt_1234567890",
"type": "payment_intent.succeeded",
"data": {
"object": {
"id": "pi_1234567890",
"amount": 5000,
"currency": "usd",
"customer": "cus_1234567890",
"status": "succeeded"
}
}
},
"attempt": 0,
"error_message": null,
"created_at": "2024-01-15 10:30:00"
}- Go to Stripe Dashboard
- Click Add endpoint
- Enter your webhook URL:
https://your-domain.com/stripe-webhook - Select events to listen for (or select "receive all events")
- Copy the Signing secret (starts with
whsec_) and add it to your.envfile
View webhook logs:
php artisan tinker
>>> \Proxynth\LaraWebhook\Models\WebhookLog::where('service', 'stripe')->latest()->first();Test with Stripe CLI:
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Forward webhooks to your local environment
stripe listen --forward-to http://localhost:8000/stripe-webhook
# Trigger a test webhook
stripe trigger payment_intent.succeededAdd your GitHub webhook secret to .env:
GITHUB_WEBHOOK_SECRET=your_github_webhook_secret_hereThen configure the service in config/larawebhook.php:
'services' => [
'github' => [
'webhook_secret' => env('GITHUB_WEBHOOK_SECRET'),
'tolerance' => 300,
],
],Define the webhook route in routes/web.php:
use App\Http\Controllers\GitHubWebhookController;
Route::post('/github-webhook', [GitHubWebhookController::class, 'handle'])
->middleware('validate-webhook:github');Create the controller at app/Http/Controllers/GitHubWebhookController.php:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
class GitHubWebhookController extends Controller
{
public function handle(Request $request): JsonResponse
{
// Webhook is already validated by the middleware
$payload = json_decode($request->getContent(), true);
$event = $request->header('X-GitHub-Event');
// Route to specific event handlers
match ($event) {
'push' => $this->handlePush($payload),
'pull_request' => $this->handlePullRequest($payload),
'pull_request_review' => $this->handlePullRequestReview($payload),
'issues' => $this->handleIssues($payload),
'issue_comment' => $this->handleIssueComment($payload),
'release' => $this->handleRelease($payload),
'workflow_run' => $this->handleWorkflowRun($payload),
'deployment' => $this->handleDeployment($payload),
'star' => $this->handleStar($payload),
default => $this->handleUnknownEvent($event, $payload),
};
return response()->json(['status' => 'success']);
}
private function handlePush(array $payload): void
{
$repository = $payload['repository']['full_name'];
$branch = str_replace('refs/heads/', '', $payload['ref']);
$commits = count($payload['commits']);
$pusher = $payload['pusher']['name'];
Log::info('GitHub: Push event received', [
'repository' => $repository,
'branch' => $branch,
'commits' => $commits,
'pusher' => $pusher,
]);
// Example: Trigger deployment for main branch
// if ($branch === 'main') {
// Artisan::call('deploy:production');
// }
}
private function handlePullRequest(array $payload): void
{
$action = $payload['action'];
$pr = $payload['pull_request'];
Log::info('GitHub: Pull request ' . $action, [
'pr_number' => $pr['number'],
'title' => $pr['title'],
'author' => $pr['user']['login'],
'state' => $pr['state'],
]);
match ($action) {
'opened' => $this->handlePullRequestOpened($pr),
'closed' => $this->handlePullRequestClosed($pr),
'reopened' => $this->handlePullRequestReopened($pr),
'synchronize' => $this->handlePullRequestSynchronize($pr),
default => null,
};
}
private function handlePullRequestOpened(array $pr): void
{
// Example: Send notification to Slack
// Notification::route('slack', config('services.slack.webhook'))
// ->notify(new NewPullRequestNotification($pr));
}
private function handlePullRequestClosed(array $pr): void
{
if ($pr['merged']) {
Log::info('GitHub: Pull request merged', [
'pr_number' => $pr['number'],
'merged_by' => $pr['merged_by']['login'] ?? 'unknown',
]);
} else {
Log::info('GitHub: Pull request closed without merge', [
'pr_number' => $pr['number'],
]);
}
}
private function handlePullRequestReopened(array $pr): void
{
Log::info('GitHub: Pull request reopened', [
'pr_number' => $pr['number'],
]);
}
private function handlePullRequestSynchronize(array $pr): void
{
Log::info('GitHub: Pull request synchronized (new commits)', [
'pr_number' => $pr['number'],
]);
// Example: Trigger CI/CD pipeline
// Artisan::call('ci:run', ['pr' => $pr['number']]);
}
private function handlePullRequestReview(array $payload): void
{
$review = $payload['review'];
$pr = $payload['pull_request'];
Log::info('GitHub: Pull request review submitted', [
'pr_number' => $pr['number'],
'reviewer' => $review['user']['login'],
'state' => $review['state'],
]);
}
private function handleIssues(array $payload): void
{
$action = $payload['action'];
$issue = $payload['issue'];
Log::info('GitHub: Issue ' . $action, [
'issue_number' => $issue['number'],
'title' => $issue['title'],
'author' => $issue['user']['login'],
]);
}
private function handleIssueComment(array $payload): void
{
$action = $payload['action'];
$comment = $payload['comment'];
$issue = $payload['issue'];
Log::info('GitHub: Issue comment ' . $action, [
'issue_number' => $issue['number'],
'commenter' => $comment['user']['login'],
]);
}
private function handleRelease(array $payload): void
{
$action = $payload['action'];
$release = $payload['release'];
Log::info('GitHub: Release ' . $action, [
'tag' => $release['tag_name'],
'name' => $release['name'],
'author' => $release['author']['login'],
]);
if ($action === 'published') {
// Example: Deploy to production
// Artisan::call('deploy:production', ['version' => $release['tag_name']]);
}
}
private function handleWorkflowRun(array $payload): void
{
$workflow = $payload['workflow_run'];
Log::info('GitHub: Workflow run ' . $workflow['conclusion'], [
'workflow' => $workflow['name'],
'status' => $workflow['status'],
'conclusion' => $workflow['conclusion'],
]);
}
private function handleDeployment(array $payload): void
{
$deployment = $payload['deployment'];
Log::info('GitHub: Deployment event', [
'environment' => $deployment['environment'],
'ref' => $deployment['ref'],
]);
}
private function handleStar(array $payload): void
{
$action = $payload['action'];
$repository = $payload['repository']['full_name'];
$stargazer = $payload['sender']['login'];
Log::info('GitHub: Repository ' . ($action === 'created' ? 'starred' : 'unstarred'), [
'repository' => $repository,
'stargazer' => $stargazer,
'stars' => $payload['repository']['stargazers_count'],
]);
}
private function handleUnknownEvent(string $event, array $payload): void
{
Log::warning('GitHub: Unknown event type received', [
'event_type' => $event,
]);
}
}┌─────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
│ │ │ │ │ │
│ GitHub Server │────────▶│ LaraWebhook │────────▶│ Your Application │
│ │ POST │ - Validates │ Valid │ - Process event │
│ (Webhook) │ │ X-Hub-Signature │ │ - Trigger CI/CD │
│ │ │ - Logs event │ │ - Send messages │
└─────────────────┘ │ - Returns response │ │ │
└──────────────────────┘ └─────────────────────┘
│
│ Invalid signature
▼
┌──────────────────────┐
│ Returns 403 │
│ Forbidden │
└──────────────────────┘
Successful webhook processing creates a log entry:
{
"id": 2,
"service": "github",
"event": "push",
"status": "success",
"payload": {
"ref": "refs/heads/main",
"repository": {
"full_name": "username/repository",
"html_url": "https://github.com/username/repository"
},
"pusher": {
"name": "username"
},
"commits": [
{
"id": "abc123def456",
"message": "feat: add new feature",
"author": {
"name": "John Doe",
"email": "john@example.com"
}
}
]
},
"attempt": 0,
"error_message": null,
"created_at": "2024-01-15 14:25:00"
}- Go to your repository Settings → Webhooks → Add webhook
- Payload URL:
https://your-domain.com/github-webhook - Content type:
application/json - Secret: Enter a strong secret and add it to your
.envfile - Events: Select individual events or "Send me everything"
- Active: Check this box
- Click Add webhook
View webhook logs:
php artisan tinker
>>> \Proxynth\LaraWebhook\Models\WebhookLog::where('service', 'github')->latest()->first();Test webhook delivery:
- Go to your repository Settings → Webhooks
- Click on your webhook
- Scroll to Recent Deliveries
- Click Redeliver on any delivery to resend it
Add your Slack signing secret to .env:
SLACK_WEBHOOK_SECRET=your_slack_signing_secret_hereGet your signing secret from your Slack app settings:
- Go to Slack API
- Select your app
- Go to Basic Information → App Credentials
- Copy the Signing Secret
Define the webhook route in routes/web.php:
use App\Http\Controllers\SlackWebhookController;
Route::post('/slack-webhook', [SlackWebhookController::class, 'handle'])
->middleware('validate-webhook:slack');Create the controller at app/Http/Controllers/SlackWebhookController.php:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class SlackWebhookController extends Controller
{
public function handle(Request $request): JsonResponse
{
$payload = json_decode($request->getContent(), true);
// Handle URL verification challenge
if (isset($payload['type']) && $payload['type'] === 'url_verification') {
return response()->json(['challenge' => $payload['challenge']]);
}
$eventType = $payload['event']['type'] ?? $payload['type'] ?? 'unknown';
match ($eventType) {
'app_mention' => $this->handleAppMention($payload),
'message' => $this->handleMessage($payload),
'block_actions' => $this->handleBlockActions($payload),
'view_submission' => $this->handleViewSubmission($payload),
default => $this->handleUnknownEvent($eventType, $payload),
};
return response()->json(['status' => 'success']);
}
private function handleAppMention(array $payload): void
{
$event = $payload['event'];
Log::info('Slack: App mentioned', [
'user' => $event['user'],
'channel' => $event['channel'],
'text' => $event['text'],
]);
// Example: Reply to the mention
// $this->slackClient->chat->postMessage([
// 'channel' => $event['channel'],
// 'text' => "Hi <@{$event['user']}>! How can I help?",
// ]);
}
private function handleMessage(array $payload): void
{
$event = $payload['event'];
// Ignore bot messages to prevent loops
if (isset($event['bot_id'])) {
return;
}
Log::info('Slack: Message received', [
'channel' => $event['channel'],
'user' => $event['user'] ?? 'unknown',
]);
}
private function handleBlockActions(array $payload): void
{
$action = $payload['actions'][0] ?? [];
Log::info('Slack: Block action triggered', [
'action_id' => $action['action_id'] ?? 'unknown',
'user' => $payload['user']['id'] ?? 'unknown',
]);
}
private function handleViewSubmission(array $payload): void
{
Log::info('Slack: View submitted', [
'view_id' => $payload['view']['id'] ?? 'unknown',
'user' => $payload['user']['id'] ?? 'unknown',
]);
}
private function handleUnknownEvent(string $eventType, array $payload): void
{
Log::warning('Slack: Unknown event type', ['event_type' => $eventType]);
}
}┌─────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
│ │ │ │ │ │
│ Slack Server │────────▶│ LaraWebhook │────────▶│ Your Application │
│ │ POST │ - Validates │ Valid │ - Process event │
│ (Event/Action) │ │ X-Slack-Signature │ │ - Reply to users │
│ │ │ - Checks timestamp │ │ - Update state │
└─────────────────┘ │ - Logs event │ │ │
└──────────────────────┘ └─────────────────────┘
- Go to Slack API and select your app
- Navigate to Event Subscriptions (for events) or Interactivity & Shortcuts (for interactions)
- Enable the feature and enter your URL:
https://your-domain.com/slack-webhook - For events, subscribe to the events you want (e.g.,
app_mention,message.channels) - Save changes and reinstall the app if prompted
Add your Shopify webhook secret to .env:
SHOPIFY_WEBHOOK_SECRET=your_shopify_webhook_secret_hereDefine the webhook route in routes/web.php:
use App\Http\Controllers\ShopifyWebhookController;
Route::post('/shopify-webhook', [ShopifyWebhookController::class, 'handle'])
->middleware('validate-webhook:shopify');Create the controller at app/Http/Controllers/ShopifyWebhookController.php:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class ShopifyWebhookController extends Controller
{
public function handle(Request $request): JsonResponse
{
$payload = json_decode($request->getContent(), true);
$topic = $request->header('X-Shopify-Topic');
$shopDomain = $request->header('X-Shopify-Shop-Domain');
Log::info('Shopify webhook received', [
'topic' => $topic,
'shop' => $shopDomain,
]);
match ($topic) {
'orders/create' => $this->handleOrderCreate($payload),
'orders/updated' => $this->handleOrderUpdated($payload),
'orders/cancelled' => $this->handleOrderCancelled($payload),
'orders/fulfilled' => $this->handleOrderFulfilled($payload),
'products/create' => $this->handleProductCreate($payload),
'products/update' => $this->handleProductUpdate($payload),
'products/delete' => $this->handleProductDelete($payload),
'customers/create' => $this->handleCustomerCreate($payload),
'refunds/create' => $this->handleRefundCreate($payload),
default => $this->handleUnknownTopic($topic, $payload),
};
return response()->json(['status' => 'success']);
}
private function handleOrderCreate(array $payload): void
{
Log::info('Shopify: Order created', [
'order_id' => $payload['id'],
'order_number' => $payload['order_number'],
'total_price' => $payload['total_price'],
'customer_email' => $payload['email'],
]);
// Example: Sync order to your database
// Order::create([
// 'shopify_id' => $payload['id'],
// 'number' => $payload['order_number'],
// 'total' => $payload['total_price'],
// 'currency' => $payload['currency'],
// 'status' => $payload['financial_status'],
// ]);
}
private function handleOrderUpdated(array $payload): void
{
Log::info('Shopify: Order updated', [
'order_id' => $payload['id'],
'financial_status' => $payload['financial_status'],
]);
}
private function handleOrderCancelled(array $payload): void
{
Log::info('Shopify: Order cancelled', [
'order_id' => $payload['id'],
'cancel_reason' => $payload['cancel_reason'] ?? 'unknown',
]);
}
private function handleOrderFulfilled(array $payload): void
{
Log::info('Shopify: Order fulfilled', [
'order_id' => $payload['id'],
]);
}
private function handleProductCreate(array $payload): void
{
Log::info('Shopify: Product created', [
'product_id' => $payload['id'],
'title' => $payload['title'],
]);
}
private function handleProductUpdate(array $payload): void
{
Log::info('Shopify: Product updated', [
'product_id' => $payload['id'],
]);
}
private function handleProductDelete(array $payload): void
{
Log::info('Shopify: Product deleted', [
'product_id' => $payload['id'],
]);
}
private function handleCustomerCreate(array $payload): void
{
Log::info('Shopify: Customer created', [
'customer_id' => $payload['id'],
'email' => $payload['email'],
]);
}
private function handleRefundCreate(array $payload): void
{
Log::info('Shopify: Refund created', [
'refund_id' => $payload['id'],
'order_id' => $payload['order_id'],
]);
}
private function handleUnknownTopic(?string $topic, array $payload): void
{
Log::warning('Shopify: Unknown topic', ['topic' => $topic]);
}
}┌─────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
│ │ │ │ │ │
│ Shopify Server │────────▶│ LaraWebhook │────────▶│ Your Application │
│ │ POST │ - Validates HMAC │ Valid │ - Sync orders │
│ (Webhook) │ │ X-Shopify-Hmac │ │ - Update inventory │
│ │ │ - Logs event │ │ - Process refunds │
└─────────────────┘ │ - Returns 200 │ │ │
└──────────────────────┘ └─────────────────────┘
Via Shopify Admin:
- Go to Settings → Notifications → Webhooks
- Click Create webhook
- Select the event (e.g.,
Order creation) - Format: JSON
- URL:
https://your-domain.com/shopify-webhook - API version: Select the latest stable version
- Click Save
- Copy the webhook signing secret and add to your
.env
Via Shopify API:
curl -X POST "https://your-shop.myshopify.com/admin/api/2024-01/webhooks.json" \
-H "X-Shopify-Access-Token: YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"webhook": {
"topic": "orders/create",
"address": "https://your-domain.com/shopify-webhook",
"format": "json"
}
}'# Install Shopify CLI
npm install -g @shopify/cli @shopify/theme
# Test webhook delivery
shopify webhook trigger --topic orders/create \
--api-version 2024-01 \
--delivery-method http \
--address https://your-domain.com/shopify-webhookWhen a webhook validation fails, you can automatically queue it for background retry instead of returning an error immediately.
WEBHOOK_RETRIES_ENABLED=true
WEBHOOK_ASYNC_RETRIES=true
WEBHOOK_MAX_ATTEMPTS=3- Webhook arrives, validation fails
- Middleware returns
202 Acceptedwith{"status": "accepted_for_retry"} RetryWebhookJobis dispatched to the queue with delay- Job retries validation in background
- If all retries fail, error is logged
// config/larawebhook.php
'retries' => [
'enabled' => true,
'max_attempts' => 3,
'delays' => [1, 5, 10], // seconds between retries
'async' => true, // Enable queue-based retries
],| Scenario | Response |
|---|---|
| Validation success | 200 OK + handler response |
| Duplicate webhook | 200 OK + {"status": "already_processed"} |
| Validation fails (async=true) | 202 Accepted + {"status": "accepted_for_retry"} |
| Validation fails (async=false) | 403 Forbidden |
All webhook events are automatically logged to Laravel's logging system in addition to the database.
- Success:
Log::info('Webhook processed successfully', $context) - Failure:
Log::error('Webhook validation failed: {message}', $context)
[
'service' => 'stripe',
'event' => 'payment_intent.succeeded',
'external_id' => 'evt_123',
'attempt' => 0,
]# Laravel default log
tail -f storage/logs/laravel.log | grep -i webhook
# Or use your logging channel (e.g., Papertrail, Loggly, etc.)✅ Always use HTTPS in production
// Force HTTPS for webhook routes in production
if (app()->environment('production')) {
URL::forceScheme('https');
}✅ Validate webhook signatures
// The validate-webhook middleware does this automatically
Route::post('/webhook', [Controller::class, 'handle'])
->middleware('validate-webhook:stripe');✅ Keep secrets in environment variables
# .env file (NEVER commit this file)
STRIPE_WEBHOOK_SECRET=whsec_your_secret_here
GITHUB_WEBHOOK_SECRET=your_github_secret_here✅ Rotate secrets regularly
- Update secrets in your webhook provider dashboard
- Update
.envfile - Deploy the change
- Delete old webhook endpoint after verifying the new one works
✅ Limit webhook IP addresses (optional)
// Only accept webhooks from Stripe IPs
Route::post('/stripe-webhook', [StripeWebhookController::class, 'handle'])
->middleware(['validate-webhook:stripe', 'throttle:60,1']);✅ Log all webhook events
// LaraWebhook automatically logs all webhooks to the database
// View them in the dashboard: /larawebhook/dashboard✅ Handle failures gracefully
private function handlePaymentFailed(array $payload): void
{
try {
// Process the event
$this->processPayment($payload);
} catch (\Exception $e) {
// Log the error
Log::error('Failed to process payment webhook', [
'error' => $e->getMessage(),
'payload' => $payload,
]);
// Notify administrators
// Notification::route('slack', config('services.slack.webhook'))
// ->notify(new WebhookProcessingFailed($e, $payload));
}
}✅ Use try-catch for external calls
private function handlePush(array $payload): void
{
try {
// Call external service
Http::timeout(5)->post('https://external-api.com/deploy', [
'repository' => $payload['repository']['name'],
]);
} catch (\Exception $e) {
Log::error('Failed to trigger deployment', [
'error' => $e->getMessage(),
]);
// Don't throw - webhook should still return 200 OK
}
}✅ Process webhooks asynchronously with queues
public function handle(Request $request): JsonResponse
{
$payload = json_decode($request->getContent(), true);
$event = $payload['type'];
// Dispatch to queue for async processing
ProcessStripeWebhook::dispatch($event, $payload);
// Return 200 immediately
return response()->json(['status' => 'success']);
}✅ Set reasonable timeouts
// Don't let webhook processing block the response
set_time_limit(30); // 30 seconds maxLaraWebhook automatically handles idempotency. The middleware extracts external IDs from webhook providers and rejects duplicates before they reach your handler.
Automatic Behavior:
- Duplicate webhooks return
200 OKwith{"status": "already_processed", "external_id": "..."} - This prevents infinite retries from providers
- Your handler only processes each webhook once
The idempotency resolution logic is now isolated internally so it can evolve without changing the public API.
Idempotency fallback
By default, LaraWebhook uses the provider external event ID as the idempotency key.
If no external ID is available, LaraWebhook falls back to a deterministic SHA-256 hash of the normalized payload.
Object keys are sorted recursively before hashing, so equivalent JSON objects produce the same idempotency key even if
their keys are ordered differently.
List order remains significant.
The generated fallback key is prefixed with payload_hash:.
External ID Sources:
| Service | External ID Source | Example |
|---|---|---|
| Stripe | Payload id field |
evt_1234567890abcdef |
| GitHub | X-GitHub-Delivery header |
abc123-delivery-uuid |
| Slack | Payload event_id field |
Ev1234567890 |
| Shopify | X-Shopify-Webhook-Id header |
b54557e4-e9e0-4d5c-8e6b-9d2e7a8b1c3d |
✅ Query logs by external ID (optional)
use Proxynth\Larawebhook\Audit\Infrastructure\Laravel\Persistence\Models\WebhookLog;
// Find a specific webhook
$log = WebhookLog::findByExternalId('stripe', 'evt_1234567890');
// Check if exists
$exists = WebhookLog::existsForExternalId('stripe', 'evt_1234567890');
// Filter by external ID
$logs = WebhookLog::service('github')
->externalId('abc123-delivery-id')
->get();Note: The
external_idcolumn has a unique constraint per service, preventing duplicate entries.
✅ Monitor webhook failures
# Check for recent failures
php artisan tinker
>>> \Proxynth\LaraWebhook\Models\WebhookLog::where('status', 'failed')
->where('created_at', '>', now()->subHour())
->count();✅ Enable automatic failure notifications
# LaraWebhook has built-in notifications for repeated failures
WEBHOOK_NOTIFICATIONS_ENABLED=true
WEBHOOK_NOTIFICATION_CHANNELS=mail,slack
WEBHOOK_EMAIL_RECIPIENTS=admin@example.com
WEBHOOK_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
WEBHOOK_FAILURE_THRESHOLD=3See the Failure Notifications section for complete setup.
✅ Use the dashboard for monitoring
- Access at
/larawebhook/dashboard - Filter by service, status, date
- Replay failed webhooks
- View detailed payloads and error messages
Ready-to-use code examples for common webhook integrations. Copy, paste, and customize!
The examples/ directory contains fully functional controller examples:
-
- Complete Stripe integration with payment intents, charges, subscriptions, and invoices
- Error handling and automatic logging
- Production-ready code with best practices
-
- Full GitHub webhook handling (push, PR, issues, releases, workflows)
- Auto-deployment on release
- Automatic retry on failure
-
- Step-by-step guide for adding custom services (Shopify example)
- Custom validator creation
- Middleware and controller setup
Option 1: Copy the Full Controller
# Copy the example you need
cp vendor/proxynth/larawebhook/examples/StripeWebhookController.php \
app/Http/Controllers/StripeWebhookController.phpOption 2: Use as Reference
Open the examples and copy specific methods you need:
// From examples/StripeWebhookController.php
private function handlePaymentIntentSucceeded(array $payload): void
{
$paymentIntent = $payload['data']['object'];
// Your custom logic here
$order = Order::where('stripe_payment_intent_id', $paymentIntent['id'])->first();
$order->update(['status' => 'paid']);
}Pattern 1: Simple Stripe Integration
// routes/web.php
Route::post('/stripe-webhook', [StripeWebhookController::class, 'handle'])
->middleware('validate-webhook:stripe');
// .env
STRIPE_WEBHOOK_SECRET=whsec_your_secret_herePattern 2: GitHub Auto-Deploy
// From GitHubWebhookController.php
private function handlePush(array $payload): void
{
$branch = str_replace('refs/heads/', '', $payload['ref']);
if ($branch === 'main') {
Artisan::call('deploy:production');
}
}Pattern 3: Custom Service (Shopify)
// 1. Create a signature validator implementing SignatureValidatorInterface
class ShopifySignatureValidator implements SignatureValidatorInterface
{
public function validate(string $payload, string $signature, string $secret, int $tolerance = 300): bool
{
$calculated = base64_encode(hash_hmac('sha256', $payload, $secret, true));
if (! hash_equals($calculated, $signature)) {
throw new InvalidSignatureException('Invalid Shopify signature.');
}
return true;
}
public function serviceName(): string
{
return 'shopify';
}
}
// 2. Create a payload parser implementing PayloadParserInterface
class ShopifyPayloadParser implements PayloadParserInterface
{
public function extractEventType(array $data): string
{
return $data['topic'] ?? 'unknown';
}
public function extractMetadata(array $data): array
{
return ['shop_domain' => $data['shop_domain'] ?? null];
}
public function serviceName(): string
{
return 'shopify';
}
}
// 3. Register in WebhookService enum (see Extensible Architecture section)For detailed usage instructions, testing strategies, and best practices, see:
- Examples README - Complete guide with patterns and tips
- Integration Examples - Stripe and GitHub integration guides below
Modify config/larawebhook.php to:
- Add services (Stripe, GitHub, etc.)
- Configure validation tolerance
- Enable retry management
- Set up failure notifications
- Customize the dashboard
Example:
'services' => [
'stripe' => [
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
'tolerance' => 300,
],
'github' => [
'webhook_secret' => env('GITHUB_WEBHOOK_SECRET'),
'tolerance' => 300,
],
],
'retries' => [
'enabled' => true,
'max_attempts' => 3,
'delays' => [1, 5, 10], // seconds
'async' => false, // Queue retries in background (returns 202 Accepted)
],
'notifications' => [
'enabled' => env('WEBHOOK_NOTIFICATIONS_ENABLED', false),
'channels' => ['mail', 'slack'],
'failure_threshold' => 3,
],
'dashboard' => [
'enabled' => true,
'path' => '/larawebhook/dashboard',
],Webhooks are logged in the webhook_logs table with:
- service (e.g., stripe, github)
- event (e.g., payment_intent.succeeded)
- status (success/failed)
- payload (webhook content)
- created_at
To view logs:
php artisan tinker
>>> \Proxynth\LaraWebhook\Models\WebhookLog::latest()->get();LaraWebhook provides a modern dashboard built with Alpine.js and Tailwind CSS to visualize and manage webhook logs.
The dashboard is automatically available at:
http://your-app.test/larawebhook/dashboard
Features:
- 📋 Paginated webhook logs table
- 🔍 Filter by service, status, and date
- 👁️ View detailed payload and error messages
- 🔄 Replay failed webhooks
- 📱 Fully responsive design
The package also provides REST API endpoints for programmatic access:
GET /api/larawebhook/logsQuery Parameters:
service- Filter by service (stripe, github, etc.)status- Filter by status (success, failed)date- Filter by date (YYYY-MM-DD)per_page- Results per page (default: 10)page- Page number
Example:
curl "https://your-app.test/api/larawebhook/logs?service=stripe&status=failed&per_page=25"Response:
{
"data": [
{
"id": 1,
"service": "stripe",
"event": "payment_intent.succeeded",
"status": "success",
"payload": {...},
"attempt": 0,
"created_at": "01/01/2024 10:30:00"
}
],
"meta": {
"current_page": 1,
"last_page": 5,
"per_page": 10,
"total": 50
},
"links": {
"first": "...",
"last": "...",
"prev": null,
"next": "..."
}
}POST /api/larawebhook/logs/{id}/replayExample:
curl -X POST "https://your-app.test/api/larawebhook/logs/123/replay" \
-H "Content-Type: application/json"Response:
{
"success": true,
"message": "Webhook replayed successfully!",
"log": {
"id": 124,
"service": "stripe",
"event": "payment_intent.succeeded",
"status": "success",
"attempt": 1
}
}Customize the dashboard in config/larawebhook.php:
'dashboard' => [
'enabled' => env('LARAWEBHOOK_DASHBOARD_ENABLED', true),
'path' => env('LARAWEBHOOK_DASHBOARD_PATH', '/larawebhook/dashboard'),
'middleware' => env('LARAWEBHOOK_DASHBOARD_MIDDLEWARE', 'web'),
],Disable the dashboard:
LARAWEBHOOK_DASHBOARD_ENABLED=falseChange the dashboard path:
LARAWEBHOOK_DASHBOARD_PATH=/admin/webhooksAdd authentication middleware:
LARAWEBHOOK_DASHBOARD_MIDDLEWARE=web,authLaraWebhook can automatically notify you when webhooks fail repeatedly. Get alerted via Email and Slack when a service experiences multiple consecutive failures.
- Detect outages early: Know immediately when a webhook provider has issues
- Reduce downtime: React quickly to integration problems
- Team collaboration: Send alerts to Slack channels for instant visibility
Enable notifications in config/larawebhook.php:
'notifications' => [
// Enable/disable failure notifications
'enabled' => env('WEBHOOK_NOTIFICATIONS_ENABLED', true),
// Notification channels (mail, slack)
'channels' => array_filter(explode(',', env('WEBHOOK_NOTIFICATION_CHANNELS', 'mail'))),
// Slack webhook URL (create an Incoming Webhook in your Slack app)
'slack_webhook' => env('WEBHOOK_SLACK_WEBHOOK_URL'),
// Email recipients for failure notifications
'email_recipients' => array_filter(explode(',', env('WEBHOOK_EMAIL_RECIPIENTS', ''))),
// Number of consecutive failures before sending notification
'failure_threshold' => (int) env('WEBHOOK_FAILURE_THRESHOLD', 3),
// Time window in minutes to count failures
'failure_window_minutes' => (int) env('WEBHOOK_FAILURE_WINDOW', 30),
// Cooldown in minutes between notifications for the same service/event
'cooldown_minutes' => (int) env('WEBHOOK_NOTIFICATION_COOLDOWN', 30),
],Add these to your .env file:
# Enable notifications
WEBHOOK_NOTIFICATIONS_ENABLED=true
# Channels: mail, slack (comma-separated)
WEBHOOK_NOTIFICATION_CHANNELS=mail,slack
# Email recipients (comma-separated)
WEBHOOK_EMAIL_RECIPIENTS=admin@example.com,devops@example.com
# Slack incoming webhook URL
WEBHOOK_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL
# Number of failures before alerting (default: 3)
WEBHOOK_FAILURE_THRESHOLD=3
# Time window for counting failures in minutes (default: 30)
WEBHOOK_FAILURE_WINDOW=30
# Cooldown between notifications in minutes (default: 30)
WEBHOOK_NOTIFICATION_COOLDOWN=30┌─────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
│ │ │ │ │ │
│ Webhook Fails │────▶│ FailureDetector │────▶│ Send Notification │
│ (3rd time) │ │ - Count failures │ │ - Email │
│ │ │ - Check threshold │ │ - Slack │
│ │ │ - Check cooldown │ │ │
└─────────────────┘ └──────────────────────┘ └─────────────────────┘
│
│ Below threshold
│ or in cooldown
▼
┌──────────────────────┐
│ No notification │
│ (prevents spam) │
└──────────────────────┘
- Failure Detection: Counts consecutive failures for each service/event combination
- Threshold Check: Only triggers notification after N failures (configurable)
- Time Window: Only counts failures within the last X minutes
- Cooldown: Prevents notification spam by waiting between alerts
- Go to Slack API
- Click Create New App → From scratch
- Give your app a name and select your workspace
- Go to Incoming Webhooks and toggle it On
- Click Add New Webhook to Workspace
- Select the channel for notifications (e.g.,
#alertsor#monitoring) - Copy the webhook URL and add it to your
.envfile
Webhook URL format: https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX
Email notifications use Laravel's built-in mail system. Make sure your mail configuration is set up in .env:
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=your_username
MAIL_PASSWORD=your_password
MAIL_FROM_ADDRESS=noreply@example.com
MAIL_FROM_NAME="LaraWebhook"Email Notification:
- Subject:
Webhook Failure Alert: {service} - Service and event name
- Number of consecutive failures
- Last attempt timestamp
- Error message (if available)
- Link to dashboard
Slack Notification:
- Red alert color (danger level)
- Service and event details
- Failure count
- Error message
- Direct link to dashboard
LaraWebhook dispatches an event when a notification is sent, allowing you to add custom logic:
use Proxynth\Larawebhook\Audit\Infrastructure\Laravel\Events\WebhookNotificationSent;
// In your EventServiceProvider
protected $listen = [
WebhookNotificationSent::class => [
YourCustomListener::class,
],
];
// Your listener
class YourCustomListener
{
public function handle(WebhookNotificationSent $event): void
{
// $event->log - The WebhookLog model
// $event->failureCount - Number of failures
// Example: Log to external monitoring service
Http::post('https://monitoring.example.com/webhook-failure', [
'service' => $event->log->service,
'event' => $event->log->event,
'failures' => $event->failureCount,
]);
}
}LaraWebhook includes built-in spam prevention:
- Failure Threshold: Only notifies after N consecutive failures (default: 3)
- Time Window: Only counts failures within the last X minutes (default: 30)
- Cooldown Period: Won't send another notification for the same service/event within X minutes (default: 30)
Example scenario:
- Stripe
payment.failedfails 3 times in 10 minutes → Notification sent - 5 more failures in the next 20 minutes → No notification (cooldown active)
- After 30 minutes, 3 more failures → Notification sent again
To completely disable notifications:
WEBHOOK_NOTIFICATIONS_ENABLED=falseOr to disable only for certain environments, use Laravel's configuration:
// config/larawebhook.php
'notifications' => [
'enabled' => env('WEBHOOK_NOTIFICATIONS_ENABLED', app()->environment('production')),
// ... other settings
],Run tests with:
composer test(Tests cover validation, retries, and logging.)
This project uses Release Please for automated releases and changelog management.
-
Commit with Conventional Commits format:
git commit -m "feat: add new webhook validation" git commit -m "fix: resolve signature verification bug" git commit -m "docs: update installation instructions"
-
Release Please creates a PR automatically when changes are pushed to
master:- Generates/updates
CHANGELOG.mdbased on commits - Bumps version in
.release-please-manifest.json - Creates a release PR titled "chore(master): release X.Y.Z"
- Generates/updates
-
Review and merge the release PR:
- Review the generated changelog
- Merge the PR to trigger the release
-
Automatic actions on merge:
- Creates a GitHub Release with tag
vX.Y.Z - Runs tests and static analysis
- Packagist syncs automatically (no manual webhook needed)
- Creates a GitHub Release with tag
feat:→ New feature (bumps minor version)fix:→ Bug fix (bumps patch version)docs:→ Documentation changesstyle:→ Code style changes (formatting, etc.)refactor:→ Code refactoringperf:→ Performance improvementstest:→ Adding/updating testschore:→ Maintenance tasksci:→ CI/CD changes
Breaking changes: Add ! after type or add BREAKING CHANGE: in commit body to bump major version.
Example:
git commit -m "feat!: change webhook validation API"LaraWebhook follows a pragmatic architecture inspired by Domain Driven Design, hexagonal architecture and CQRS.
The goal is to keep the package readable, testable and explicit without introducing unnecessary abstractions.
See ARCHITECTURE.md for details.
- Fork the repository
- Create a branch (
git checkout -b feature/my-feature) - Commit your changes (
git commit -am 'Add my feature') - Push the branch (
git push origin feature/my-feature) - Open a Pull Request
(See CONTRIBUTING.md for more details.)
This project is licensed under the MIT License. See LICENSE for more information.




