Laravel 13 Webhook 处理完全指南

Webhook 是现代应用集成的重要机制。本文将深入探讨 Laravel 13 中 Webhook 处理的各种方法和最佳实践。

接收 Webhook

基础 Webhook 控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class WebhookController extends Controller
{
public function handle(Request $request)
{
$payload = $request->all();

Log::info('Webhook received', $payload);

return response()->json(['status' => 'received'], 200);
}
}

签名验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class VerifyWebhookSignature
{
public function handle(Request $request, Closure $next, string $secretKey = null): Response
{
$signature = $request->header('X-Webhook-Signature');

if (!$signature) {
abort(401, 'Missing signature');
}

$secret = config("webhooks.secrets.{$secretKey}");
$payload = $request->getContent();

$expectedSignature = hash_hmac('sha256', $payload, $secret);

if (!hash_equals($expectedSignature, $signature)) {
abort(401, 'Invalid signature');
}

return $next($request);
}
}

Stripe Webhook

Stripe Webhook 处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Stripe\Webhook;
use Stripe\Exception\SignatureVerificationException;

class StripeWebhookController extends Controller
{
public function handle(Request $request)
{
$payload = $request->getContent();
$signature = $request->header('Stripe-Signature');
$secret = config('services.stripe.webhook_secret');

try {
$event = Webhook::constructEvent(
$payload,
$signature,
$secret
);
} catch (SignatureVerificationException $e) {
return response()->json(['error' => 'Invalid signature'], 400);
}

return match ($event->type) {
'payment_intent.succeeded' => $this->handlePaymentSucceeded($event->data->object),
'payment_intent.payment_failed' => $this->handlePaymentFailed($event->data->object),
'customer.subscription.created' => $this->handleSubscriptionCreated($event->data->object),
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($event->data->object),
default => response()->json(['status' => 'ignored']),
};
}

protected function handlePaymentSucceeded($paymentIntent)
{
$order = Order::where('stripe_payment_intent_id', $paymentIntent->id)->first();

if ($order) {
$order->update(['status' => 'paid']);
event(new PaymentSucceeded($order));
}

return response()->json(['status' => 'processed']);
}

protected function handlePaymentFailed($paymentIntent)
{
$order = Order::where('stripe_payment_intent_id', $paymentIntent->id)->first();

if ($order) {
$order->update(['status' => 'failed']);
event(new PaymentFailed($order));
}

return response()->json(['status' => 'processed']);
}
}

GitHub Webhook

GitHub Webhook 处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class GitHubWebhookController extends Controller
{
public function handle(Request $request)
{
$signature = $request->header('X-Hub-Signature-256');
$secret = config('services.github.webhook_secret');
$payload = $request->getContent();

$expectedSignature = 'sha256=' . hash_hmac('sha256', $payload, $secret);

if (!hash_equals($expectedSignature, $signature)) {
return response()->json(['error' => 'Invalid signature'], 401);
}

$event = $request->header('X-GitHub-Event');
$data = $request->json()->all();

return match ($event) {
'push' => $this->handlePush($data),
'pull_request' => $this->handlePullRequest($data),
'issues' => $this->handleIssues($data),
'release' => $this->handleRelease($data),
default => response()->json(['status' => 'ignored']),
};
}

protected function handlePush(array $data)
{
$repository = $data['repository']['full_name'];
$branch = str_replace('refs/heads/', '', $data['ref']);
$commits = $data['commits'];

foreach ($commits as $commit) {
dispatch(new ProcessCommit($repository, $branch, $commit));
}

return response()->json(['status' => 'processed']);
}

protected function handlePullRequest(array $data)
{
$action = $data['action'];
$pullRequest = $data['pull_request'];

if ($action === 'opened' || $action === 'synchronize') {
dispatch(new AnalyzePullRequest($pullRequest));
}

return response()->json(['status' => 'processed']);
}
}

通用 Webhook 服务

Webhook 服务类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
<?php

namespace App\Services;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class WebhookService
{
protected array $handlers = [];

public function registerHandler(string $event, callable $handler): self
{
$this->handlers[$event][] = $handler;
return $this;
}

public function handle(Request $request, string $source): array
{
$event = $this->extractEvent($request, $source);
$payload = $this->extractPayload($request, $source);

$this->log($source, $event, $payload);

$results = [];

if (isset($this->handlers[$event])) {
foreach ($this->handlers[$event] as $handler) {
try {
$results[] = $handler($payload);
} catch (\Exception $e) {
Log::error("Webhook handler failed", [
'source' => $source,
'event' => $event,
'error' => $e->getMessage(),
]);
}
}
}

return [
'source' => $source,
'event' => $event,
'processed' => count($results),
];
}

protected function extractEvent(Request $request, string $source): string
{
return match ($source) {
'stripe' => $request->json('type'),
'github' => $request->header('X-GitHub-Event'),
'slack' => $request->json('type'),
default => $request->json('event', 'unknown'),
};
}

protected function extractPayload(Request $request, string $source): array
{
return match ($source) {
'stripe' => $request->json('data.object', []),
default => $request->json()->all(),
};
}

protected function log(string $source, string $event, array $payload): void
{
Log::channel('webhooks')->info("Webhook received", [
'source' => $source,
'event' => $event,
'payload' => $payload,
'timestamp' => now()->toIso8601String(),
]);
}
}

发送 Webhook

Webhook 发送服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class WebhookSender
{
protected int $timeout = 30;
protected int $retryTimes = 3;

public function send(string $url, array $payload, array $options = []): bool
{
$secret = $options['secret'] ?? null;
$headers = $options['headers'] ?? [];

$body = json_encode($payload);

if ($secret) {
$signature = hash_hmac('sha256', $body, $secret);
$headers['X-Webhook-Signature'] = $signature;
}

$headers['Content-Type'] = 'application/json';
$headers['X-Webhook-Timestamp'] = now()->timestamp;

try {
$response = Http::withHeaders($headers)
->timeout($this->timeout)
->retry($this->retryTimes, 1000)
->post($url, $payload);

$success = $response->successful();

$this->log($url, $payload, $response->status(), $success);

return $success;
} catch (\Exception $e) {
Log::error('Webhook send failed', [
'url' => $url,
'error' => $e->getMessage(),
]);

return false;
}
}

public function sendAsync(string $url, array $payload, array $options = []): void
{
dispatch(new SendWebhookJob($url, $payload, $options));
}

protected function log(string $url, array $payload, int $status, bool $success): void
{
Log::channel('webhooks')->info('Webhook sent', [
'url' => $url,
'status' => $status,
'success' => $success,
'payload_size' => strlen(json_encode($payload)),
]);
}
}

Webhook 发送任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?php

namespace App\Jobs;

use App\Services\WebhookSender;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;

class SendWebhookJob implements ShouldQueue
{
use InteractsWithQueue, Queueable;

public int $tries = 3;
public array $backoff = [10, 60, 300];

public function __construct(
protected string $url,
protected array $payload,
protected array $options = []
) {}

public function handle(WebhookSender $sender): void
{
$success = $sender->send($this->url, $this->payload, $this->options);

if (!$success) {
$this->fail(new \Exception('Webhook delivery failed'));
}
}
}

Webhook 重试

重试策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<?php

namespace App\Services;

use App\Models\WebhookAttempt;
use Illuminate\Support\Facades\Http;

class WebhookRetryService
{
protected array $backoff = [60, 300, 900, 3600, 86400];

public function attempt(WebhookAttempt $attempt): bool
{
$response = Http::withHeaders($attempt->headers)
->timeout(30)
->post($attempt->url, $attempt->payload);

$attempt->update([
'attempts' => $attempt->attempts + 1,
'last_attempt_at' => now(),
'last_response_status' => $response->status(),
'last_response_body' => $response->body(),
]);

if ($response->successful()) {
$attempt->update(['status' => 'completed']);
return true;
}

if ($attempt->attempts >= count($this->backoff)) {
$attempt->update(['status' => 'failed']);
return false;
}

$nextDelay = $this->backoff[$attempt->attempts - 1];
$attempt->update([
'status' => 'retrying',
'next_attempt_at' => now()->addSeconds($nextDelay),
]);

return false;
}

public function scheduleRetry(WebhookAttempt $attempt): void
{
if ($attempt->status !== 'retrying') {
return;
}

dispatch(function () use ($attempt) {
$this->attempt($attempt);
})->delay($attempt->next_attempt_at);
}
}

Webhook 验证

请求验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<?php

namespace App\Services;

use Illuminate\Http\Request;

class WebhookValidator
{
public function validateStripe(Request $request): bool
{
$payload = $request->getContent();
$signature = $request->header('Stripe-Signature');
$secret = config('services.stripe.webhook_secret');

$timestamp = null;
$v1Signature = null;

foreach (explode(',', $signature) as $item) {
[$key, $value] = explode('=', $item, 2);
if ($key === 't') {
$timestamp = $value;
} elseif ($key === 'v1') {
$v1Signature = $value;
}
}

if (!$timestamp || !$v1Signature) {
return false;
}

if (abs(time() - $timestamp) > 300) {
return false;
}

$signedPayload = "{$timestamp}.{$payload}";
$expectedSignature = hash_hmac('sha256', $signedPayload, $secret);

return hash_equals($expectedSignature, $v1Signature);
}

public function validateGeneric(Request $request, string $secret): bool
{
$signature = $request->header('X-Webhook-Signature');
$payload = $request->getContent();

$expectedSignature = hash_hmac('sha256', $payload, $secret);

return hash_equals($expectedSignature, $signature);
}
}

总结

Laravel 13 的 Webhook 处理提供了:

  • 完善的签名验证机制
  • 多平台 Webhook 支持
  • 通用 Webhook 服务
  • 异步发送队列
  • 重试策略支持
  • 完整的日志记录

掌握 Webhook 处理技巧可以实现高效的应用集成。