├── LICENSE.txt ├── README.md ├── composer.json ├── config └── paypal-webhooks.php └── src ├── Exception └── WebhookFailed.php ├── Http └── Controllers │ └── PayPalWebhooksController.php ├── Jobs └── ProcessPayPalWebhookJob.php ├── Model └── PayPalWebhookCall.php ├── PayPalSignatureValidator.php ├── PayPalWebhookConfig.php ├── PayPalWebhookProfile.php └── PayPalWebhooksServiceProvider.php /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Ankur Kumar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PayPal Webhooks Client for Laravel 2 | 3 | [![Packagist](https://badgen.net/packagist/v/ankurk91/laravel-paypal-webhooks)](https://packagist.org/packages/ankurk91/laravel-paypal-webhooks) 4 | [![GitHub-tag](https://badgen.net/github/tag/ankurk91/laravel-paypal-webhooks)](https://github.com/ankurk91/laravel-paypal-webhooks/tags) 5 | [![License](https://badgen.net/packagist/license/ankurk91/laravel-paypal-webhooks)](LICENSE.txt) 6 | [![Downloads](https://badgen.net/packagist/dt/ankurk91/laravel-paypal-webhooks)](https://packagist.org/packages/ankurk91/laravel-paypal-webhooks/stats) 7 | [![GH-Actions](https://github.com/ankurk91/laravel-paypal-webhooks/workflows/tests/badge.svg)](https://github.com/ankurk91/laravel-paypal-webhooks/actions) 8 | [![codecov](https://codecov.io/gh/ankurk91/laravel-paypal-webhooks/branch/main/graph/badge.svg)](https://codecov.io/gh/ankurk91/laravel-paypal-webhooks) 9 | 10 | Handle [PayPal](https://developer.paypal.com/api/rest/webhooks/) webhooks in Laravel php framework. 11 | 12 | ## Installation 13 | 14 | You can install the package via composer: 15 | 16 | ```bash 17 | composer require "ankurk91/laravel-paypal-webhooks" 18 | ``` 19 | 20 | The service provider will automatically register itself. 21 | 22 | You must publish the config file with: 23 | 24 | ```bash 25 | php artisan vendor:publish --provider="Ankurk91\PayPalWebhooks\PayPalWebhooksServiceProvider" 26 | ``` 27 | 28 | Next, you must publish the migration with: 29 | 30 | ```bash 31 | php artisan vendor:publish --provider="Spatie\WebhookClient\WebhookClientServiceProvider" --tag="webhook-client-migrations" 32 | ``` 33 | 34 | After the migration has been published you can create the `webhook_calls` table by running the migrations: 35 | 36 | ```bash 37 | php artisan migrate 38 | ``` 39 | 40 | Next, for routing, add this route (guest) to your `routes/web.php` 41 | 42 | ```bash 43 | Route::paypalWebhooks('/webhooks/paypal'); 44 | ``` 45 | 46 | Behind the scenes this will register a `POST` route to a controller provided by this package. Next, you must add that 47 | route to the `except` array of your `VerifyCsrfToken` middleware: 48 | 49 | ```php 50 | protected $except = [ 51 | '/webhooks/*', 52 | ]; 53 | ``` 54 | 55 | It is recommended to set up a queue worker to precess the incoming webhooks. 56 | 57 | ## Setup PayPal account 58 | 59 | * Login to PayPal developer [dashboard](https://developer.paypal.com/dashboard) 60 | * Create a new Application (recommended) 61 | * Create a new Webhook under the newly created application 62 | * Enter your webhook URL. :bulb: You can use [ngrok](https://ngrok.com/) for local development 63 | * Choose events to be tracked (Don't select all), for example: 64 | * Checkout order approved 65 | * You will see a Webhook ID upon saving 66 | * Specify this webhook ID in your `.env` like 67 | 68 | ```dotenv 69 | PAYPAL_WEBHOOK_ID=6U272633NC098611R 70 | ``` 71 | 72 | This webhook ID will be used to verify the incoming request Signature. 73 | 74 | ### Troubleshoot 75 | 76 | When using ngrok during development, you must update your `APP_URL` to match with ngrok vanity URL, for example: 77 | 78 | ```dotenv 79 | APP_URL=https://af59-111-93-41-42.ngrok-free.app 80 | ``` 81 | 82 | You must verify that your webhook URL is publicly accessible by visiting the URL on terminal 83 | 84 | ```bash 85 | curl -X POST https://af59-111-93-41-42.ngrok-free.app/webhooks/paypal 86 | ``` 87 | 88 | ## Usage 89 | 90 | There are 2 ways to handle incoming webhooks via this package. 91 | 92 | ### 1 - Handling webhook requests using jobs 93 | 94 | If you want to do something when a specific event type comes in; you can define a job for that event. 95 | Here's an example of such job: 96 | 97 | ```php 98 | webhookCall->payload['resource']; 118 | 119 | // todo do something with $message['id'] 120 | } 121 | } 122 | ``` 123 | 124 | After having created your job you must register it at the `jobs` array in the `config/paypal-webhooks.php` config file. 125 | The key must be in lowercase and dots must be replaced by `_`. 126 | The value must be a fully qualified classname. 127 | 128 | ```php 129 | [ 133 | 'checkout_order_approved' => \App\Jobs\Webhook\PayPal\CheckoutOrderApprovedJob::class, 134 | ], 135 | ]; 136 | ``` 137 | 138 | ### 2 - Handling webhook requests using events and listeners 139 | 140 | Instead of queueing jobs to perform some work when a webhook request comes in, you can opt to listen to the events this 141 | package will fire. Whenever a matching request hits your app, the package will fire 142 | a `paypal-webhooks::` event. 143 | 144 | The payload of the events will be the instance of `WebhookCall` that was created for the incoming request. 145 | 146 | You can listen for such event by registering the listener in your `EventServiceProvider` class. 147 | 148 | ```php 149 | protected $listen = [ 150 | 'paypal-webhooks::payment_order_cancelled' => [ 151 | App\Listeners\PayPal\PaymentOrderCancelledListener::class, 152 | ], 153 | ]; 154 | ``` 155 | 156 | Here's an example of such listener class: 157 | 158 | ```php 159 | payload['resource']; 171 | 172 | // todo do something with $message 173 | } 174 | } 175 | ``` 176 | 177 | ## Pruning old webhooks (opt-in but recommended) 178 | 179 | Update your `app/Console/Kernel.php` file like: 180 | 181 | ```php 182 | use Illuminate\Database\Console\PruneCommand; 183 | use Spatie\WebhookClient\Models\WebhookCall; 184 | 185 | $schedule->command(PruneCommand::class, [ 186 | '--model' => [WebhookCall::class] 187 | ]) 188 | ->onOneServer() 189 | ->daily() 190 | ->description('Prune webhook_calls.'); 191 | ``` 192 | 193 | This will delete records older than `30` days, you can modify this duration by publishing this config file. 194 | 195 | ```bash 196 | php artisan vendor:publish --provider="Spatie\WebhookClient\WebhookClientServiceProvider" --tag="webhook-client-config" 197 | ``` 198 | 199 | ### Changelog 200 | 201 | Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. 202 | 203 | ### Testing 204 | 205 | ```bash 206 | composer test 207 | ``` 208 | 209 | ### Security 210 | 211 | If you discover any security issue, please email `pro.ankurk1[at]gmail[dot]com` instead of using the issue tracker. 212 | 213 | ### Useful Links 214 | 215 | * https://developer.paypal.com/api/rest/webhooks/ 216 | 217 | ### Acknowledgment 218 | 219 | This package is highly inspired by: 220 | 221 | * https://github.com/spatie/laravel-stripe-webhooks 222 | 223 | ### License 224 | 225 | This package is licensed under [MIT License](https://opensource.org/licenses/MIT). 226 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ankurk91/laravel-paypal-webhooks", 3 | "description": "Handle PayPal webhooks in Laravel php framework", 4 | "keywords": [ 5 | "laravel", 6 | "paypal", 7 | "event", 8 | "webhook" 9 | ], 10 | "homepage": "https://github.com/ankurk91/laravel-paypal-webhooks", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "ankurk91", 15 | "homepage": "https://ankurk91.github.io", 16 | "role": "Developer" 17 | } 18 | ], 19 | "require": { 20 | "php": "^8.2", 21 | "ext-openssl": "*", 22 | "guzzlehttp/guzzle": "^7.5", 23 | "illuminate/http": "^10.0 || ^11.0", 24 | "illuminate/support": "^10.0 || ^11.0", 25 | "spatie/laravel-webhook-client": "^3.1.7" 26 | }, 27 | "require-dev": { 28 | "orchestra/testbench": "^8.0 || ^9.0", 29 | "phpunit/phpunit": "^9.5 || ^10.0" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "Ankurk91\\PayPalWebhooks\\": "src" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "Ankurk91\\PayPalWebhooks\\Tests\\": "tests/" 39 | } 40 | }, 41 | "config": { 42 | "sort-packages": true, 43 | "preferred-install": "dist" 44 | }, 45 | "scripts": { 46 | "test": "vendor/bin/phpunit", 47 | "test:coverage": "vendor/bin/phpunit --coverage-clover=coverage.xml" 48 | }, 49 | "extra": { 50 | "laravel": { 51 | "providers": [ 52 | "Ankurk91\\PayPalWebhooks\\PayPalWebhooksServiceProvider" 53 | ] 54 | } 55 | }, 56 | "minimum-stability": "dev", 57 | "prefer-stable": true 58 | } 59 | -------------------------------------------------------------------------------- /config/paypal-webhooks.php: -------------------------------------------------------------------------------- 1 | [ 14 | // 'checkout_order_approved' => \App\Jobs\Webhook\PayPal\CheckoutOrderApprovedJob::class, 15 | ], 16 | 17 | /* 18 | * The classname of the model to be used. The class should equal or extend 19 | * \Ankurk91\PayPalWebhooks\Model\PayPalWebhookCall. 20 | */ 21 | 'model' => \Ankurk91\PayPalWebhooks\Model\PayPalWebhookCall::class, 22 | 23 | /** 24 | * This class determines if the incoming webhook call should be stored and processed. 25 | */ 26 | 'profile' => \Ankurk91\PayPalWebhooks\PayPalWebhookProfile::class, 27 | 28 | /* 29 | * When disabled, the package will not verify if the signature is valid. 30 | * This can be handy in local environments and testing. 31 | */ 32 | 'verify_signature' => (bool) env('PAYPAL_SIGNATURE_VERIFY', true), 33 | 34 | /* 35 | * The ID of the webhook resource for the destination URL to which PayPal delivers the event notification. 36 | * Required for signature verification. 37 | */ 38 | 'webhook_id' => env('PAYPAL_WEBHOOK_ID'), 39 | ]; 40 | -------------------------------------------------------------------------------- /src/Exception/WebhookFailed.php: -------------------------------------------------------------------------------- 1 | process(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Jobs/ProcessPayPalWebhookJob.php: -------------------------------------------------------------------------------- 1 | webhookCall->payload; 15 | 16 | $eventKey = $this->createEventKey($message['event_type']); 17 | 18 | event("paypal-webhooks::$eventKey", $this->webhookCall); 19 | 20 | $jobClass = config("paypal-webhooks.jobs.$eventKey"); 21 | 22 | if (empty($jobClass)) { 23 | return; 24 | } 25 | 26 | if (!class_exists($jobClass)) { 27 | $this->fail(WebhookFailed::jobClassDoesNotExist($jobClass)); 28 | return; 29 | } 30 | 31 | dispatch(new $jobClass($this->webhookCall)); 32 | } 33 | 34 | protected function createEventKey(string $eventType): string 35 | { 36 | return Str::of($eventType)->lower()->replace('.', '_')->value(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Model/PayPalWebhookCall.php: -------------------------------------------------------------------------------- 1 | $config->name, 22 | 'exception' => null, 23 | 'url' => $request->path(), 24 | 'headers' => $headers, 25 | 'payload' => self::makePayload($request), 26 | ]); 27 | } 28 | 29 | protected static function makePayload(Request $request): array 30 | { 31 | try { 32 | return json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR); 33 | } catch (JsonException $e) { 34 | throw WebhookFailed::invalidJsonPayload($e); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/PayPalSignatureValidator.php: -------------------------------------------------------------------------------- 1 | hasValidCertDomain($request)) { 31 | return false; 32 | } 33 | 34 | return openssl_verify( 35 | data: implode('|', [ 36 | $request->header('PAYPAL-TRANSMISSION-ID'), 37 | $request->header('PAYPAL-TRANSMISSION-TIME'), 38 | $webhookID, 39 | crc32($request->getContent()), 40 | ]), 41 | signature: base64_decode($request->header('PAYPAL-TRANSMISSION-SIG')), 42 | public_key: openssl_pkey_get_public($this->downloadCert($request->header('PAYPAL-CERT-URL'))), 43 | algorithm: OPENSSL_ALGO_SHA256 44 | ) === 1; 45 | } catch (RequestException $e) { 46 | throw $e; 47 | } catch (Throwable $e) { 48 | report_if(app()->hasDebugModeEnabled(), $e); 49 | 50 | return false; 51 | } 52 | } 53 | 54 | protected function hasValidCertDomain(Request $request): bool 55 | { 56 | $url = (string) $request->header('PAYPAL-CERT-URL'); 57 | $host = parse_url($url, PHP_URL_HOST) ?? ''; 58 | 59 | return str_ends_with($host, 'paypal.com'); 60 | } 61 | 62 | /** 63 | * @throws RequestException 64 | */ 65 | protected function downloadCert(string $url): string 66 | { 67 | return Http::timeout(15)->get($url)->throw()->body(); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/PayPalWebhookConfig.php: -------------------------------------------------------------------------------- 1 | 'paypal', 15 | 'signature_validator' => PayPalSignatureValidator::class, 16 | 'webhook_profile' => config('paypal-webhooks.profile'), 17 | 'webhook_model' => config('paypal-webhooks.model'), 18 | 'process_webhook_job' => ProcessPayPalWebhookJob::class, 19 | ]); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/PayPalWebhookProfile.php: -------------------------------------------------------------------------------- 1 | where('name', $config->name) 18 | ->where('payload->id', $request->json('id')) 19 | ->doesntExist(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/PayPalWebhooksServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 14 | $this->publishes([ 15 | $this->getConfigPath() => config_path('paypal-webhooks.php'), 16 | ], 'config'); 17 | } 18 | } 19 | 20 | public function register(): void 21 | { 22 | $this->mergeConfigFrom($this->getConfigPath(), 'paypal-webhooks'); 23 | 24 | Route::macro('paypalWebhooks', function (string $url) { 25 | return Route::post($url, '\Ankurk91\PayPalWebhooks\Http\Controllers\PayPalWebhooksController') 26 | ->name('paypalWebhooks'); 27 | }); 28 | } 29 | 30 | protected function getConfigPath(): string 31 | { 32 | return __DIR__ . '/../config/paypal-webhooks.php'; 33 | } 34 | } 35 | --------------------------------------------------------------------------------