spatie/laravel-stripe-webhooks
Laravel package to handle Stripe webhooks: verifies Stripe signatures, logs valid calls to the database, and dispatches configurable jobs or events per webhook type. Provides the plumbing for receiving and validating webhooks; you implement the business logic.
Installation:
composer require spatie/laravel-stripe-webhooks
php artisan vendor:publish --provider="Spatie\StripeWebhooks\StripeWebhooksServiceProvider"
php artisan vendor:publish --provider="Spatie\WebhookClient\WebhookClientServiceProvider" --tag="webhook-client-migrations"
php artisan migrate
Configure Stripe Webhook Endpoint:
.env (STRIPE_WEBHOOK_SECRET).routes/web.php:
Route::stripeWebhooks('stripe/webhook');
app/Http/Middleware/VerifyCsrfToken.php:
protected $except = [
'stripe/webhook',
];
First Use Case:
charge.succeeded) in config/stripe-webhooks.php:
'jobs' => [
'charge_succeeded' => \App\Jobs\StripeWebhooks\HandleChargeSucceeded::class,
],
namespace App\Jobs\StripeWebhooks;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Spatie\WebhookClient\Models\WebhookCall;
class HandleChargeSucceeded
{
use InteractsWithQueue, Queueable, SerializesModels;
public $webhookCall;
public function __construct(WebhookCall $webhookCall)
{
$this->webhookCall = $webhookCall;
}
public function handle()
{
$payload = $this->webhookCall->payload;
// Handle charge.succeeded logic here
}
}
Job-Based Handling:
config/stripe-webhooks.php.WebhookCall to access the raw payload and metadata (e.g., id, created_at).public function handle(WebhookCall $webhookCall)
{
$event = $webhookCall->payload['data']['object'];
// Process $event (e.g., update user subscription)
}
Event-Based Handling:
stripe-webhooks::<event_name> events in EventServiceProvider:
protected $listen = [
'stripe-webhooks::charge.succeeded' => [
\App\Listeners\HandleChargeSucceeded::class,
],
];
ShouldQueue for async processing:
class HandleChargeSucceeded implements ShouldQueue
{
public function handle(WebhookCall $webhookCall) { ... }
}
Default Job Handling:
default_job in config to handle unregistered events:
'default_job' => \App\Jobs\StripeWebhooks\HandleDefaultEvent::class,
Payload Transformation:
use Stripe\Event;
public function handle(WebhookCall $webhookCall)
{
$stripeEvent = Event::constructFrom($webhookCall->payload);
$charge = $stripeEvent->data->object; // Stripe\Charge
}
Testing Locally:
.env:
STRIPE_SIGNATURE_VERIFY=false
stripe listen --forward-to localhost/stripe/webhook
Retrying Failed Webhooks:
use Spatie\StripeWebhooks\ProcessStripeWebhookJob;
ProcessStripeWebhookJob::dispatch(WebhookCall::find($id));
Multi-Tenant/Connect Support:
Route::stripeWebhooks('stripe/webhook/{secretKey}');
config/stripe-webhooks.php:
'signing_secret_connect' => env('STRIPE_CONNECT_WEBHOOK_SECRET'),
\Log::info('Processed webhook', ['event' => $webhookCall->payload['type']]);
try {
\Stripe\Webhook::constructEvent(
$webhookCall->payload,
$webhookCall->signature,
env('STRIPE_WEBHOOK_SECRET')
);
} catch (\Stripe\Exception\SignatureVerificationException $e) {
\Log::error('Webhook signature verification failed', ['error' => $e->getMessage()]);
}
php artisan queue:work --queue=stripe-webhook-queue
Signature Mismatches:
STRIPE_WEBHOOK_SECRET or missing Stripe-Signature header.webhook_calls table for failed entries with exception column populated.Duplicate Events:
WebhookCall::where('payload->id', $eventId)->exists() to check for duplicates.Queue Timeouts:
ShouldQueue and optimize job logic. Increase timeout in queue.php:
'timeout' => 300, // 5 minutes
Payload Size Limits:
customer.created with many metadata) may exceed PHP limits.post_max_size and memory_limit in php.ini or stream payloads.Route Misconfiguration:
VerifyCsrfToken middleware and Stripe Dashboard URL.\Log::debug('Webhook payload', $webhookCall->payload);
php artisan tinker
>>> \Spatie\WebhookClient\Models\WebhookCall::latest()->first();
stripe listen --forward-to localhost/stripe/webhook --print-payload-only
Dynamic Config Keys:
signing_secret_{key} (e.g., signing_secret_connect).Default Job:
default_job is empty, events are stored but not processed. Set a job to handle them:
'default_job' => \App\Jobs\StripeWebhooks\LogUnmappedEvents::class,
Model Customization:
ProcessStripeWebhookJob for pre/post-processing:
class CustomWebhookJob extends \Spatie\StripeWebhooks\ProcessStripeWebhookJob
{
public function handle()
{
\Log::info('Custom logic before parent');
parent::handle();
\Log::info('Custom logic after parent');
}
}
Custom Profiles:
WebhookProfile to filter webhooks dynamically:
class CustomProfile implements \Spatie\WebhookClient\WebhookProfile\WebhookProfile
{
public function shouldProcess(Request $request): bool
{
return $request->ip() === 'trusted-ip';
}
}
'profile' => \App\Profiles\CustomProfile::class,
Middleware:
Route::stripeWebhooks('stripe/webhook')
->middleware(\App\Http\Middleware\ValidateWebhookData::class);
Webhook Call Model:
WebhookCall to add custom fields:How can I help you explore Laravel packages today?