Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Paypal Webhooks Laravel Package

ankurk91/laravel-paypal-webhooks

Verify and handle PayPal webhooks in Laravel with minimal setup. It validates incoming webhook signatures and dispatches typed events for each PayPal notification, so you can focus on business logic instead of parsing raw payloads and checking certificates.

View on GitHub
Deep Wiki
Context7

Getting Started

This package wires PayPal's webhook verification flow into Laravel: it exposes an endpoint, verifies the incoming signature against PayPal's /v1/notifications/verify-webhook-signature API using your configured credentials, and dispatches a Laravel event containing the payload.

1. Install and publish

composer require ankurk91/laravel-paypal-webhooks
php artisan vendor:publish --provider="Ankurk91\PayPalWebhooks\PayPalWebhooksServiceProvider"

You should end up with a config/paypal-webhooks.php file (check the provider for the exact publish tag if the config isn't picked up — --tag=paypal-webhooks-config is the usual convention). Because the package is archived, pin the version and read the config file before assuming env key names.

2. Configure credentials

The verification call needs a PayPal app (client ID + secret) and the webhook ID from the PayPal dashboard:

PAYPAL_MODE=sandbox
PAYPAL_CLIENT_ID=...
PAYPAL_CLIENT_SECRET=...
PAYPAL_WEBHOOK_ID=WH-...

Create the webhook in the PayPal developer dashboard (or live dashboard) and point it at your route. The webhook_id must match the webhook whose signature is being sent — it is not the app client ID.

3. Register the route

Point a POST route at the controller the package ships. Keep it out of web middleware so CSRF doesn't reject PayPal:

// routes/api.php
Route::post('webhooks/paypal', [
    \Ankurk91\PayPalWebhooks\Http\Controllers\WebhookController::class, 'handle',
])->name('paypal.webhook');

Confirm the exact controller name in vendor/ankurk91/laravel-paypal-webhooks/src — if it differs, the route path is the only thing you need to change.

4. Listen for the event

use Ankurk91\PayPalWebhooks\Events\WebhookReceived;

Event::listen(WebhookReceived::class, function (WebhookReceived $event) {
    logger()->info('PayPal webhook', $event->payload);
});

First use case: listen for PAYMENT.CAPTURE.COMPLETED, look up the order by resource.id/supplementary_data.related_ids.order_id, and mark the order paid in your database. That single flow is 90% of what most teams use this package for.

Implementation Patterns

Switch on event_type, not on the resource shape. PayPal payloads are deeply nested and differ per event. Keep one listener that fans out:

Event::listen(WebhookReceived::class, function (WebhookReceived $event) {
    match ($event->payload['event_type']) {
        'CHECKOUT.ORDER.APPROVED'        => (new HandleOrderApproved)($event->payload),
        'PAYMENT.CAPTURE.COMPLETED'     => (new HandleCaptureCompleted)($event->payload),
        'PAYMENT.CAPTURE.DENIED',
        'PAYMENT.CAPTURE.REFUNDED'      => (new HandleCaptureReversed)($event->payload),
        default                          => null,
    };
});

Make idempotency the default. PayPal retries aggressively. Store $event->payload['id'] (the PayPal event ID) in a paypal_webhook_events table with a unique index, and return early on duplicate:

$eventId = $payload['id'];
if (! PaypalWebhookEvent::create(['event_id' => $eventId])->wasRecentlyCreated) {
    return;
}

Wrap this and your business logic in a transaction where possible.

Queue the work, not the verification. Verification is a synchronous outbound HTTPS call — you want it done in the request so you can return 200 quickly, but the business logic should be queued:

Event::listen(WebhookReceived::class, ProcessPayPalWebhook::class);

class ProcessPayPalWebhook implements ShouldQueue
{
    public function handle(WebhookReceived $event): void { /* ... */ }
}

If verification itself becomes slow under load, consider a middleware or a job that re-verifies, but don't return 200 before verifying — that defeats the point.

Separate "order lifecycle" from "money movement." CHECKOUT.ORDER.* events tell you the buyer intent; PAYMENT.CAPTURE.* events tell you funds settled. Treat captures as the source of truth for "paid," and use order events only to update UI/status. Don't mark paid on ORDER.APPROVED.

Pair it with a synchronous capture. Webhooks lag. After your frontend calls your captureOrder endpoint, update the order immediately from the capture response, then let the webhook act as reconciliation. This avoids the "user paid but sees pending for 30 seconds" problem.

Local development with ngrok:

ngrok http 8000
# then in PayPal dashboard set webhook URL to
# https://<subdomain>.ngrok.io/api/webhooks/paypal

Use the sandbox dashboard's "Webhooks simulator" to fire events manually. Point .env at sandbox credentials and a sandbox webhook ID; never mix sandbox events with live verification credentials.

Testing. The package performs a real HTTP call during verification. In feature tests, fake it:

Http::fake([
    'api-m.sandbox.paypal.com/*' => Http::response(['verification_status' => 'SUCCESS']),
]);
$this->postJson('/api/webhooks/paypal', $payload)->assertOk();

Assert your listener side effects, not the package internals. Also test the failure path (verification_status not SUCCESS) returns a non-2xx so you notice bad signature wiring.

Gotchas and Tips

Archived means no fixes. This package is MIT and archived. The core logic is a thin wrapper around PayPal's verify endpoint, but if a newer Laravel/PayPal API change breaks it, you're on your own. For long-lived projects, plan to fork it or port the verification logic into a custom controller (~50 lines) rather than depend on an unmaintained package.

The webhook ID is not the client ID. The single most common setup failure is putting the app client ID in the webhook_id slot. PayPal returns a verification_status other than SUCCESS and the package will (correctly) reject, but the error is easy to misread.

Don't touch the request body before verification. Signature verification is over the raw bytes. Any middleware that trims, reorders, or re-encodes JSON will break it. If you have a global middleware that calls $request->json() and reserializes, verify before it runs, or exclude the webhook route.

CSRF and route placement. Put the route in routes/api.php (stateless) or explicitly in VerifyCsrfToken::$except. Signature verification is your authentication — treat the endpoint as public and rely on the package's validation, not on auth middleware.

Fail closed. If PayPal's verification API is down, the package returns a non-2xx. PayPal will retry, which is what you want. Do not add a "skip verification in production on timeout" escape hatch — that's an unauthenticated write endpoint.

Sandbox vs live credentials drift. Your .env may have sandbox credentials while your webhook is live (or vice versa). Add a startup check or a config cache build step that logs PAYPAL_MODE. The verification call succeeds only if the credentials and the webhook ID belong to the same environment.

Event id vs resource.id. id is the event; resource.id is the PayPal object (order ID, capture ID). Idempotency on event ID is correct, but you'll also want a unique constraint on the capture/order IDs in your own tables because PayPal occasionally emits multiple event types for the same object.

Log the raw payload before parsing. When something goes wrong, you want the exact body. The package's config usually has a log_channel key — point it at a channel you actually review, or wrap the event listener in a Log::withContext(['paypal_event_id' => ...]).

Extension points are limited. The package does not expose a pluggable verifier interface. If you need to skip verification for tests, do it by swapping the HTTP client with Http::fake, not by subclassing. If you need to support multiple PayPal webhooks with different IDs (e.g., one per merchant account), this package's single-ID config won't do it — read the config at request time or fork.

Respond fast, then queue. PayPal's retry window widens quickly if you're slow. Keep the controller response under a second; push all database and third-party calls to a job. Monitor the queue — a growing backlog is the first signal something in your PayPal event handling is wrong.

Return 200 on duplicates. Returning non-2xx on duplicates causes infinite retries. Always return 200 once the event ID is recorded as seen.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core
nodisrupt/laravel-agent
sierratecnologia/media-manager