From 55efb87e1485e991d31f0b83b8a3807e98706e92 Mon Sep 17 00:00:00 2001 From: Adam Campbell Date: Thu, 30 Apr 2026 09:11:25 -0500 Subject: [PATCH 1/7] chore: upgrade and fix issues --- .github/copilot-instructions.md | 54 ++++++++++++ README.md | 4 +- composer.json | 4 +- src/AbstractWebhook.php | 2 +- src/Console/Commands/GenerateReceiver.php | 2 +- src/Contracts/Factory.php | 4 + src/Providers/GithubProvider.php | 2 +- src/Providers/HubspotProvider.php | 1 - src/Providers/PostmarkProvider.php | 8 +- stubs/receiver-verified.stub | 3 - stubs/receiver.stub | 3 - tests/Fixtures/Github/IssuesClosed.php | 2 +- tests/HubspotProviderTest.php | 94 ++++++++++++++++++++ tests/PostmarkProviderTest.php | 63 +++++++++++++ tests/StripeProviderTest.php | 102 ++++++++++++++++++++++ 15 files changed, 326 insertions(+), 22 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 tests/HubspotProviderTest.php create mode 100644 tests/PostmarkProviderTest.php create mode 100644 tests/StripeProviderTest.php diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..b18d628 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,54 @@ +# Receiver – Copilot Instructions + +## Commands + +```bash +# Run all tests +composer test +# or +vendor/bin/phpunit + +# Run a single test file +vendor/bin/phpunit tests/GithubProviderTest.php + +# Run a single test method +vendor/bin/phpunit --filter test_it_can_receive_github_webhook + +# Fix code style +vendor/bin/php-cs-fixer fix +``` + +## Architecture + +Receiver is a Laravel package that provides a driver-based webhook handling pipeline. + +**Entry point:** `Receiver::driver('stripe')->receive($request)->ok()` + +**Driver resolution:** `ReceiverManager` extends Laravel's `Manager` class. Each built-in driver has a corresponding `create{Name}Driver()` method that reads `services.{driver}.webhook_secret` from config and instantiates the provider. + +**Provider lifecycle (in `AbstractProvider::receive()`):** +1. `handshake()` – if defined, runs first; returning a truthy value short-circuits handling and sends that as the response +2. `verify()` – if defined, returns `false` → 401 abort +3. `mapWebhook()` – builds a `Webhook` object from `getEvent()` and `getData()` +4. `handle()` – resolves and dispatches a handler class + +**Handler class resolution:** `\App\Http\Handlers\{DriverName}\{EventClassName}` +- `{DriverName}` = provider class basename with `Provider` stripped (e.g., `GithubProvider` → `Github`) +- `{EventClassName}` = event string with non-alphanumeric chars replaced by spaces, then converted to StudlyCase (e.g., `customer.created` → `CustomerCreated`) +- GitHub is a special case: event = `{X-GitHub-Event}_{action}` (e.g., `issues_opened`) + +**Handlers** must use the `Dispatchable` trait and accept `(string $event, array $data)` in the constructor. Implement `ShouldQueue` to queue them. + +**Custom providers** are registered via `app('receiver')->extend('name', fn($app) => new MyProvider($secret))` in a service provider. Use `php artisan receiver:make ` (or `--verified`) to scaffold. + +## Key Conventions + +**Config key for secrets:** Always `services.{driver}.webhook_secret` — see `ReceiverManager::buildProvider()`. + +**`FakeProvider`** is available as the `fake` driver for testing purposes. + +**Testing approach:** Tests use [Orchestra Testbench](https://github.com/orchestral/testbench). The base `TestCase` extends `\Orchestra\Testbench\TestCase` and registers `ReceiverServiceProvider`. Use `Mockery` to mock `Request` objects. To test handler dispatch in isolation, call `$provider->setHandlerNamespace('Your\\Test\\Namespace')` before `receive()`. + +**Fixture handlers** in `tests/Fixtures/` use `Log::info('Webhook handled.')` to signal that dispatch occurred — assert with `Log::partialMock()->shouldReceive('info')->withArgs(['Webhook handled.'])`. + +**Code style:** PHP CS Fixer with the config in `.php-cs-fixer.php`. Notable rules: single quotes, alpha-sorted imports (`ordered_imports`), trailing commas in multiline structures, blank line before `return`. diff --git a/README.md b/README.md index dc53229..ed00875 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,8 @@ Of course, Receiver can receive webhooks from any source using [custom providers Requires: -- PHP ^8.1 -- Laravel 9+ +- PHP ^8.2 +- Laravel 10+ ```shell composer require hotmeteor/receiver diff --git a/composer.json b/composer.json index b2ccafb..1ad3610 100644 --- a/composer.json +++ b/composer.json @@ -24,8 +24,8 @@ }, "require-dev": { "nunomaduro/collision": "^7.0|^8.0", - "orchestra/testbench": "^8.0|^9.0", - "phpunit/phpunit": "^10.0|^11.0", + "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0", + "phpunit/phpunit": "^10.0|^11.0|^12.0", "stripe/stripe-php": "^13.0" }, "autoload": { diff --git a/src/AbstractWebhook.php b/src/AbstractWebhook.php index 0ff28c1..685c8c2 100644 --- a/src/AbstractWebhook.php +++ b/src/AbstractWebhook.php @@ -33,7 +33,7 @@ abstract class AbstractWebhook implements ArrayAccess, Webhook */ public function getEvent(): string { - return $this->event; + return $this->event ?? ''; } /** diff --git a/src/Console/Commands/GenerateReceiver.php b/src/Console/Commands/GenerateReceiver.php index 85601ed..63552c5 100644 --- a/src/Console/Commands/GenerateReceiver.php +++ b/src/Console/Commands/GenerateReceiver.php @@ -35,7 +35,7 @@ class GenerateReceiver extends GeneratorCommand */ protected function getStub(): string { - return $this->option('verified') === false + return ! $this->option('verified') ? __DIR__.'/../../../stubs/receiver.stub' : __DIR__.'/../../../stubs/receiver-verified.stub'; } diff --git a/src/Contracts/Factory.php b/src/Contracts/Factory.php index 37ddd54..6404e4d 100644 --- a/src/Contracts/Factory.php +++ b/src/Contracts/Factory.php @@ -4,5 +4,9 @@ interface Factory { + /** + * @param string|null $driver + * @return mixed + */ public function driver($driver = null); } diff --git a/src/Providers/GithubProvider.php b/src/Providers/GithubProvider.php index f1b03fe..2b814fa 100644 --- a/src/Providers/GithubProvider.php +++ b/src/Providers/GithubProvider.php @@ -26,6 +26,6 @@ public function verify(Request $request): bool */ public function getEvent(Request $request): string { - return implode('_', [$request->header('X-GitHub-Event'), $request->input('action')]); + return implode('_', array_filter([$request->header('X-GitHub-Event'), $request->input('action')])); } } diff --git a/src/Providers/HubspotProvider.php b/src/Providers/HubspotProvider.php index 2906ffa..e71f644 100644 --- a/src/Providers/HubspotProvider.php +++ b/src/Providers/HubspotProvider.php @@ -23,7 +23,6 @@ public function verify(Request $request): bool $request->getContent(), ]); - $signature = urlencode($signature); $signature = hash_hmac('sha256', $signature, $this->secret); $signature = base64_encode($signature); diff --git a/src/Providers/PostmarkProvider.php b/src/Providers/PostmarkProvider.php index 825e32f..587cae2 100644 --- a/src/Providers/PostmarkProvider.php +++ b/src/Providers/PostmarkProvider.php @@ -15,13 +15,7 @@ class PostmarkProvider extends AbstractProvider */ public function verify(Request $request): bool { - try { - Auth::onceBasic(); - - return true; - } catch (\Exception $exception) { - return false; - } + return Auth::onceBasic() === null; } /** diff --git a/stubs/receiver-verified.stub b/stubs/receiver-verified.stub index ea8c7ca..f003737 100644 --- a/stubs/receiver-verified.stub +++ b/stubs/receiver-verified.stub @@ -2,14 +2,11 @@ namespace {{ providerNamespace }}; -use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Http\Request; use Receiver\Providers\AbstractProvider; class {{ providerClass }} extends AbstractProvider { - use Dispatchable; - /** * @param Request $request * @return bool diff --git a/stubs/receiver.stub b/stubs/receiver.stub index 6cd11fc..fa44989 100644 --- a/stubs/receiver.stub +++ b/stubs/receiver.stub @@ -2,14 +2,11 @@ namespace {{ providerNamespace }}; -use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Http\Request; use Receiver\Providers\AbstractProvider; class {{ providerClass }} extends AbstractProvider { - use Dispatchable; - /** * @param Request $request * @return string diff --git a/tests/Fixtures/Github/IssuesClosed.php b/tests/Fixtures/Github/IssuesClosed.php index 272353b..7b604aa 100644 --- a/tests/Fixtures/Github/IssuesClosed.php +++ b/tests/Fixtures/Github/IssuesClosed.php @@ -16,7 +16,7 @@ class IssuesClosed implements ShouldQueue use Queueable; use SerializesModels; - public function __construct(public string $name, public array $data) + public function __construct(public string $event, public array $data) { } diff --git a/tests/HubspotProviderTest.php b/tests/HubspotProviderTest.php new file mode 100644 index 0000000..6f2a967 --- /dev/null +++ b/tests/HubspotProviderTest.php @@ -0,0 +1,94 @@ +mockPayload()); + + $signature = base64_encode(hash_hmac('sha256', $method.$uri.$body, $secret)); + + $request = Mockery::mock(Request::class); + $request->allows('header')->with('X-HubSpot-Request-Timestamp')->andReturns($time->unix()); + $request->allows('header')->with('X-HubSpot-Signature-v3')->andReturns($signature); + $request->allows('method')->andReturns($method); + $request->allows('getUri')->andReturns($uri); + $request->allows('getContent')->andReturns($body); + $request->allows('input')->with('eventType')->andReturns($this->mockPayload('eventType')); + $request->allows('all')->andReturns($this->mockPayload()); + + $provider = new HubspotProvider($secret); + $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); + } + + public function test_it_denies_invalid_hubspot_webhook() + { + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Unauthorized'); + + $time = Carbon::parse('2022-08-01 12:00:00', 'America/Chicago'); + Carbon::setTestNow($time); + + $request = Mockery::mock(Request::class); + $request->allows('header')->with('X-HubSpot-Request-Timestamp')->andReturns($time->unix()); + $request->allows('header')->with('X-HubSpot-Signature-v3')->andReturns('invalid-signature'); + $request->allows('method')->andReturns('POST'); + $request->allows('getUri')->andReturns('https://example.com/webhooks/hubspot'); + $request->allows('getContent')->andReturns(json_encode($this->mockPayload())); + + $provider = new HubspotProvider('hubspot-webhook-secret'); + $provider->receive($request); + } + + public function test_it_denies_expired_hubspot_webhook() + { + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Unauthorized'); + + $staleTimestamp = now()->subMinutes(10)->unix(); + + $request = Mockery::mock(Request::class); + $request->allows('header')->with('X-HubSpot-Request-Timestamp')->andReturns($staleTimestamp); + + $provider = new HubspotProvider('hubspot-webhook-secret'); + $provider->receive($request); + } + + public function test_it_gets_event_from_event_type() + { + $request = Mockery::mock(Request::class); + $request->allows('input')->with('eventType')->andReturns('contact.creation'); + + $provider = new HubspotProvider('secret'); + + $this->assertEquals('contact.creation', $provider->getEvent($request)); + } + + protected function mockPayload(string $key = null): mixed + { + $data = [ + 'eventType' => 'contact.creation', + 'objectId' => 123, + ]; + + return $key ? data_get($data, $key) : $data; + } +} diff --git a/tests/PostmarkProviderTest.php b/tests/PostmarkProviderTest.php new file mode 100644 index 0000000..b8bff35 --- /dev/null +++ b/tests/PostmarkProviderTest.php @@ -0,0 +1,63 @@ +once()->andReturnNull(); + + $request = Mockery::mock(Request::class); + $request->allows('filled')->with('RecordType')->andReturns(true); + $request->allows('input')->with('RecordType')->andReturns('Delivery'); + $request->allows('all')->andReturns(['RecordType' => 'Delivery']); + + $provider = new PostmarkProvider(null); + $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); + } + + public function test_it_denies_unauthorized_postmark_webhook() + { + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Unauthorized'); + + Auth::shouldReceive('onceBasic')->once()->andReturn(new Response('Unauthorized', 401)); + + $request = Mockery::mock(Request::class); + + $provider = new PostmarkProvider(null); + $provider->receive($request); + } + + public function test_it_gets_record_type_event() + { + $request = Mockery::mock(Request::class); + $request->allows('filled')->with('RecordType')->andReturns(true); + $request->allows('input')->with('RecordType')->andReturns('Bounce'); + + $provider = new PostmarkProvider(null); + + $this->assertEquals('Bounce', $provider->getEvent($request)); + } + + public function test_it_defaults_to_inbound_event() + { + $request = Mockery::mock(Request::class); + $request->allows('filled')->with('RecordType')->andReturns(false); + + $provider = new PostmarkProvider(null); + + $this->assertEquals('Inbound', $provider->getEvent($request)); + } +} diff --git a/tests/StripeProviderTest.php b/tests/StripeProviderTest.php new file mode 100644 index 0000000..c79c36e --- /dev/null +++ b/tests/StripeProviderTest.php @@ -0,0 +1,102 @@ +mockPayload()); + + $signature = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret); + $header = "t={$timestamp},v1={$signature}"; + + $request = Mockery::mock(Request::class); + $request->allows('has')->with('challenge')->andReturns(false); + $request->allows('only')->with('challenge')->andReturns([]); + $request->allows('getContent')->andReturns($payload); + $request->allows('header')->with('STRIPE_SIGNATURE')->andReturns($header); + $request->allows('input')->with('type')->andReturns($this->mockPayload('type')); + $request->allows('input')->with('data')->andReturns($this->mockPayload('data')); + $request->allows('all')->andReturns($this->mockPayload()); + + $provider = new StripeProvider($secret); + $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); + } + + public function test_it_denies_invalid_stripe_signature() + { + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Unauthorized'); + + $request = Mockery::mock(Request::class); + $request->allows('has')->with('challenge')->andReturns(false); + $request->allows('only')->with('challenge')->andReturns([]); + $request->allows('getContent')->andReturns(json_encode($this->mockPayload())); + $request->allows('header')->with('STRIPE_SIGNATURE')->andReturns('t=1234,v1=invalidsig'); + + $provider = new StripeProvider('stripe-test-secret'); + $provider->receive($request); + } + + public function test_it_handles_stripe_handshake() + { + $request = Mockery::mock(Request::class); + $request->allows('has')->with('challenge')->andReturns(true); + $request->allows('only')->with('challenge')->andReturns(['challenge' => 'stripe-challenge-token']); + + $provider = new StripeProvider('stripe-test-secret'); + $provider->receive($request); + + $this->assertNull($provider->webhook()); + + $response = $provider->toResponse($request); + $this->assertInstanceOf(JsonResponse::class, $response); + } + + public function test_it_gets_event_from_type() + { + $request = Mockery::mock(Request::class); + $request->allows('input')->with('type')->andReturns('customer.created'); + + $provider = new StripeProvider('secret'); + + $this->assertEquals('customer.created', $provider->getEvent($request)); + } + + public function test_it_gets_data_from_data_key() + { + $request = Mockery::mock(Request::class); + $request->allows('input')->with('data')->andReturns(['object' => ['id' => 'cus_123']]); + + $provider = new StripeProvider('secret'); + + $this->assertEquals(['object' => ['id' => 'cus_123']], $provider->getData($request)); + } + + protected function mockPayload(string $key = null): mixed + { + $data = [ + 'type' => 'customer.created', + 'data' => [ + 'object' => [ + 'id' => 'cus_123', + 'email' => 'test@example.com', + ], + ], + ]; + + return $key ? data_get($data, $key) : $data; + } +} From be37f837c53e911880f16e304519cd1f9558854c Mon Sep 17 00:00:00 2001 From: Adam Campbell Date: Thu, 30 Apr 2026 09:21:47 -0500 Subject: [PATCH 2/7] feat: multi-event support, Postmark verification modes, case-insensitive events - Allow getEvent() to return string|array for multi-event payloads (PR #26) - Lowercase event names before handler class resolution for case-insensitive matching (PR #22) - Rewrite PostmarkProvider::verify() with configurable verification_types: 'auth', 'headers', and 'ips' modes (PR #31, closes #7) - Update all provider getEvent() signatures to string|array - Add Postmark verification tests (8 tests) - Add multi-event and case-insensitive handler tests - Add Configuration section to README with per-provider services.php examples - Document multi-event support in README - Document Postmark verification_types options Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 114 ++++++++++++++++++++++- src/AbstractWebhook.php | 11 +-- src/Contracts/Webhook.php | 4 +- src/Providers/AbstractProvider.php | 33 ++++--- src/Providers/FakeProvider.php | 2 +- src/Providers/GithubProvider.php | 2 +- src/Providers/HubspotProvider.php | 2 +- src/Providers/PostmarkProvider.php | 39 +++++++- src/Providers/SlackProvider.php | 2 +- src/Providers/StripeProvider.php | 2 +- tests/Fixtures/EventA.php | 18 ++++ tests/Fixtures/EventB.php | 18 ++++ tests/Fixtures/TestProvider.php | 4 +- tests/PostmarkProviderTest.php | 140 +++++++++++++++++++++++++++-- tests/ProviderTest.php | 39 ++++++++ tests/TestCase.php | 12 +++ 16 files changed, 404 insertions(+), 38 deletions(-) create mode 100644 tests/Fixtures/EventA.php create mode 100644 tests/Fixtures/EventB.php diff --git a/README.md b/README.md index ed00875..f6d7b35 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Of course, Receiver can receive webhooks from any source using [custom providers ## Table of Contents - [Installation](#installation) +- [Configuration](#configuration) - [Receiving Webhooks](#receiving-webhooks) - [The Basics](#the-basics) - [Receiving from multiple apps](#receiving-from-multiple-apps) @@ -32,6 +33,7 @@ Of course, Receiver can receive webhooks from any source using [custom providers - [Extending Receiver](#extending-receiver) - [Adding custom providers](#adding-custom-providers) - [Defining attributes](#defining-attributes) + - [Receiving multiple events in a single webhook](#receiving-multiple-events-in-a-single-webhook) - [Securing webhooks](#securing-webhooks) - [Handshakes](#handshakes) - [Community Receivers](#share-your-receivers) @@ -53,6 +55,78 @@ Optional: **Stripe** support requires [`stripe/stripe-php`](https://github.com/stripe/stripe-php) +## Configuration + +Each provider reads its signing secret from `config/services.php`. Add the relevant entry for each webhook source you intend to receive. + +**GitHub** +```php +'github' => [ + 'webhook_secret' => env('GITHUB_WEBHOOK_SECRET'), +], +``` + +**HubSpot** +```php +'hubspot' => [ + 'webhook_secret' => env('HUBSPOT_WEBHOOK_SECRET'), +], +``` + +**Slack** +```php +'slack' => [ + 'webhook_secret' => env('SLACK_WEBHOOK_SECRET'), +], +``` + +**Stripe** +```php +'stripe' => [ + 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), +], +``` + +**Postmark** + +Postmark offers several verification strategies. Configure which ones to apply (and in what order) under the `webhook` key: + +```php +'postmark' => [ + 'token' => env('POSTMARK_TOKEN'), + 'webhook' => [ + // Choose one or more: 'auth', 'headers', 'ips' + 'verification_types' => ['headers', 'ips'], + + // Used when 'headers' is in verification_types. + // Specify header name => expected value pairs. + 'headers' => [ + 'X-Custom-Header' => env('POSTMARK_WEBHOOK_HEADER'), + ], + + // Used when 'ips' is in verification_types. + // Postmark's official webhook IPs: + // https://postmarkapp.com/support/article/800-ips-for-firewalls#webhooks + 'ips' => [ + '3.134.147.250', + '50.31.156.6', + '50.31.156.77', + '18.217.206.57', + ], + ], +], +``` + +Available `verification_types`: + +| Type | Description | +|------|-------------| +| `auth` | HTTP Basic Auth via `Auth::onceBasic()` | +| `headers` | Validates that specific request headers match expected values | +| `ips` | Validates that the request originates from an allowed IP address | + +If `verification_types` is empty or not set, all Postmark requests are accepted without verification. + ## Receiving Webhooks ### The Basics @@ -322,9 +396,9 @@ class CustomProvider extends AbstractProvider { /** * @param Request $request - * @return string + * @return string|array */ - public function getEvent(Request $request): string + public function getEvent(Request $request): string|array { return $request->input('event.name'); } @@ -344,6 +418,42 @@ The *`getEvent()`* method is used to return the name of the webhook event, ie. ` The *`getData()`* method is used to return the payload of data that can be used within your handler. By default this is set to `$request->all()`. +### Receiving Multiple Events in a Single Webhook + +Some services send more than one event per request. Receiver supports this by allowing `getEvent()` to return an array of `['event_name' => $eventData]` pairs instead of a single string. + +When an array is returned, Receiver will dispatch a separate handler for each event: + +```php +input('event.name'); + + // Multiple events — return an array of ['event_name' => $eventData] + $events = []; + foreach ($request->input('events', []) as $event) { + $events[$event['type']] = $event; + } + return $events; + } +} +``` + +Each matching handler will receive its own event name and data pair. + ### Securing Webhooks Many webhooks have ways of verifying their authenticity as they are received, most commonly through signatures or basic authentication. No matter the strategy, Receiver allows you to write custom verification code as necessary. Simply implement the `verify` method in your provider and return true or false if it passes. diff --git a/src/AbstractWebhook.php b/src/AbstractWebhook.php index 685c8c2..108f85e 100644 --- a/src/AbstractWebhook.php +++ b/src/AbstractWebhook.php @@ -8,11 +8,12 @@ abstract class AbstractWebhook implements ArrayAccess, Webhook { /** - * The normalized name of the webhook event. + * The normalized name of the webhook event. May be an array of [event => data] + * pairs when the provider returns multiple events in a single payload. * - * @var string|null + * @var string|array|null */ - public string|null $event = null; + public string|array|null $event = null; /** * The payload of the webhook event. @@ -29,9 +30,9 @@ abstract class AbstractWebhook implements ArrayAccess, Webhook public array $webhook = []; /** - * @return string + * @return string|array */ - public function getEvent(): string + public function getEvent(): string|array { return $this->event ?? ''; } diff --git a/src/Contracts/Webhook.php b/src/Contracts/Webhook.php index 9e88392..e4eae0c 100644 --- a/src/Contracts/Webhook.php +++ b/src/Contracts/Webhook.php @@ -5,9 +5,9 @@ interface Webhook { /** - * @return string + * @return string|array */ - public function getEvent(): string; + public function getEvent(): string|array; /** * @return array diff --git a/src/Providers/AbstractProvider.php b/src/Providers/AbstractProvider.php index 27ecaa4..76fb3d9 100644 --- a/src/Providers/AbstractProvider.php +++ b/src/Providers/AbstractProvider.php @@ -35,9 +35,9 @@ abstract class AbstractProvider implements ProviderContract, Responsable protected Closure|null $fallback = null; /** - * @var bool + * @var array */ - protected mixed $dispatched = false; + protected array $dispatchedEvents = []; /** * @var string @@ -53,9 +53,9 @@ public function __construct(protected ?string $secret = null) /** * @param Request $request - * @return string + * @return string|array */ - abstract public function getEvent(Request $request): string; + abstract public function getEvent(Request $request): string|array; /** * @param Request $request @@ -138,11 +138,14 @@ public function webhook(): ?Webhook } /** + * @param string|null $key Handler class name to check (e.g. MyHandler::class) * @return bool */ - public function dispatched(): bool + public function dispatched(string $key = null): bool { - return $this->dispatched; + return $key + ? in_array($key, $this->dispatchedEvents) + : ! empty($this->dispatchedEvents); } /** @@ -162,12 +165,20 @@ protected function mapWebhook(Request $request): Webhook */ protected function handle(): static { - $class = $this->getClass($event = $this->webhook->getEvent()); + $events = $this->webhook->getEvent(); + + if (! is_array($events)) { + $events = [$events => $this->webhook->getData()]; + } - if (class_exists($class)) { - $class::dispatch($event, $this->webhook->getData()); + foreach ($events as $event => $data) { + $class = $this->getClass($event); - $this->dispatched = true; + if (class_exists($class)) { + $class::dispatch($event, $data); + + $this->dispatchedEvents[] = $class; + } } return $this; @@ -193,7 +204,7 @@ protected function getClass(string $event): string */ protected function prepareHandlerClassname(string $event): string { - return (string) Str::of($event)->replaceMatches('/[^A-Za-z0-9]++/', ' ')->studly(); + return (string) Str::of($event)->lower()->replaceMatches('/[^A-Za-z0-9]++/', ' ')->studly(); } /** diff --git a/src/Providers/FakeProvider.php b/src/Providers/FakeProvider.php index 363b3e8..0899da0 100644 --- a/src/Providers/FakeProvider.php +++ b/src/Providers/FakeProvider.php @@ -10,7 +10,7 @@ class FakeProvider extends AbstractProvider * @param Request $request * @return string */ - public function getEvent(Request $request): string + public function getEvent(Request $request): string|array { return $request->input('type', 'fake'); } diff --git a/src/Providers/GithubProvider.php b/src/Providers/GithubProvider.php index 2b814fa..43673bd 100644 --- a/src/Providers/GithubProvider.php +++ b/src/Providers/GithubProvider.php @@ -24,7 +24,7 @@ public function verify(Request $request): bool * @param Request $request * @return string */ - public function getEvent(Request $request): string + public function getEvent(Request $request): string|array { return implode('_', array_filter([$request->header('X-GitHub-Event'), $request->input('action')])); } diff --git a/src/Providers/HubspotProvider.php b/src/Providers/HubspotProvider.php index e71f644..02543d7 100644 --- a/src/Providers/HubspotProvider.php +++ b/src/Providers/HubspotProvider.php @@ -36,7 +36,7 @@ public function verify(Request $request): bool * @param Request $request * @return string */ - public function getEvent(Request $request): string + public function getEvent(Request $request): string|array { return $request->input('eventType'); } diff --git a/src/Providers/PostmarkProvider.php b/src/Providers/PostmarkProvider.php index 587cae2..6b7d4e0 100644 --- a/src/Providers/PostmarkProvider.php +++ b/src/Providers/PostmarkProvider.php @@ -8,6 +8,16 @@ class PostmarkProvider extends AbstractProvider { /** + * Verify the incoming Postmark webhook request. + * + * Configure which verification methods to run (and in what order) via: + * config('services.postmark.webhook.verification_types') + * + * Available types: + * - 'auth' Verify via HTTP Basic Auth (Auth::onceBasic) + * - 'headers' Verify that specific headers are present and match expected values + * - 'ips' Verify that the request originates from an allowed IP address + * * https://postmarkapp.com/developer/webhooks/webhooks-overview#protecting-your-webhook. * * @param Request $request @@ -15,14 +25,39 @@ class PostmarkProvider extends AbstractProvider */ public function verify(Request $request): bool { - return Auth::onceBasic() === null; + foreach (config('services.postmark.webhook.verification_types') ?? [] as $type) { + switch ($type) { + case 'auth': + if (Auth::onceBasic() !== null) { + return false; + } + break; + + case 'headers': + foreach (config('services.postmark.webhook.headers') ?? [] as $key => $value) { + if (! $request->hasHeader($key) || $request->header($key) !== $value) { + return false; + } + } + break; + + case 'ips': + $allowed = config('services.postmark.webhook.ips') ?? []; + if (! in_array($request->getClientIp(), $allowed, true)) { + return false; + } + break; + } + } + + return true; } /** * @param Request $request * @return string */ - public function getEvent(Request $request): string + public function getEvent(Request $request): string|array { return $request->filled('RecordType') ? $request->input('RecordType') : 'Inbound'; } diff --git a/src/Providers/SlackProvider.php b/src/Providers/SlackProvider.php index 2de3c96..81ebe78 100644 --- a/src/Providers/SlackProvider.php +++ b/src/Providers/SlackProvider.php @@ -40,7 +40,7 @@ public function verify(Request $request): bool * @param Request $request * @return string */ - public function getEvent(Request $request): string + public function getEvent(Request $request): string|array { return $request->input('event.type'); } diff --git a/src/Providers/StripeProvider.php b/src/Providers/StripeProvider.php index 45b9918..20a88fe 100644 --- a/src/Providers/StripeProvider.php +++ b/src/Providers/StripeProvider.php @@ -43,7 +43,7 @@ public function verify(Request $request): bool * @param Request $request * @return string */ - public function getEvent(Request $request): string + public function getEvent(Request $request): string|array { return $request->input('type'); } diff --git a/tests/Fixtures/EventA.php b/tests/Fixtures/EventA.php new file mode 100644 index 0000000..e6f0478 --- /dev/null +++ b/tests/Fixtures/EventA.php @@ -0,0 +1,18 @@ +input('event'); } diff --git a/tests/PostmarkProviderTest.php b/tests/PostmarkProviderTest.php index b8bff35..d836b26 100644 --- a/tests/PostmarkProviderTest.php +++ b/tests/PostmarkProviderTest.php @@ -3,8 +3,8 @@ namespace Receiver\Tests; use Illuminate\Http\Request; -use Illuminate\Http\Response; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Config; use Mockery; use Receiver\Providers\PostmarkProvider; use Receiver\Providers\Webhook; @@ -12,14 +12,17 @@ class PostmarkProviderTest extends TestCase { - public function test_it_can_verify_postmark_webhook() + // ------------------------------------------------------------------------- + // verify() — auth mode + // ------------------------------------------------------------------------- + + public function test_it_can_verify_postmark_webhook_via_auth() { + Config::set('services.postmark.webhook.verification_types', ['auth']); + Auth::shouldReceive('onceBasic')->once()->andReturnNull(); - $request = Mockery::mock(Request::class); - $request->allows('filled')->with('RecordType')->andReturns(true); - $request->allows('input')->with('RecordType')->andReturns('Delivery'); - $request->allows('all')->andReturns(['RecordType' => 'Delivery']); + $request = $this->mockBaseRequest(); $provider = new PostmarkProvider(null); $provider->receive($request); @@ -27,19 +30,119 @@ public function test_it_can_verify_postmark_webhook() $this->assertInstanceOf(Webhook::class, $provider->webhook()); } - public function test_it_denies_unauthorized_postmark_webhook() + public function test_it_denies_postmark_webhook_with_invalid_auth() { + Config::set('services.postmark.webhook.verification_types', ['auth']); + $this->expectException(HttpException::class); $this->expectExceptionMessage('Unauthorized'); - Auth::shouldReceive('onceBasic')->once()->andReturn(new Response('Unauthorized', 401)); + Auth::shouldReceive('onceBasic')->once()->andReturn(response('Unauthorized', 401)); - $request = Mockery::mock(Request::class); + $request = $this->mockBaseRequest(); + + $provider = new PostmarkProvider(null); + $provider->receive($request); + } + + // ------------------------------------------------------------------------- + // verify() — headers mode + // ------------------------------------------------------------------------- + + public function test_it_can_verify_postmark_webhook_via_valid_headers() + { + Config::set('services.postmark.webhook.verification_types', ['headers']); + + $request = $this->mockBaseRequest(); + $request->allows('hasHeader')->with('X-Custom-Header')->andReturns(true); + $request->allows('header')->with('X-Custom-Header')->andReturns('PostmarkExpected'); $provider = new PostmarkProvider(null); $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); } + public function test_it_denies_postmark_webhook_with_missing_header() + { + Config::set('services.postmark.webhook.verification_types', ['headers']); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Unauthorized'); + + $request = $this->mockBaseRequest(); + $request->allows('hasHeader')->with('X-Custom-Header')->andReturns(false); + + $provider = new PostmarkProvider(null); + $provider->receive($request); + } + + public function test_it_denies_postmark_webhook_with_wrong_header_value() + { + Config::set('services.postmark.webhook.verification_types', ['headers']); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Unauthorized'); + + $request = $this->mockBaseRequest(); + $request->allows('hasHeader')->with('X-Custom-Header')->andReturns(true); + $request->allows('header')->with('X-Custom-Header')->andReturns('WrongValue'); + + $provider = new PostmarkProvider(null); + $provider->receive($request); + } + + // ------------------------------------------------------------------------- + // verify() — IPs mode + // ------------------------------------------------------------------------- + + public function test_it_can_verify_postmark_webhook_via_allowed_ip() + { + Config::set('services.postmark.webhook.verification_types', ['ips']); + + $request = $this->mockBaseRequest(); + $request->allows('getClientIp')->andReturns('3.134.147.250'); + + $provider = new PostmarkProvider(null); + $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); + } + + public function test_it_denies_postmark_webhook_from_disallowed_ip() + { + Config::set('services.postmark.webhook.verification_types', ['ips']); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Unauthorized'); + + $request = $this->mockBaseRequest(); + $request->allows('getClientIp')->andReturns('1.2.3.4'); + + $provider = new PostmarkProvider(null); + $provider->receive($request); + } + + // ------------------------------------------------------------------------- + // verify() — no verification_types configured + // ------------------------------------------------------------------------- + + public function test_it_passes_when_no_verification_types_configured() + { + Config::set('services.postmark.webhook.verification_types', []); + + $request = $this->mockBaseRequest(); + + $provider = new PostmarkProvider(null); + $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); + } + + // ------------------------------------------------------------------------- + // getEvent() + // ------------------------------------------------------------------------- + public function test_it_gets_record_type_event() { $request = Mockery::mock(Request::class); @@ -60,4 +163,23 @@ public function test_it_defaults_to_inbound_event() $this->assertEquals('Inbound', $provider->getEvent($request)); } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + protected function mockBaseRequest(): Request + { + $payload = [ + 'RecordType' => 'Delivery', + 'MessageID' => '883953f4-6105-42a2-a16a-77a8eac79483', + ]; + + $request = Mockery::mock(Request::class); + $request->allows('filled')->with('RecordType')->andReturns(true); + $request->allows('input')->with('RecordType')->andReturns('Delivery'); + $request->allows('all')->andReturns($payload); + + return $request; + } } diff --git a/tests/ProviderTest.php b/tests/ProviderTest.php index 4e2b65a..6283009 100644 --- a/tests/ProviderTest.php +++ b/tests/ProviderTest.php @@ -42,6 +42,45 @@ public function test_handles_webhook_with_missing_handler() $this->assertInstanceOf(JsonResponse::class, $response); } + public function test_handles_multiple_events_in_single_payload() + { + $events = [ + 'event_a' => ['id' => 1], + 'event_b' => ['id' => 2], + ]; + + $request = new Request(['event' => $events, 'data' => []]); + + $provider = new TestProvider(); + + $response = $provider + ->receive($request) + ->ok(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertTrue($provider->dispatched()); + $this->assertTrue($provider->dispatched(Fixtures\EventA::class)); + $this->assertTrue($provider->dispatched(Fixtures\EventB::class)); + } + + public function test_handler_class_resolved_case_insensitively() + { + // 'FOO.BARRED' and 'foo.barred' must resolve to the same class + $payload = $this->mockPayload(); + data_set($payload, 'event', 'FOO.BARRED'); + + $request = new Request($payload); + + $provider = new TestProvider(); + + $response = $provider + ->receive($request) + ->ok(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertTrue($provider->dispatched()); + } + protected function mockPayload(string $key = null): mixed { $payload = [ diff --git a/tests/TestCase.php b/tests/TestCase.php index 67036d2..a611e1f 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -26,6 +26,18 @@ protected function getEnvironmentSetUp($app) 'redirect' => 'http://your-callback-url', 'webhook_secret' => 'slack-webhook-secret', ]); + + $app['config']->set('services.postmark.webhook', [ + 'headers' => [ + 'X-Custom-Header' => 'PostmarkExpected', + ], + 'ips' => [ + '3.134.147.250', + '50.31.156.6', + '50.31.156.77', + '18.217.206.57', + ], + ]); } /** From ac162ce1c34dfe280b7c71db97a5a59ba3fbca85 Mon Sep 17 00:00:00 2001 From: Adam Campbell Date: Thu, 30 Apr 2026 09:27:04 -0500 Subject: [PATCH 3/7] refactor(tests): convert to PHP 8 #[Test] attributes Replace test_ prefix convention with native PHPUnit #[Test] attributes across all test files. Also add return type hints (void) and clean up redundant @param/@return docblocks on helper methods. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/FacadeTest.php | 4 +++- tests/GithubProviderTest.php | 15 +++++---------- tests/HubspotProviderTest.php | 14 +++++++++----- tests/ManagerTest.php | 7 +++++-- tests/PostmarkProviderTest.php | 31 +++++++++++++++++++++---------- tests/ProviderTest.php | 13 +++++++++---- tests/SlackProviderTest.php | 21 +++++++-------------- tests/StripeProviderTest.php | 16 +++++++++++----- 8 files changed, 70 insertions(+), 51 deletions(-) diff --git a/tests/FacadeTest.php b/tests/FacadeTest.php index 7d19ea4..4030b69 100644 --- a/tests/FacadeTest.php +++ b/tests/FacadeTest.php @@ -4,11 +4,13 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use PHPUnit\Framework\Attributes\Test; use Receiver\Facades\Receiver; class FacadeTest extends TestCase { - public function test_ide_helpers() + #[Test] + public function ide_helpers(): void { $request = new Request(); diff --git a/tests/GithubProviderTest.php b/tests/GithubProviderTest.php index 4999784..01eb103 100644 --- a/tests/GithubProviderTest.php +++ b/tests/GithubProviderTest.php @@ -5,12 +5,14 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use Mockery; +use PHPUnit\Framework\Attributes\Test; use Receiver\Providers\GithubProvider; use Receiver\Providers\Webhook; class GithubProviderTest extends TestCase { - public function test_it_can_receive_github_webhook() + #[Test] + public function it_can_receive_github_webhook(): void { Log::partialMock()->shouldReceive('info')->never(); @@ -31,7 +33,8 @@ public function test_it_can_receive_github_webhook() $this->assertInstanceOf(Webhook::class, $webhook); } - public function test_it_dispatches_matching_handler() + #[Test] + public function it_dispatches_matching_handler(): void { Log::partialMock()->shouldReceive('info')->withArgs(['Webhook handled.'])->andReturnNull(); @@ -55,11 +58,6 @@ public function test_it_dispatches_matching_handler() $this->assertInstanceOf(Webhook::class, $webhook); } - /** - * @param Request $request - * @param string $signature - * @return Request - */ protected function signUsing(Request $request, string $signature): Request { $request->allows('getContent')->andReturns(json_encode($this->mockPayload())); @@ -70,9 +68,6 @@ protected function signUsing(Request $request, string $signature): Request /** * https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#example-delivery. - * - * @param string|null $key - * @return mixed */ protected function mockPayload(string $key = null): mixed { diff --git a/tests/HubspotProviderTest.php b/tests/HubspotProviderTest.php index 6f2a967..3e91dba 100644 --- a/tests/HubspotProviderTest.php +++ b/tests/HubspotProviderTest.php @@ -3,16 +3,17 @@ namespace Receiver\Tests; use Carbon\Carbon; -use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Mockery; +use PHPUnit\Framework\Attributes\Test; use Receiver\Providers\HubspotProvider; use Receiver\Providers\Webhook; use Symfony\Component\HttpKernel\Exception\HttpException; class HubspotProviderTest extends TestCase { - public function test_it_can_receive_hubspot_webhook() + #[Test] + public function it_can_receive_hubspot_webhook(): void { $secret = 'hubspot-webhook-secret'; $time = Carbon::parse('2022-08-01 12:00:00', 'America/Chicago'); @@ -39,7 +40,8 @@ public function test_it_can_receive_hubspot_webhook() $this->assertInstanceOf(Webhook::class, $provider->webhook()); } - public function test_it_denies_invalid_hubspot_webhook() + #[Test] + public function it_denies_invalid_hubspot_webhook(): void { $this->expectException(HttpException::class); $this->expectExceptionMessage('Unauthorized'); @@ -58,7 +60,8 @@ public function test_it_denies_invalid_hubspot_webhook() $provider->receive($request); } - public function test_it_denies_expired_hubspot_webhook() + #[Test] + public function it_denies_expired_hubspot_webhook(): void { $this->expectException(HttpException::class); $this->expectExceptionMessage('Unauthorized'); @@ -72,7 +75,8 @@ public function test_it_denies_expired_hubspot_webhook() $provider->receive($request); } - public function test_it_gets_event_from_event_type() + #[Test] + public function it_gets_event_from_event_type(): void { $request = Mockery::mock(Request::class); $request->allows('input')->with('eventType')->andReturns('contact.creation'); diff --git a/tests/ManagerTest.php b/tests/ManagerTest.php index 6d6deb5..407400d 100644 --- a/tests/ManagerTest.php +++ b/tests/ManagerTest.php @@ -2,13 +2,15 @@ namespace Receiver\Tests; +use PHPUnit\Framework\Attributes\Test; use Receiver\Contracts\Factory; use Receiver\Providers\GithubProvider; use Receiver\Providers\PostmarkProvider; class ManagerTest extends TestCase { - public function test_it_can_instantiate_the_github_driver() + #[Test] + public function it_can_instantiate_the_github_driver(): void { $factory = $this->app->make(Factory::class); @@ -17,7 +19,8 @@ public function test_it_can_instantiate_the_github_driver() $this->assertInstanceOf(GithubProvider::class, $provider); } - public function test_it_can_instantiate_the_postmark_driver() + #[Test] + public function it_can_instantiate_the_postmark_driver(): void { $factory = $this->app->make(Factory::class); diff --git a/tests/PostmarkProviderTest.php b/tests/PostmarkProviderTest.php index d836b26..31f83f3 100644 --- a/tests/PostmarkProviderTest.php +++ b/tests/PostmarkProviderTest.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Config; use Mockery; +use PHPUnit\Framework\Attributes\Test; use Receiver\Providers\PostmarkProvider; use Receiver\Providers\Webhook; use Symfony\Component\HttpKernel\Exception\HttpException; @@ -16,7 +17,8 @@ class PostmarkProviderTest extends TestCase // verify() — auth mode // ------------------------------------------------------------------------- - public function test_it_can_verify_postmark_webhook_via_auth() + #[Test] + public function it_can_verify_postmark_webhook_via_auth(): void { Config::set('services.postmark.webhook.verification_types', ['auth']); @@ -30,7 +32,8 @@ public function test_it_can_verify_postmark_webhook_via_auth() $this->assertInstanceOf(Webhook::class, $provider->webhook()); } - public function test_it_denies_postmark_webhook_with_invalid_auth() + #[Test] + public function it_denies_postmark_webhook_with_invalid_auth(): void { Config::set('services.postmark.webhook.verification_types', ['auth']); @@ -49,7 +52,8 @@ public function test_it_denies_postmark_webhook_with_invalid_auth() // verify() — headers mode // ------------------------------------------------------------------------- - public function test_it_can_verify_postmark_webhook_via_valid_headers() + #[Test] + public function it_can_verify_postmark_webhook_via_valid_headers(): void { Config::set('services.postmark.webhook.verification_types', ['headers']); @@ -63,7 +67,8 @@ public function test_it_can_verify_postmark_webhook_via_valid_headers() $this->assertInstanceOf(Webhook::class, $provider->webhook()); } - public function test_it_denies_postmark_webhook_with_missing_header() + #[Test] + public function it_denies_postmark_webhook_with_missing_header(): void { Config::set('services.postmark.webhook.verification_types', ['headers']); @@ -77,7 +82,8 @@ public function test_it_denies_postmark_webhook_with_missing_header() $provider->receive($request); } - public function test_it_denies_postmark_webhook_with_wrong_header_value() + #[Test] + public function it_denies_postmark_webhook_with_wrong_header_value(): void { Config::set('services.postmark.webhook.verification_types', ['headers']); @@ -96,7 +102,8 @@ public function test_it_denies_postmark_webhook_with_wrong_header_value() // verify() — IPs mode // ------------------------------------------------------------------------- - public function test_it_can_verify_postmark_webhook_via_allowed_ip() + #[Test] + public function it_can_verify_postmark_webhook_via_allowed_ip(): void { Config::set('services.postmark.webhook.verification_types', ['ips']); @@ -109,7 +116,8 @@ public function test_it_can_verify_postmark_webhook_via_allowed_ip() $this->assertInstanceOf(Webhook::class, $provider->webhook()); } - public function test_it_denies_postmark_webhook_from_disallowed_ip() + #[Test] + public function it_denies_postmark_webhook_from_disallowed_ip(): void { Config::set('services.postmark.webhook.verification_types', ['ips']); @@ -127,7 +135,8 @@ public function test_it_denies_postmark_webhook_from_disallowed_ip() // verify() — no verification_types configured // ------------------------------------------------------------------------- - public function test_it_passes_when_no_verification_types_configured() + #[Test] + public function it_passes_when_no_verification_types_configured(): void { Config::set('services.postmark.webhook.verification_types', []); @@ -143,7 +152,8 @@ public function test_it_passes_when_no_verification_types_configured() // getEvent() // ------------------------------------------------------------------------- - public function test_it_gets_record_type_event() + #[Test] + public function it_gets_record_type_event(): void { $request = Mockery::mock(Request::class); $request->allows('filled')->with('RecordType')->andReturns(true); @@ -154,7 +164,8 @@ public function test_it_gets_record_type_event() $this->assertEquals('Bounce', $provider->getEvent($request)); } - public function test_it_defaults_to_inbound_event() + #[Test] + public function it_defaults_to_inbound_event(): void { $request = Mockery::mock(Request::class); $request->allows('filled')->with('RecordType')->andReturns(false); diff --git a/tests/ProviderTest.php b/tests/ProviderTest.php index 6283009..fc09834 100644 --- a/tests/ProviderTest.php +++ b/tests/ProviderTest.php @@ -4,12 +4,14 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use PHPUnit\Framework\Attributes\Test; use Receiver\Providers\Webhook; use Receiver\Tests\Fixtures\TestProvider; class ProviderTest extends TestCase { - public function test_handles_webhook_with_existing_handler() + #[Test] + public function handles_webhook_with_existing_handler(): void { $request = new Request($this->mockPayload()); @@ -23,7 +25,8 @@ public function test_handles_webhook_with_existing_handler() $this->assertInstanceOf(JsonResponse::class, $response); } - public function test_handles_webhook_with_missing_handler() + #[Test] + public function handles_webhook_with_missing_handler(): void { $this->expectExceptionMessage('Fallback!'); @@ -42,7 +45,8 @@ public function test_handles_webhook_with_missing_handler() $this->assertInstanceOf(JsonResponse::class, $response); } - public function test_handles_multiple_events_in_single_payload() + #[Test] + public function handles_multiple_events_in_single_payload(): void { $events = [ 'event_a' => ['id' => 1], @@ -63,7 +67,8 @@ public function test_handles_multiple_events_in_single_payload() $this->assertTrue($provider->dispatched(Fixtures\EventB::class)); } - public function test_handler_class_resolved_case_insensitively() + #[Test] + public function handler_class_resolved_case_insensitively(): void { // 'FOO.BARRED' and 'foo.barred' must resolve to the same class $payload = $this->mockPayload(); diff --git a/tests/SlackProviderTest.php b/tests/SlackProviderTest.php index 110fc76..5fb2e9f 100644 --- a/tests/SlackProviderTest.php +++ b/tests/SlackProviderTest.php @@ -6,6 +6,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Mockery; +use PHPUnit\Framework\Attributes\Test; use Receiver\Providers\SlackProvider; use Receiver\Providers\Webhook; use Symfony\Component\HttpKernel\Exception\HttpException; @@ -14,10 +15,9 @@ class SlackProviderTest extends TestCase { /** * https://api.slack.com/apis/connections/events-api#the-events-api__subscribing-to-event-types__events-api-request-urls__request-url-configuration--verification. - * - * @return void */ - public function test_it_can_receive_slack_handshake() + #[Test] + public function it_can_receive_slack_handshake(): void { $request = Mockery::mock(Request::class); $request->allows('has')->with('challenge')->andReturns(true); @@ -34,7 +34,8 @@ public function test_it_can_receive_slack_handshake() $this->assertJson(json_encode(['challenge' => 'slack-challenge-token']), $response->content()); } - public function test_it_can_sign_and_verify_slack_webhook() + #[Test] + public function it_can_sign_and_verify_slack_webhook(): void { $valid_signature = 'v0=9cd89ead8bc70cf63775d36d04c592a4833c253d9f0f0c0b21762f0f6e9ae429'; $time = Carbon::parse('2022-08-01 12:00:00', 'America/Chicago'); @@ -57,7 +58,8 @@ public function test_it_can_sign_and_verify_slack_webhook() $this->assertInstanceOf(Webhook::class, $webhook); } - public function test_it_can_sign_and_deny_slack_webhook() + #[Test] + public function it_can_sign_and_deny_slack_webhook(): void { $this->expectException(HttpException::class); $this->expectExceptionMessage('Unauthorized'); @@ -76,12 +78,6 @@ public function test_it_can_sign_and_deny_slack_webhook() $provider->receive($request); } - /** - * @param Request $request - * @param int $timestamp - * @param string $signature - * @return Request - */ protected function signUsing(Request $request, int $timestamp, string $signature): Request { $request->allows('header')->with('X-Slack-Request-Timestamp')->andReturns($timestamp); @@ -93,9 +89,6 @@ protected function signUsing(Request $request, int $timestamp, string $signature /** * https://api.slack.com/apis/connections/events-api#the-events-api__receiving-events__event-type-structure. - * - * @param string|null $key - * @return mixed */ protected function mockPayload(string $key = null): mixed { diff --git a/tests/StripeProviderTest.php b/tests/StripeProviderTest.php index c79c36e..7d4cd3a 100644 --- a/tests/StripeProviderTest.php +++ b/tests/StripeProviderTest.php @@ -5,13 +5,15 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Mockery; +use PHPUnit\Framework\Attributes\Test; use Receiver\Providers\StripeProvider; use Receiver\Providers\Webhook; use Symfony\Component\HttpKernel\Exception\HttpException; class StripeProviderTest extends TestCase { - public function test_it_can_receive_stripe_webhook() + #[Test] + public function it_can_receive_stripe_webhook(): void { $secret = 'stripe-test-secret'; $timestamp = time(); @@ -35,7 +37,8 @@ public function test_it_can_receive_stripe_webhook() $this->assertInstanceOf(Webhook::class, $provider->webhook()); } - public function test_it_denies_invalid_stripe_signature() + #[Test] + public function it_denies_invalid_stripe_signature(): void { $this->expectException(HttpException::class); $this->expectExceptionMessage('Unauthorized'); @@ -50,7 +53,8 @@ public function test_it_denies_invalid_stripe_signature() $provider->receive($request); } - public function test_it_handles_stripe_handshake() + #[Test] + public function it_handles_stripe_handshake(): void { $request = Mockery::mock(Request::class); $request->allows('has')->with('challenge')->andReturns(true); @@ -65,7 +69,8 @@ public function test_it_handles_stripe_handshake() $this->assertInstanceOf(JsonResponse::class, $response); } - public function test_it_gets_event_from_type() + #[Test] + public function it_gets_event_from_type(): void { $request = Mockery::mock(Request::class); $request->allows('input')->with('type')->andReturns('customer.created'); @@ -75,7 +80,8 @@ public function test_it_gets_event_from_type() $this->assertEquals('customer.created', $provider->getEvent($request)); } - public function test_it_gets_data_from_data_key() + #[Test] + public function it_gets_data_from_data_key(): void { $request = Mockery::mock(Request::class); $request->allows('input')->with('data')->andReturns(['object' => ['id' => 'cus_123']]); From b65dbd0ba83671168a71b6730dd6a7d51d000234 Mon Sep 17 00:00:00 2001 From: Adam Campbell Date: Thu, 30 Apr 2026 10:25:37 -0500 Subject: [PATCH 4/7] feat: Add 5 new providers and community provider scaffolding (fixes #8) - Add ShopifyProvider (HMAC-SHA256, X-Shopify-Hmac-Sha256 header) - Add TwilioProvider (HMAC-SHA1 of fullUrl + sorted POST params) - Add MailchimpProvider (?secret= query param verification) - Add SendGridProvider (opt-in ECDSA SHA-256, multi-event batch dispatch) - Add PaddleProvider (HMAC-SHA256, Paddle-Signature header, key rotation) - Register all 5 new drivers in ReceiverManager - Add --provider flag to receiver:make to scaffold companion ServiceProvider - Add stubs/receiver-provider.stub - Fix ShopifyProvider getEvent to use ?? '' instead of header default arg - Add tests for all 5 providers (27 new tests) - Expand ManagerTest with driver instantiation tests for all 5 new providers - Update README: new providers in out-of-the-box list and configuration section - Document --provider flag and community provider packaging pattern in README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 120 +++++++++++----- src/Console/Commands/GenerateReceiver.php | 45 ++++++ src/Providers/MailchimpProvider.php | 25 ++++ src/Providers/PaddleProvider.php | 58 ++++++++ src/Providers/SendGridProvider.php | 59 ++++++++ src/Providers/ShopifyProvider.php | 24 ++++ src/Providers/TwilioProvider.php | 45 ++++++ src/ReceiverManager.php | 60 ++++++++ stubs/receiver-provider.stub | 19 +++ stubs/receiver-verified.stub | 14 +- stubs/receiver.stub | 10 +- tests/MailchimpProviderTest.php | 63 +++++++++ tests/ManagerTest.php | 45 ++++++ tests/PaddleProviderTest.php | 101 ++++++++++++++ tests/SendGridProviderTest.php | 161 ++++++++++++++++++++++ tests/ShopifyProviderTest.php | 68 +++++++++ tests/TestCase.php | 20 +++ tests/TwilioProviderTest.php | 104 ++++++++++++++ 18 files changed, 981 insertions(+), 60 deletions(-) create mode 100644 src/Providers/MailchimpProvider.php create mode 100644 src/Providers/PaddleProvider.php create mode 100644 src/Providers/SendGridProvider.php create mode 100644 src/Providers/ShopifyProvider.php create mode 100644 src/Providers/TwilioProvider.php create mode 100644 stubs/receiver-provider.stub create mode 100644 tests/MailchimpProviderTest.php create mode 100644 tests/PaddleProviderTest.php create mode 100644 tests/SendGridProviderTest.php create mode 100644 tests/ShopifyProviderTest.php create mode 100644 tests/TwilioProviderTest.php diff --git a/README.md b/README.md index f6d7b35..85d365a 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,14 @@ Out of the box, Receiver has built in support for: - [GitHub Webhooks](https://docs.github.com/en/developers/webhooks-and-events/webhooks/about-webhooks) - [Hubspot Webhooks](https://developers.hubspot.com/docs/api/webhooks) +- [Mailchimp Marketing Webhooks](https://mailchimp.com/developer/marketing/guides/sync-audience-data-webhooks/) +- [Paddle Billing Webhooks](https://developer.paddle.com/webhooks/overview) - [Postmark Webhooks](https://postmarkapp.com/developer/webhooks/webhooks-overview) +- [SendGrid Event Webhooks](https://docs.sendgrid.com/for-developers/tracking-events/getting-started-event-webhook-security-features) +- [Shopify Webhooks](https://shopify.dev/docs/apps/webhooks) - [Slack Events API](https://api.slack.com/apis/connections/events-api) - [Stripe Webhooks](https://stripe.com/docs/webhooks) +- [Twilio Webhooks](https://www.twilio.com/docs/usage/webhooks) Of course, Receiver can receive webhooks from any source using [custom providers](#extending-receiver). @@ -32,6 +37,7 @@ Of course, Receiver can receive webhooks from any source using [custom providers - [Queueing handlers](#queueing-handlers) - [Extending Receiver](#extending-receiver) - [Adding custom providers](#adding-custom-providers) + - [Creating a community provider](#creating-a-community-provider) - [Defining attributes](#defining-attributes) - [Receiving multiple events in a single webhook](#receiving-multiple-events-in-a-single-webhook) - [Securing webhooks](#securing-webhooks) @@ -73,6 +79,40 @@ Each provider reads its signing secret from `config/services.php`. Add the relev ], ``` +**Mailchimp** + +Mailchimp Marketing webhooks use a secret embedded in your configured webhook URL (`?secret=...`). Configure the same value here: + +```php +'mailchimp' => [ + 'webhook_secret' => env('MAILCHIMP_WEBHOOK_SECRET'), +], +``` + +**Paddle** +```php +'paddle' => [ + 'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'), +], +``` + +**SendGrid** + +Verification is opt-in. Set `webhook_secret` to the PEM-format public key from the SendGrid dashboard (Settings → Mail Settings → Event Webhook). Leave it empty to accept all requests without signature verification. + +```php +'sendgrid' => [ + 'webhook_secret' => env('SENDGRID_WEBHOOK_PUBLIC_KEY', ''), +], +``` + +**Shopify** +```php +'shopify' => [ + 'webhook_secret' => env('SHOPIFY_WEBHOOK_SECRET'), +], +``` + **Slack** ```php 'slack' => [ @@ -87,6 +127,13 @@ Each provider reads its signing secret from `config/services.php`. Add the relev ], ``` +**Twilio** +```php +'twilio' => [ + 'webhook_secret' => env('TWILIO_AUTH_TOKEN'), +], +``` + **Postmark** Postmark offers several verification strategies. Configure which ones to apply (and in what order) under the `webhook` key: @@ -330,49 +377,44 @@ php artisan receiver:make --verified Once you've created your new provider you can simply extend Receiver in your `AppServiceProvider` so that Receiver can use it: ```php -extend('mailgun', function ($app) { + return new MailgunProvider( + config('services.mailgun.webhook_secret') + ); +}); +``` -use App\Http\Receivers\MailchimpProvider; -use App\Http\Receivers\MailgunProvider; -use Illuminate\Support\ServiceProvider; +### Creating a Community Provider -class AppServiceProvider extends ServiceProvider -{ - /** - * Register any application services. - * - * @return void - */ - public function register() - { - // - } +If you're building a standalone provider package to share with the community, add the `--provider` flag to also scaffold a companion `ServiceProvider` that auto-registers the driver via `Receiver::extend()`: - /** - * Bootstrap any application services. - * - * @return void - */ - public function boot() - { - $receiver = app('receiver'); - - $receiver->extend('mailchimp', function ($app) { - return new MailchimpProvider( - config('services.mailchimp.webhook_secret') - ); - }); - - $receiver->extend('mailgun', function ($app) { - return new MailgunProvider( - config('services.mailgun.webhook_secret') - ); - }); +```shell +php artisan receiver:make --provider +# or with signature verification +php artisan receiver:make --verified --provider +``` + +This generates two files: + +- `app/Http/Receivers/{Name}Provider.php` — your provider class +- `app/Providers/{Name}ReceiverServiceProvider.php` — registers the driver + +The generated `ServiceProvider` calls `Receiver::extend()` in its `boot` method so consumers only need to add the provider to `config/app.php` (or use package auto-discovery). + +To publish your package for auto-discovery, add the service provider to your package's `composer.json`: + +```json +{ + "extra": { + "laravel": { + "providers": [ + "YourVendor\\YourPackage\\YourReceiverServiceProvider" + ] + } } } - ``` ### Defining Attributes @@ -502,7 +544,9 @@ Unlike the `verify` method, `handshake` expects an array to be returned, since m ## Share your Receivers! -**Have you created a custom Receiver?** Share it with the community in our **[Receivers Discussion topic](https://github.com/hotmeteor/receiver/discussions/categories/receivers)**! +**Have you created a custom Receiver provider?** Share it with the community in our **[Receivers Discussion topic](https://github.com/hotmeteor/receiver/discussions/categories/receivers)**! + +> **Tip:** Use `php artisan receiver:make --provider` to scaffold a standalone package with a companion `ServiceProvider` that supports Laravel's package auto-discovery. ## Credits diff --git a/src/Console/Commands/GenerateReceiver.php b/src/Console/Commands/GenerateReceiver.php index 63552c5..a5fbfa0 100644 --- a/src/Console/Commands/GenerateReceiver.php +++ b/src/Console/Commands/GenerateReceiver.php @@ -89,6 +89,51 @@ protected function getOptions() { return [ ['verified', false, InputOption::VALUE_NONE, 'Webhooks are verified with a signature'], + ['provider', false, InputOption::VALUE_NONE, 'Also generate a companion ServiceProvider for package distribution'], ]; } + + public function handle() + { + if (parent::handle() === false) { + return false; + } + + if ($this->option('provider')) { + $this->generateServiceProvider(); + } + } + + protected function generateServiceProvider(): void + { + $name = $this->argument('name'); + $class = class_basename(Str::studly(str_replace(['Receiver'], '', $name))); + $driverName = Str::lower($class); + $serviceProviderClass = $class.'ReceiverServiceProvider'; + + $path = $this->laravel->basePath().'/app/Providers/'.$serviceProviderClass.'.php'; + + if ($this->files->exists($path)) { + $this->components->error("Service provider [{$serviceProviderClass}] already exists!"); + + return; + } + + $directory = dirname($path); + if (! $this->files->isDirectory($directory)) { + $this->files->makeDirectory($directory, 0777, true, true); + } + + $stub = $this->files->get(__DIR__.'/../../../stubs/receiver-provider.stub'); + + $stub = str_replace( + ['{{ serviceProviderNamespace }}', '{{ serviceProviderClass }}', '{{ driverName }}', '{{ providerNamespace }}', '{{ providerClass }}'], + ['App\\Providers', $serviceProviderClass, $driverName, 'App\\Http\\Receivers', $class], + $stub + ); + + $this->files->put($path, $stub); + + $this->components->info("Service provider [{$path}] created successfully."); + } } diff --git a/src/Providers/MailchimpProvider.php b/src/Providers/MailchimpProvider.php new file mode 100644 index 0000000..6e30aaf --- /dev/null +++ b/src/Providers/MailchimpProvider.php @@ -0,0 +1,25 @@ +secret, (string) $request->query('secret')); + } + + public function getEvent(Request $request): string|array + { + return (string) $request->input('type', ''); + } +} diff --git a/src/Providers/PaddleProvider.php b/src/Providers/PaddleProvider.php new file mode 100644 index 0000000..6a9ffe9 --- /dev/null +++ b/src/Providers/PaddleProvider.php @@ -0,0 +1,58 @@ +;h1=[;h1=] + * + * Multiple h1 values may be present during secret rotation; a match + * against any of them is accepted. + * + * https://developer.paddle.com/webhooks/signature-verification + */ + public function verify(Request $request): bool + { + $header = $request->header('Paddle-Signature'); + + if (! $header) { + return false; + } + + $ts = null; + $signatures = []; + + foreach (explode(';', $header) as $part) { + [$key, $value] = array_pad(explode('=', $part, 2), 2, null); + if ($key === 'ts') { + $ts = $value; + } elseif ($key === 'h1') { + $signatures[] = $value; + } + } + + if (! $ts || empty($signatures)) { + return false; + } + + $payload = $ts.':'.$request->getContent(); + $computed = hash_hmac('sha256', $payload, $this->secret); + + foreach ($signatures as $signature) { + if (hash_equals($computed, $signature)) { + return true; + } + } + + return false; + } + + public function getEvent(Request $request): string|array + { + return (string) $request->input('event_type', ''); + } +} diff --git a/src/Providers/SendGridProvider.php b/src/Providers/SendGridProvider.php new file mode 100644 index 0000000..d372865 --- /dev/null +++ b/src/Providers/SendGridProvider.php @@ -0,0 +1,59 @@ +secret)) { + return true; + } + + $signature = $request->header('X-Twilio-Email-Event-Webhook-Signature'); + $timestamp = $request->header('X-Twilio-Email-Event-Webhook-Timestamp'); + + if (! $signature || ! $timestamp) { + return false; + } + + $payload = $timestamp.$request->getContent(); + + return openssl_verify($payload, base64_decode($signature), $this->secret, OPENSSL_ALGO_SHA256) === 1; + } + + /** + * SendGrid sends a JSON array of events per request. + * Returns an event-type => first-event-of-that-type map for multi-dispatch. + */ + public function getEvent(Request $request): string|array + { + $events = json_decode($request->getContent(), true) ?? []; + + $result = []; + foreach ($events as $event) { + $type = $event['event'] ?? null; + if ($type && ! isset($result[$type])) { + $result[$type] = $event; + } + } + + return $result ?: ''; + } + + public function getData(Request $request): array + { + return json_decode($request->getContent(), true) ?? []; + } +} diff --git a/src/Providers/ShopifyProvider.php b/src/Providers/ShopifyProvider.php new file mode 100644 index 0000000..7bd666d --- /dev/null +++ b/src/Providers/ShopifyProvider.php @@ -0,0 +1,24 @@ +header('X-Shopify-Hmac-Sha256'); + $computed = base64_encode(hash_hmac('sha256', $request->getContent(), $this->secret, true)); + + return hash_equals($computed, (string) $header); + } + + public function getEvent(Request $request): string|array + { + return str_replace('/', '_', (string) ($request->header('X-Shopify-Topic') ?? '')); + } +} diff --git a/src/Providers/TwilioProvider.php b/src/Providers/TwilioProvider.php new file mode 100644 index 0000000..c220883 --- /dev/null +++ b/src/Providers/TwilioProvider.php @@ -0,0 +1,45 @@ +header('X-Twilio-Signature'); + + if (! $header) { + return false; + } + + $url = $request->fullUrl(); + $params = $request->post(); + + if (! empty($params)) { + ksort($params); + foreach ($params as $key => $value) { + $url .= $key.$value; + } + } + + $computed = base64_encode(hash_hmac('sha1', $url, $this->secret, true)); + + return hash_equals($computed, $header); + } + + public function getEvent(Request $request): string|array + { + return $request->input('EventType') + ?? $request->input('MessageStatus') + ?? $request->input('CallStatus') + ?? ''; + } +} diff --git a/src/ReceiverManager.php b/src/ReceiverManager.php index 1c60b20..0d477b6 100644 --- a/src/ReceiverManager.php +++ b/src/ReceiverManager.php @@ -10,9 +10,14 @@ use Receiver\Providers\FakeProvider; use Receiver\Providers\GithubProvider; use Receiver\Providers\HubspotProvider; +use Receiver\Providers\MailchimpProvider; +use Receiver\Providers\PaddleProvider; use Receiver\Providers\PostmarkProvider; +use Receiver\Providers\SendGridProvider; +use Receiver\Providers\ShopifyProvider; use Receiver\Providers\SlackProvider; use Receiver\Providers\StripeProvider; +use Receiver\Providers\TwilioProvider; class ReceiverManager extends Manager implements Factory { @@ -103,6 +108,61 @@ protected function createFakeDriver(): FakeProvider ); } + /** + * Create an instance of the specified driver. + */ + protected function createShopifyDriver(): ShopifyProvider + { + return $this->buildProvider( + ShopifyProvider::class, + $this->config->get('services.shopify') + ); + } + + /** + * Create an instance of the specified driver. + */ + protected function createTwilioDriver(): TwilioProvider + { + return $this->buildProvider( + TwilioProvider::class, + $this->config->get('services.twilio') + ); + } + + /** + * Create an instance of the specified driver. + */ + protected function createMailchimpDriver(): MailchimpProvider + { + return $this->buildProvider( + MailchimpProvider::class, + $this->config->get('services.mailchimp') + ); + } + + /** + * Create an instance of the specified driver. + */ + protected function createSendgridDriver(): SendGridProvider + { + return $this->buildProvider( + SendGridProvider::class, + $this->config->get('services.sendgrid') + ); + } + + /** + * Create an instance of the specified driver. + */ + protected function createPaddleDriver(): PaddleProvider + { + return $this->buildProvider( + PaddleProvider::class, + $this->config->get('services.paddle') + ); + } + /** * Build a webhook provider instance. * diff --git a/stubs/receiver-provider.stub b/stubs/receiver-provider.stub new file mode 100644 index 0000000..a560288 --- /dev/null +++ b/stubs/receiver-provider.stub @@ -0,0 +1,19 @@ +buildProvider( + \{{ providerNamespace }}\{{ providerClass }}::class, + config('services.{{ driverName }}') + ); + }); + } +} diff --git a/stubs/receiver-verified.stub b/stubs/receiver-verified.stub index f003737..1506778 100644 --- a/stubs/receiver-verified.stub +++ b/stubs/receiver-verified.stub @@ -7,28 +7,16 @@ use Receiver\Providers\AbstractProvider; class {{ providerClass }} extends AbstractProvider { - /** - * @param Request $request - * @return bool - */ public function verify(Request $request): bool { return true; } - /** - * @param Request $request - * @return string - */ - public function getEvent(Request $request): string + public function getEvent(Request $request): string|array { // } - /** - * @param Request $request - * @return array - */ public function getData(Request $request): array { // diff --git a/stubs/receiver.stub b/stubs/receiver.stub index fa44989..5562b29 100644 --- a/stubs/receiver.stub +++ b/stubs/receiver.stub @@ -7,19 +7,11 @@ use Receiver\Providers\AbstractProvider; class {{ providerClass }} extends AbstractProvider { - /** - * @param Request $request - * @return string - */ - public function getEvent(Request $request): string + public function getEvent(Request $request): string|array { // } - /** - * @param Request $request - * @return array - */ public function getData(Request $request): array { // diff --git a/tests/MailchimpProviderTest.php b/tests/MailchimpProviderTest.php new file mode 100644 index 0000000..6b200e9 --- /dev/null +++ b/tests/MailchimpProviderTest.php @@ -0,0 +1,63 @@ +allows('query')->with('secret')->andReturns($secret); + $request->allows('input')->with('type', '')->andReturns('subscribe'); + $request->allows('all')->andReturns(['type' => 'subscribe', 'data' => []]); + + $provider = new MailchimpProvider($secret); + $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); + } + + #[Test] + public function it_denies_a_mismatched_secret(): void + { + $this->expectException(HttpException::class); + + $request = Mockery::mock(Request::class); + $request->allows('query')->with('secret')->andReturns('wrong-secret'); + + $provider = new MailchimpProvider('mailchimp-webhook-secret'); + $provider->receive($request); + } + + #[Test] + public function it_returns_the_type_as_event(): void + { + $request = Mockery::mock(Request::class); + $request->allows('input')->with('type', '')->andReturns('unsubscribe'); + + $provider = new MailchimpProvider('secret'); + + $this->assertEquals('unsubscribe', $provider->getEvent($request)); + } + + #[Test] + public function it_returns_empty_string_when_no_type(): void + { + $request = Mockery::mock(Request::class); + $request->allows('input')->with('type', '')->andReturns(''); + + $provider = new MailchimpProvider('secret'); + + $this->assertEquals('', $provider->getEvent($request)); + } +} diff --git a/tests/ManagerTest.php b/tests/ManagerTest.php index 407400d..3e5b392 100644 --- a/tests/ManagerTest.php +++ b/tests/ManagerTest.php @@ -5,7 +5,12 @@ use PHPUnit\Framework\Attributes\Test; use Receiver\Contracts\Factory; use Receiver\Providers\GithubProvider; +use Receiver\Providers\MailchimpProvider; +use Receiver\Providers\PaddleProvider; use Receiver\Providers\PostmarkProvider; +use Receiver\Providers\SendGridProvider; +use Receiver\Providers\ShopifyProvider; +use Receiver\Providers\TwilioProvider; class ManagerTest extends TestCase { @@ -28,4 +33,44 @@ public function it_can_instantiate_the_postmark_driver(): void $this->assertInstanceOf(PostmarkProvider::class, $provider); } + + #[Test] + public function it_can_instantiate_the_shopify_driver(): void + { + $factory = $this->app->make(Factory::class); + + $this->assertInstanceOf(ShopifyProvider::class, $factory->driver('shopify')); + } + + #[Test] + public function it_can_instantiate_the_twilio_driver(): void + { + $factory = $this->app->make(Factory::class); + + $this->assertInstanceOf(TwilioProvider::class, $factory->driver('twilio')); + } + + #[Test] + public function it_can_instantiate_the_mailchimp_driver(): void + { + $factory = $this->app->make(Factory::class); + + $this->assertInstanceOf(MailchimpProvider::class, $factory->driver('mailchimp')); + } + + #[Test] + public function it_can_instantiate_the_sendgrid_driver(): void + { + $factory = $this->app->make(Factory::class); + + $this->assertInstanceOf(SendGridProvider::class, $factory->driver('sendgrid')); + } + + #[Test] + public function it_can_instantiate_the_paddle_driver(): void + { + $factory = $this->app->make(Factory::class); + + $this->assertInstanceOf(PaddleProvider::class, $factory->driver('paddle')); + } } diff --git a/tests/PaddleProviderTest.php b/tests/PaddleProviderTest.php new file mode 100644 index 0000000..c155470 --- /dev/null +++ b/tests/PaddleProviderTest.php @@ -0,0 +1,101 @@ +mockPayload()); + $timestamp = (string) time(); + $computed = hash_hmac('sha256', "{$timestamp}:{$payload}", $secret); + $header = "ts={$timestamp};h1={$computed}"; + + $request = Mockery::mock(Request::class); + $request->allows('header')->with('Paddle-Signature')->andReturns($header); + $request->allows('getContent')->andReturns($payload); + $request->allows('input')->with('event_type', '')->andReturns('transaction.created'); + $request->allows('all')->andReturns($this->mockPayload()); + + $provider = new PaddleProvider($secret); + $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); + } + + #[Test] + public function it_denies_an_invalid_signature(): void + { + $this->expectException(HttpException::class); + + $payload = json_encode($this->mockPayload()); + $header = 'ts='.time().';h1=invalidsignature'; + + $request = Mockery::mock(Request::class); + $request->allows('header')->with('Paddle-Signature')->andReturns($header); + $request->allows('getContent')->andReturns($payload); + + $provider = new PaddleProvider('paddle-webhook-secret'); + $provider->receive($request); + } + + #[Test] + public function it_returns_false_when_signature_header_missing(): void + { + $request = Mockery::mock(Request::class); + $request->allows('header')->with('Paddle-Signature')->andReturns(null); + + $provider = new PaddleProvider('secret'); + + $this->assertFalse($provider->verify($request)); + } + + #[Test] + public function it_accepts_any_matching_h1_during_key_rotation(): void + { + $secret = 'paddle-webhook-secret'; + $payload = json_encode($this->mockPayload()); + $timestamp = (string) time(); + $good = hash_hmac('sha256', "{$timestamp}:{$payload}", $secret); + $header = "ts={$timestamp};h1=oldinvalidsignature;h1={$good}"; + + $request = Mockery::mock(Request::class); + $request->allows('header')->with('Paddle-Signature')->andReturns($header); + $request->allows('getContent')->andReturns($payload); + + $provider = new PaddleProvider($secret); + + $this->assertTrue($provider->verify($request)); + } + + #[Test] + public function it_returns_the_event_type(): void + { + $request = Mockery::mock(Request::class); + $request->allows('input')->with('event_type', '')->andReturns('subscription.created'); + + $provider = new PaddleProvider('secret'); + + $this->assertEquals('subscription.created', $provider->getEvent($request)); + } + + protected function mockPayload(): array + { + return [ + 'event_type' => 'transaction.created', + 'data' => [ + 'id' => 'txn_123', + 'status' => 'completed', + ], + ]; + } +} diff --git a/tests/SendGridProviderTest.php b/tests/SendGridProviderTest.php new file mode 100644 index 0000000..9beda47 --- /dev/null +++ b/tests/SendGridProviderTest.php @@ -0,0 +1,161 @@ +allows('getContent')->andReturns(json_encode($this->mockBatch())); + $request->allows('input')->with('event')->andReturns(null); + $request->allows('all')->andReturns($this->mockBatch()); + + $provider = new SendGridProvider(''); + $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); + } + + #[Test] + public function it_denies_when_signature_header_is_missing(): void + { + $this->expectException(HttpException::class); + + $keyPair = $this->generateEcKeyPair(); + + $request = Mockery::mock(Request::class); + $request->allows('getContent')->andReturns('{}'); + $request->allows('header')->with('X-Twilio-Email-Event-Webhook-Signature')->andReturns(null); + $request->allows('header')->with('X-Twilio-Email-Event-Webhook-Timestamp')->andReturns('1234567890'); + + $provider = new SendGridProvider($keyPair['public']); + $provider->receive($request); + } + + #[Test] + public function it_verifies_a_valid_ecdsa_signature(): void + { + $keyPair = $this->generateEcKeyPair(); + $timestamp = (string) time(); + $payload = json_encode($this->mockBatch()); + $toSign = $timestamp.$payload; + + openssl_sign($toSign, $rawSignature, $keyPair['private'], OPENSSL_ALGO_SHA256); + $signature = base64_encode($rawSignature); + + $request = Mockery::mock(Request::class); + $request->allows('getContent')->andReturns($payload); + $request->allows('header')->with('X-Twilio-Email-Event-Webhook-Signature')->andReturns($signature); + $request->allows('header')->with('X-Twilio-Email-Event-Webhook-Timestamp')->andReturns($timestamp); + $request->allows('all')->andReturns($this->mockBatch()); + + $provider = new SendGridProvider($keyPair['public']); + $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); + } + + #[Test] + public function it_denies_an_invalid_ecdsa_signature(): void + { + $this->expectException(HttpException::class); + + $keyPair = $this->generateEcKeyPair(); + + $request = Mockery::mock(Request::class); + $request->allows('getContent')->andReturns(json_encode($this->mockBatch())); + $request->allows('header')->with('X-Twilio-Email-Event-Webhook-Signature')->andReturns(base64_encode('invalidsig')); + $request->allows('header')->with('X-Twilio-Email-Event-Webhook-Timestamp')->andReturns('1234567890'); + + $provider = new SendGridProvider($keyPair['public']); + $provider->receive($request); + } + + #[Test] + public function it_returns_a_map_of_unique_event_types(): void + { + $batch = $this->mockBatch(); + + $request = Mockery::mock(Request::class); + $request->allows('getContent')->andReturns(json_encode($batch)); + + $provider = new SendGridProvider(''); + + $result = $provider->getEvent($request); + + $this->assertIsArray($result); + $this->assertArrayHasKey('click', $result); + $this->assertArrayHasKey('open', $result); + $this->assertCount(2, $result); + } + + #[Test] + public function it_returns_only_the_first_event_per_type(): void + { + $batch = [ + ['event' => 'click', 'url' => 'https://first.example.com'], + ['event' => 'click', 'url' => 'https://second.example.com'], + ]; + + $request = Mockery::mock(Request::class); + $request->allows('getContent')->andReturns(json_encode($batch)); + + $provider = new SendGridProvider(''); + + $result = $provider->getEvent($request); + + $this->assertEquals('https://first.example.com', $result['click']['url']); + } + + #[Test] + public function it_returns_the_full_batch_as_data(): void + { + $batch = $this->mockBatch(); + + $request = Mockery::mock(Request::class); + $request->allows('getContent')->andReturns(json_encode($batch)); + + $provider = new SendGridProvider(''); + + $this->assertEquals($batch, $provider->getData($request)); + } + + protected function mockBatch(): array + { + return [ + [ + 'email' => 'example@test.com', + 'timestamp' => 1460565976, + 'event' => 'click', + 'url' => 'https://example.com', + ], + [ + 'email' => 'example@test.com', + 'timestamp' => 1460565977, + 'event' => 'open', + ], + ]; + } + + protected function generateEcKeyPair(): array + { + $key = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + openssl_pkey_export($key, $privateKeyPem); + $publicKeyPem = openssl_pkey_get_details($key)['key']; + + return ['private' => $privateKeyPem, 'public' => $publicKeyPem]; + } +} diff --git a/tests/ShopifyProviderTest.php b/tests/ShopifyProviderTest.php new file mode 100644 index 0000000..4d5091d --- /dev/null +++ b/tests/ShopifyProviderTest.php @@ -0,0 +1,68 @@ + 123, 'email' => 'test@example.com']); + $signature = base64_encode(hash_hmac('sha256', $payload, $secret, true)); + + $request = Mockery::mock(Request::class); + $request->allows('getContent')->andReturns($payload); + $request->allows('header')->with('X-Shopify-Hmac-Sha256')->andReturns($signature); + $request->allows('header')->with('X-Shopify-Topic')->andReturns('orders/created'); + $request->allows('input')->with('id')->andReturns(123); + $request->allows('all')->andReturns(['id' => 123]); + + $provider = new ShopifyProvider($secret); + $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); + } + + #[Test] + public function it_denies_an_invalid_signature(): void + { + $this->expectException(HttpException::class); + + $request = Mockery::mock(Request::class); + $request->allows('getContent')->andReturns(json_encode(['id' => 123])); + $request->allows('header')->with('X-Shopify-Hmac-Sha256')->andReturns('invalidsignature'); + + $provider = new ShopifyProvider('shopify-webhook-secret'); + $provider->receive($request); + } + + #[Test] + public function it_converts_topic_slashes_to_underscores(): void + { + $request = Mockery::mock(Request::class); + $request->allows('header')->with('X-Shopify-Topic')->andReturns('orders/created'); + + $provider = new ShopifyProvider('secret'); + + $this->assertEquals('orders_created', $provider->getEvent($request)); + } + + #[Test] + public function it_returns_empty_string_when_no_topic(): void + { + $request = Mockery::mock(Request::class); + $request->allows('header')->with('X-Shopify-Topic')->andReturns(null); + + $provider = new ShopifyProvider('secret'); + + $this->assertEquals('', $provider->getEvent($request)); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index a611e1f..2fbb20d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -27,6 +27,26 @@ protected function getEnvironmentSetUp($app) 'webhook_secret' => 'slack-webhook-secret', ]); + $app['config']->set('services.shopify', [ + 'webhook_secret' => 'shopify-webhook-secret', + ]); + + $app['config']->set('services.twilio', [ + 'webhook_secret' => 'twilio-webhook-secret', + ]); + + $app['config']->set('services.mailchimp', [ + 'webhook_secret' => 'mailchimp-webhook-secret', + ]); + + $app['config']->set('services.sendgrid', [ + 'webhook_secret' => '', + ]); + + $app['config']->set('services.paddle', [ + 'webhook_secret' => 'paddle-webhook-secret', + ]); + $app['config']->set('services.postmark.webhook', [ 'headers' => [ 'X-Custom-Header' => 'PostmarkExpected', diff --git a/tests/TwilioProviderTest.php b/tests/TwilioProviderTest.php new file mode 100644 index 0000000..55da59b --- /dev/null +++ b/tests/TwilioProviderTest.php @@ -0,0 +1,104 @@ + 'SM123', 'MessageStatus' => 'delivered']; + + ksort($params); + $data = $url; + foreach ($params as $key => $value) { + $data .= $key.$value; + } + $signature = base64_encode(hash_hmac('sha1', $data, $secret, true)); + + $request = Mockery::mock(Request::class); + $request->allows('header')->with('X-Twilio-Signature')->andReturns($signature); + $request->allows('fullUrl')->andReturns($url); + $request->allows('post')->andReturns($params); + $request->allows('input')->with('EventType')->andReturns(null); + $request->allows('input')->with('MessageStatus')->andReturns('delivered'); + $request->allows('all')->andReturns($params); + + $provider = new TwilioProvider($secret); + $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); + } + + #[Test] + public function it_denies_an_invalid_signature(): void + { + $this->expectException(HttpException::class); + + $request = Mockery::mock(Request::class); + $request->allows('header')->with('X-Twilio-Signature')->andReturns('invalidsig'); + $request->allows('fullUrl')->andReturns('https://example.com/webhook'); + $request->allows('post')->andReturns([]); + + $provider = new TwilioProvider('twilio-webhook-secret'); + $provider->receive($request); + } + + #[Test] + public function it_returns_false_when_signature_header_missing(): void + { + $request = Mockery::mock(Request::class); + $request->allows('header')->with('X-Twilio-Signature')->andReturns(null); + $request->allows('fullUrl')->andReturns('https://example.com/webhook'); + $request->allows('post')->andReturns([]); + + $provider = new TwilioProvider('secret'); + + $this->assertFalse($provider->verify($request)); + } + + #[Test] + public function it_returns_message_status_as_event(): void + { + $request = Mockery::mock(Request::class); + $request->allows('input')->with('EventType')->andReturns(null); + $request->allows('input')->with('MessageStatus')->andReturns('delivered'); + + $provider = new TwilioProvider('secret'); + + $this->assertEquals('delivered', $provider->getEvent($request)); + } + + #[Test] + public function it_returns_call_status_as_event(): void + { + $request = Mockery::mock(Request::class); + $request->allows('input')->with('EventType')->andReturns(null); + $request->allows('input')->with('MessageStatus')->andReturns(null); + $request->allows('input')->with('CallStatus')->andReturns('completed'); + + $provider = new TwilioProvider('secret'); + + $this->assertEquals('completed', $provider->getEvent($request)); + } + + #[Test] + public function it_prefers_event_type_over_status_fields(): void + { + $request = Mockery::mock(Request::class); + $request->allows('input')->with('EventType')->andReturns('com.twilio.messaging.message.delivered'); + + $provider = new TwilioProvider('secret'); + + $this->assertEquals('com.twilio.messaging.message.delivered', $provider->getEvent($request)); + } +} From 1430f5007a5b1e1d6feea172ed246dea78a65b22 Mon Sep 17 00:00:00 2001 From: Adam Campbell Date: Thu, 30 Apr 2026 10:39:27 -0500 Subject: [PATCH 5/7] docs: Rewrite README for clarity and completeness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace bullet list with a driver-name table for quick reference - Consolidate Configuration section: group common providers in one block, add targeted notes for Mailchimp, SendGrid, and Postmark only - Restructure Receiving Webhooks: single provider → multiple → fallbacks - Add handler naming table showing how event names map to class names - Fix wrong namespace in examples (Receiver\Providers → App\Http\Receivers) - Remove dangling references and unused `use Auth` imports - Update queues link from 9.x to current docs/queues - Fix CSRF middleware class to ValidateCsrfToken (Laravel 11+ compatible) - Fix route param from driver to provider to match ReceivesWebhooks trait - Simplify Extending section: generate → define → secure → handshake → multi-event → community provider (logical build-up order) - Use constructor property promotion in handler examples - Fix TOC anchors and remove duplicate entries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 523 +++++++++++++++++++++--------------------------------- 1 file changed, 200 insertions(+), 323 deletions(-) diff --git a/README.md b/README.md index 85d365a..adc63cf 100644 --- a/README.md +++ b/README.md @@ -4,22 +4,24 @@ **Receiver is a drop-in webhook handling library for Laravel.** -Webhooks are a powerful part of any API lifecycle. **Receiver** aims to make handling incoming webhooks in your Laravel app a consistent and easy process. +Receiver gives you a consistent, expressive way to receive, verify, and handle incoming webhooks in your Laravel app. Point a route at a controller, call three methods, and you're done. -Out of the box, Receiver has built in support for: +Out of the box, Receiver supports: -- [GitHub Webhooks](https://docs.github.com/en/developers/webhooks-and-events/webhooks/about-webhooks) -- [Hubspot Webhooks](https://developers.hubspot.com/docs/api/webhooks) -- [Mailchimp Marketing Webhooks](https://mailchimp.com/developer/marketing/guides/sync-audience-data-webhooks/) -- [Paddle Billing Webhooks](https://developer.paddle.com/webhooks/overview) -- [Postmark Webhooks](https://postmarkapp.com/developer/webhooks/webhooks-overview) -- [SendGrid Event Webhooks](https://docs.sendgrid.com/for-developers/tracking-events/getting-started-event-webhook-security-features) -- [Shopify Webhooks](https://shopify.dev/docs/apps/webhooks) -- [Slack Events API](https://api.slack.com/apis/connections/events-api) -- [Stripe Webhooks](https://stripe.com/docs/webhooks) -- [Twilio Webhooks](https://www.twilio.com/docs/usage/webhooks) +| Provider | Driver | +|----------|--------| +| [GitHub](https://docs.github.com/en/developers/webhooks-and-events/webhooks/about-webhooks) | `github` | +| [HubSpot](https://developers.hubspot.com/docs/api/webhooks) | `hubspot` | +| [Mailchimp Marketing](https://mailchimp.com/developer/marketing/guides/sync-audience-data-webhooks/) | `mailchimp` | +| [Paddle Billing](https://developer.paddle.com/webhooks/overview) | `paddle` | +| [Postmark](https://postmarkapp.com/developer/webhooks/webhooks-overview) | `postmark` | +| [SendGrid Events](https://docs.sendgrid.com/for-developers/tracking-events/getting-started-event-webhook-security-features) | `sendgrid` | +| [Shopify](https://shopify.dev/docs/apps/webhooks) | `shopify` | +| [Slack Events API](https://api.slack.com/apis/connections/events-api) | `slack` | +| [Stripe](https://stripe.com/docs/webhooks) | `stripe` | +| [Twilio](https://www.twilio.com/docs/usage/webhooks) | `twilio` | -Of course, Receiver can receive webhooks from any source using [custom providers](#extending-receiver). +Any other webhook source can be added with a [custom provider](#extending-receiver). ![Tests](https://github.com/hotmeteor/receiver/workflows/Tests/badge.svg) [![Latest Version on Packagist](https://img.shields.io/packagist/vpre/hotmeteor/receiver.svg?style=flat-square)](https://packagist.org/packages/hotmeteor/receiver) @@ -30,129 +32,79 @@ Of course, Receiver can receive webhooks from any source using [custom providers - [Installation](#installation) - [Configuration](#configuration) - [Receiving Webhooks](#receiving-webhooks) - - [The Basics](#the-basics) - - [Receiving from multiple apps](#receiving-from-multiple-apps) + - [Single provider](#single-provider) + - [Multiple providers](#multiple-providers) + - [Fallbacks](#fallbacks) - [Handling Webhooks](#handling-webhooks) - - [The Basics](#the-basics-1) + - [Handler naming](#handler-naming) - [Queueing handlers](#queueing-handlers) - [Extending Receiver](#extending-receiver) - - [Adding custom providers](#adding-custom-providers) - - [Creating a community provider](#creating-a-community-provider) - - [Defining attributes](#defining-attributes) - - [Receiving multiple events in a single webhook](#receiving-multiple-events-in-a-single-webhook) + - [Generating a provider](#generating-a-provider) + - [Defining getEvent() and getData()](#defining-getevent-and-getdata) - [Securing webhooks](#securing-webhooks) - [Handshakes](#handshakes) -- [Community Receivers](#share-your-receivers) + - [Multiple events per request](#multiple-events-per-request) + - [Creating a community provider](#creating-a-community-provider) +- [Share Your Receivers!](#share-your-receivers) - [Credits](#credits) - [License](#license) ## Installation -Requires: - -- PHP ^8.2 -- Laravel 10+ +Requires PHP ^8.2 and Laravel 10+. ```shell composer require hotmeteor/receiver ``` -Optional: - -**Stripe** support requires [`stripe/stripe-php`](https://github.com/stripe/stripe-php) +> **Note:** The Stripe provider requires [`stripe/stripe-php`](https://github.com/stripe/stripe-php): +> ```shell +> composer require stripe/stripe-php +> ``` ## Configuration -Each provider reads its signing secret from `config/services.php`. Add the relevant entry for each webhook source you intend to receive. +Each provider reads its secret from `config/services.php`. Add an entry for each source you intend to receive from. -**GitHub** -```php -'github' => [ - 'webhook_secret' => env('GITHUB_WEBHOOK_SECRET'), -], -``` +Most providers use the same shape: -**HubSpot** ```php -'hubspot' => [ - 'webhook_secret' => env('HUBSPOT_WEBHOOK_SECRET'), -], +'github' => ['webhook_secret' => env('GITHUB_WEBHOOK_SECRET')], +'hubspot' => ['webhook_secret' => env('HUBSPOT_WEBHOOK_SECRET')], +'paddle' => ['webhook_secret' => env('PADDLE_WEBHOOK_SECRET')], +'shopify' => ['webhook_secret' => env('SHOPIFY_WEBHOOK_SECRET')], +'slack' => ['webhook_secret' => env('SLACK_WEBHOOK_SECRET')], +'stripe' => ['webhook_secret' => env('STRIPE_WEBHOOK_SECRET')], +'twilio' => ['webhook_secret' => env('TWILIO_AUTH_TOKEN')], ``` -**Mailchimp** - -Mailchimp Marketing webhooks use a secret embedded in your configured webhook URL (`?secret=...`). Configure the same value here: +**Mailchimp** — Mailchimp Marketing webhooks are verified via a secret you embed in your webhook URL (`?secret=...`). Configure the same value here so Receiver can compare it: ```php -'mailchimp' => [ - 'webhook_secret' => env('MAILCHIMP_WEBHOOK_SECRET'), -], -``` - -**Paddle** -```php -'paddle' => [ - 'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'), -], +'mailchimp' => ['webhook_secret' => env('MAILCHIMP_WEBHOOK_SECRET')], ``` -**SendGrid** - -Verification is opt-in. Set `webhook_secret` to the PEM-format public key from the SendGrid dashboard (Settings → Mail Settings → Event Webhook). Leave it empty to accept all requests without signature verification. +**SendGrid** — Signature verification is opt-in. Set `webhook_secret` to the PEM-format public key found in the SendGrid dashboard under Settings → Mail Settings → Event Webhook. Leave it empty to accept all requests without verification. ```php -'sendgrid' => [ - 'webhook_secret' => env('SENDGRID_WEBHOOK_PUBLIC_KEY', ''), -], +'sendgrid' => ['webhook_secret' => env('SENDGRID_WEBHOOK_PUBLIC_KEY', '')], ``` -**Shopify** -```php -'shopify' => [ - 'webhook_secret' => env('SHOPIFY_WEBHOOK_SECRET'), -], -``` - -**Slack** -```php -'slack' => [ - 'webhook_secret' => env('SLACK_WEBHOOK_SECRET'), -], -``` - -**Stripe** -```php -'stripe' => [ - 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), -], -``` - -**Twilio** -```php -'twilio' => [ - 'webhook_secret' => env('TWILIO_AUTH_TOKEN'), -], -``` - -**Postmark** - -Postmark offers several verification strategies. Configure which ones to apply (and in what order) under the `webhook` key: +**Postmark** — Postmark supports several verification strategies. Configure which ones to use under the `webhook` key: ```php 'postmark' => [ 'token' => env('POSTMARK_TOKEN'), 'webhook' => [ - // Choose one or more: 'auth', 'headers', 'ips' + // One or more of: 'auth', 'headers', 'ips' 'verification_types' => ['headers', 'ips'], - // Used when 'headers' is in verification_types. - // Specify header name => expected value pairs. + // Header name => expected value pairs (used with 'headers') 'headers' => [ 'X-Custom-Header' => env('POSTMARK_WEBHOOK_HEADER'), ], - // Used when 'ips' is in verification_types. - // Postmark's official webhook IPs: + // Allowed source IPs (used with 'ips') // https://postmarkapp.com/support/article/800-ips-for-firewalls#webhooks 'ips' => [ '3.134.147.250', @@ -164,137 +116,120 @@ Postmark offers several verification strategies. Configure which ones to apply ( ], ``` -Available `verification_types`: - -| Type | Description | -|------|-------------| +| Postmark `verification_type` | Description | +|------------------------------|-------------| | `auth` | HTTP Basic Auth via `Auth::onceBasic()` | | `headers` | Validates that specific request headers match expected values | -| `ips` | Validates that the request originates from an allowed IP address | +| `ips` | Validates that the request originates from an allowed IP | If `verification_types` is empty or not set, all Postmark requests are accepted without verification. ## Receiving Webhooks -### The Basics - -Webhooks require an exposed endpoint to POST to. Receiver aims to make this a one-time setup that supports any of your incoming webhooks. - -1. Create a controller and route for the webhooks you expect to receive. -2. Receive the webhook and handle it, as necessary: - ```php - receive($request) - ->ok(); - } - } - ``` - -The methods being used are simple: - -- Define the `driver` that should process the webhook -- `receive` the request for handling -- Respond back to the sender with a `200` `ok` response - - -### Receiving from multiple apps +### Single provider -Maybe you have webhooks coming in from multiple services -- handle them all from one controller with a driver variable from your route. +Create a controller and route for each webhook source, then call `driver()`, `receive()`, and `ok()`: ```php receive($request) - ->ok(); - } + public function store(Request $request) + { + return Receiver::driver('stripe') + ->receive($request) + ->ok(); + } } ``` -The provided `ReceivesWebhooks` trait will take care of this for you. +- `driver()` — selects the provider and reads its config +- `receive()` — verifies the signature and maps the event +- `ok()` — dispatches matched handlers and returns a `200` response + +### Multiple providers + +If you'd rather handle all webhooks through a single controller, use a `{provider}` route parameter: + +```php +// routes/web.php +Route::post('/webhooks/{provider}', [WebhookController::class, 'store']) + ->withoutMiddleware(\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class); +``` ```php receive($request) + ->ok(); + } } ``` -_Note: you'll still need to create the route to this action._ Example: +Or use the included `ReceivesWebhooks` trait, which provides this exact `store()` method for you: ```php -Route::post('/hooks/{driver}', [\App\Http\Controllers\Webhooks\WebhooksController::class, 'store']) - ->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class); -``` +receive($request) - ->fallback(function(Webhook $webhook) { - // Do whatever you like here... - }) - ->ok(); - } -} +return Receiver::driver($provider) + ->receive($request) + ->fallback(function (Webhook $webhook) { + Log::info('Unhandled webhook', ['event' => $webhook->getEvent()]); + }) + ->ok(); ``` ## Handling Webhooks -### The Basics +Once a webhook is received, Receiver looks for a handler class that matches the event and dispatches it. Handlers live in `App\Http\Handlers\{Driver}\` by default. If no matching handler is found the webhook is silently ignored and a `200` is returned. -Now that webhooks are being received they need to be handled. Receiver will look for designated `Handler` classes for each event type that comes in in the `App\Http\Handlers\[Driver]` namespace. Receiver *does not* provide these handlers -- they are up to you to provide as needed. If Receiver doesn't find a matching handler it simplies ignores the event and responds with a 200 status code. +### Handler naming -For example, a Stripe webhook handler would be `App\Http\Handlers\Stripe\CustomerCreated` for the incoming [`customer.created`](https://stripe.com/docs/api/events/types#event_types-customer.created) event. +The handler class name is derived from the event name — all non-alphanumeric characters are treated as word separators, then converted to `StudlyCase`: -Each handler is constructed with the `event` (name of the webhook event) and `data` properties. +| Event name | Handler class | +|------------|---------------| +| `customer.created` | `CustomerCreated` | +| `subscription_activated` | `SubscriptionActivated` | +| `orders_created` | `OrdersCreated` | +| `invoice.payment_failed` | `InvoicePaymentFailed` | -Each handler must also use the `Dispatchable` trait. +For example, Stripe's `customer.created` event dispatches `App\Http\Handlers\Stripe\CustomerCreated`. + +Each handler receives the `$event` name and the `$data` array, and must use the `Dispatchable` trait: ```php -``` +# Basic provider +php artisan receiver:make Mailgun -This command will generate a new provider with the name you defined. This class will be created in the `App\Http\Receivers` namespace. - -If your provider needs to be able to verify webhook signatures simply add the `--verified` flag to the command: - -```shell -php artisan receiver:make --verified +# With signature verification scaffolded +php artisan receiver:make Mailgun --verified ``` -Once you've created your new provider you can simply extend Receiver in your `AppServiceProvider` so that Receiver can use it: +The generated class is placed in `App\Http\Receivers`. Once created, register the driver in your `AppServiceProvider`: ```php -$receiver = app('receiver'); - -$receiver->extend('mailgun', function ($app) { - return new MailgunProvider( - config('services.mailgun.webhook_secret') - ); -}); -``` - -### Creating a Community Provider - -If you're building a standalone provider package to share with the community, add the `--provider` flag to also scaffold a companion `ServiceProvider` that auto-registers the driver via `Receiver::extend()`: - -```shell -php artisan receiver:make --provider -# or with signature verification -php artisan receiver:make --verified --provider -``` - -This generates two files: - -- `app/Http/Receivers/{Name}Provider.php` — your provider class -- `app/Providers/{Name}ReceiverServiceProvider.php` — registers the driver - -The generated `ServiceProvider` calls `Receiver::extend()` in its `boot` method so consumers only need to add the provider to `config/app.php` (or use package auto-discovery). - -To publish your package for auto-discovery, add the service provider to your package's `composer.json`: - -```json +public function boot(): void { - "extra": { - "laravel": { - "providers": [ - "YourVendor\\YourPackage\\YourReceiverServiceProvider" - ] - } - } + app('receiver')->extend('mailgun', function () { + return new \App\Http\Receivers\MailgunProvider( + config('services.mailgun.webhook_secret') + ); + }); } ``` -### Defining Attributes +### Defining getEvent() and getData() -Receiver needs two pieces of information to receive and handle webhook events: - -- The event `name` -- The event `data` - -Since these are found in different attributes or headers depending on the webhook, Receiver makes it simple ways to define them in your provider. +Implement `getEvent()` to return the event name. Optionally implement `getData()` to return the event payload — by default it returns `$request->all()`. ```php input('event.name'); + return $request->input('event-data.event'); } - - /** - * @param Request $request - * @return array - */ + public function getData(Request $request): array { - return $request->all(); + return $request->input('event-data', []); } } ``` -The *`getEvent()`* method is used to return the name of the webhook event, ie. `customer.created`. - -The *`getData()`* method is used to return the payload of data that can be used within your handler. By default this is set to `$request->all()`. +### Securing webhooks -### Receiving Multiple Events in a Single Webhook - -Some services send more than one event per request. Receiver supports this by allowing `getEvent()` to return an array of `['event_name' => $eventData]` pairs instead of a single string. - -When an array is returned, Receiver will dispatch a separate handler for each event: +Implement a `verify()` method that returns `true` if the request is authentic, or `false` to reject it with a `401` response: ```php -input('event.name'); + $signature = $request->header('X-Mailgun-Signature'); + $expected = hash_hmac('sha256', $request->getContent(), $this->secret); - // Multiple events — return an array of ['event_name' => $eventData] - $events = []; - foreach ($request->input('events', []) as $event) { - $events[$event['type']] = $event; - } - return $events; - } + return hash_equals($expected, (string) $signature); } ``` -Each matching handler will receive its own event name and data pair. +The signing secret from `config/services.{driver}.webhook_secret` is available as `$this->secret`. -### Securing Webhooks - -Many webhooks have ways of verifying their authenticity as they are received, most commonly through signatures or basic authentication. No matter the strategy, Receiver allows you to write custom verification code as necessary. Simply implement the `verify` method in your provider and return true or false if it passes. +### Handshakes -A `false` return will result in a 401 response being returned to the webhook sender. +Some services send a verification request when a webhook URL is first registered. Implement `handshake()` to respond to it: ```php - $request->input('challenge')]; +} +``` -namespace Receiver\Providers; +When `handshake()` returns a non-empty array, Receiver responds immediately with that payload and skips normal event handling. When it returns an empty array, Receiver processes the request normally. -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; +### Multiple events per request -class CustomProvider extends AbstractProvider +Some services batch multiple events into a single request. Return an `['event_name' => $eventData]` array from `getEvent()` and Receiver will dispatch a separate handler for each entry: + +```php +public function getEvent(Request $request): string|array { - public function verify(Request $request): bool - { - // return result of verification + $events = []; + + foreach (json_decode($request->getContent(), true) as $event) { + $type = $event['type'] ?? null; + if ($type && ! isset($events[$type])) { + $events[$type] = $event; + } } + + return $events; } ``` -### Handshakes +### Creating a community provider -Some webhooks want to perform a "handshake" to check if your endpoint exists and returns a valid response when it's first set up. To facilitate this, implement the `handshake` method in your provider: +If you're building a reusable provider package to share, add the `--provider` flag to also generate a companion `ServiceProvider` that registers the driver automatically: -```php - **Tip:** Use `php artisan receiver:make --provider` to scaffold a standalone package with a companion `ServiceProvider` that supports Laravel's package auto-discovery. +**Built a provider for a service not listed above?** Share it with the community in the **[Receivers Discussion topic](https://github.com/hotmeteor/receiver/discussions/categories/receivers)**! ## Credits - [Adam Campbell](https://github.com/hotmeteor) - [All Contributors](../../contributors) - - + + Made with [contributors-img](https://contrib.rocks). From 99ece10f1babbb05d92b4551bbfcf7e6c1f5f34f Mon Sep 17 00:00:00 2001 From: Adam Campbell Date: Thu, 30 Apr 2026 10:45:33 -0500 Subject: [PATCH 6/7] ci: Add Pint and PHPStan, fix CI matrix to cover Laravel 10-13 - Add laravel/pint and phpstan/phpstan ^2.1 as dev dependencies - Create phpstan.neon (level 5, src/ only) - Add lint, lint:check, and analyse scripts to composer.json - Fix ReceiverManager: add @template to buildProvider() so PHPStan resolves concrete return types on all createXxxDriver() methods - Fix AbstractProvider: remove incorrect @return PHPDoc (native `static` is correct) - Fix GenerateReceiver::handle(): add missing `return null` at end - Suppress known trait-unused warning for ReceivesWebhooks in phpstan.neon - Rewrite .github/workflows/php.yml: - Split into three jobs: lint (Pint), analyse (PHPStan), tests - Fix matrix: laravel properly listed in the top-level array (10, 11, 12, 13) - Add PHP 8.4 to the matrix; exclude Laravel 10 + PHP 8.4 - Add testbench 11.* mapping for Laravel 13 - Drop deprecated --no-suggest flag; set fail-fast: false on tests - Clean up composer cache key Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/php.yml | 129 ++++++++++++++-------- composer.json | 7 +- phpstan.neon | 7 ++ src/AbstractWebhook.php | 21 ---- src/Console/Commands/GenerateReceiver.php | 10 +- src/Contracts/Factory.php | 2 +- src/Contracts/Provider.php | 2 - src/Contracts/Webhook.php | 6 - src/Facades/Receiver.php | 6 +- src/Providers/AbstractProvider.php | 79 ++----------- src/Providers/FakeProvider.php | 5 - src/Providers/GithubProvider.php | 4 - src/Providers/HubspotProvider.php | 4 - src/Providers/PostmarkProvider.php | 4 - src/Providers/SlackProvider.php | 10 +- src/Providers/StripeProvider.php | 17 +-- src/Providers/Webhook.php | 4 +- src/ReceiverManager.php | 14 +-- src/ReceivesWebhooks.php | 3 +- tests/FacadeTest.php | 2 +- tests/Fixtures/EventA.php | 8 +- tests/Fixtures/EventB.php | 8 +- tests/Fixtures/FooBarred.php | 4 +- tests/Fixtures/Github/IssuesClosed.php | 4 +- tests/Fixtures/TestProvider.php | 12 -- tests/GithubProviderTest.php | 2 +- tests/HubspotProviderTest.php | 2 +- tests/ProviderTest.php | 10 +- tests/SlackProviderTest.php | 2 +- tests/StripeProviderTest.php | 2 +- tests/TestCase.php | 1 - 31 files changed, 143 insertions(+), 248 deletions(-) create mode 100644 phpstan.neon diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index b9d68bd..cfb5450 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -1,4 +1,4 @@ -name: Tests +name: CI on: push: @@ -7,54 +7,93 @@ on: branches: [ main ] jobs: - build: + lint: + name: Lint (Pint) runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Check code style + run: composer run-script lint:check + + analyse: + name: Analyse (PHPStan) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Run PHPStan + run: composer run-script analyse + + tests: + name: P${{ matrix.php }} - L${{ matrix.laravel }} + runs-on: ubuntu-latest + strategy: - fail-fast: true + fail-fast: false matrix: - laravel: [ 10.*, 11.* ] - php: [ 8.2, 8.3 ] + laravel: [ '10.*', '11.*', '12.*', '13.*' ] + php: [ '8.2', '8.3', '8.4' ] include: - - laravel: 10.* - testbench: 8.* - - laravel: 11.* - testbench: 9.* - - laravel: 12.* - testbench: 10.* - - name: P${{ matrix.php }} - L${{ matrix.laravel }} + - laravel: '10.*' + testbench: '8.*' + - laravel: '11.*' + testbench: '9.*' + - laravel: '12.*' + testbench: '10.*' + - laravel: '13.*' + testbench: '11.*' + exclude: + # Laravel 10 was released before PHP 8.4 + - laravel: '10.*' + php: '8.4' steps: - - uses: actions/checkout@v4 - - - name: Validate composer.json and composer.lock - run: composer validate - - - name: Cache Composer packages - id: composer-cache - uses: actions/cache@v4 - with: - path: vendor - key: ${{ runner.os }}-${{ matrix.php }}--${{ matrix.laravel }}--node-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-${{ matrix.php }}--${{ matrix.laravel }}-node- - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: dom, fileinfo, libxml, mbstring - coverage: none - - - name: Install dependencies - if: steps.composer-cache.outputs.cache-hit != 'true' - run: | - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update - composer update --prefer-dist --no-progress --no-suggest - - # Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit" - # Docs: https://getcomposer.org/doc/articles/scripts.md - - - name: Run test suite - run: composer run-script test + - uses: actions/checkout@v4 + + - name: Validate composer.json + run: composer validate + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, fileinfo, libxml, mbstring + coverage: none + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v4 + with: + path: vendor + key: ${{ runner.os }}-${{ matrix.php }}-${{ matrix.laravel }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.php }}-${{ matrix.laravel }}- + + - name: Install dependencies + if: steps.composer-cache.outputs.cache-hit != 'true' + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update + composer update --prefer-dist --no-interaction --no-progress + + - name: Run test suite + run: composer run-script test diff --git a/composer.json b/composer.json index 1ad3610..d5b8b5f 100644 --- a/composer.json +++ b/composer.json @@ -23,8 +23,10 @@ "illuminate/support": ">= 10.0" }, "require-dev": { + "laravel/pint": "^1.29", "nunomaduro/collision": "^7.0|^8.0", "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0", + "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^10.0|^11.0|^12.0", "stripe/stripe-php": "^13.0" }, @@ -39,7 +41,10 @@ } }, "scripts": { - "test": "vendor/bin/phpunit" + "test": "vendor/bin/phpunit", + "lint": "vendor/bin/pint", + "lint:check": "vendor/bin/pint --test", + "analyse": "vendor/bin/phpstan analyse --no-progress" }, "extra": { "laravel": { diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..c300fbe --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,7 @@ +parameters: + paths: + - src/ + level: 5 + ignoreErrors: + # Traits are not analysed when unused within the package itself + - '#Trait Receiver\\ReceivesWebhooks is used zero times#' diff --git a/src/AbstractWebhook.php b/src/AbstractWebhook.php index 108f85e..0947681 100644 --- a/src/AbstractWebhook.php +++ b/src/AbstractWebhook.php @@ -10,36 +10,24 @@ abstract class AbstractWebhook implements ArrayAccess, Webhook /** * The normalized name of the webhook event. May be an array of [event => data] * pairs when the provider returns multiple events in a single payload. - * - * @var string|array|null */ public string|array|null $event = null; /** * The payload of the webhook event. - * - * @var array */ public array $data = []; /** * The webhook's raw attributes. - * - * @var array */ public array $webhook = []; - /** - * @return string|array - */ public function getEvent(): string|array { return $this->event ?? ''; } - /** - * @return array - */ public function getData(): array { return $this->data; @@ -47,8 +35,6 @@ public function getData(): array /** * Get the raw webhook array. - * - * @return array */ public function getRaw(): array { @@ -58,7 +44,6 @@ public function getRaw(): array /** * Set the raw webhook array from the provider. * - * @param array $webhook * @return $this */ public function setRaw(array $webhook): static @@ -71,7 +56,6 @@ public function setRaw(array $webhook): static /** * Map the given array onto the webhook's properties. * - * @param array $attributes * @return $this */ public function map(array $attributes): static @@ -87,7 +71,6 @@ public function map(array $attributes): static * Determine if the given raw webhook attribute exists. * * @param string $offset - * @return bool */ public function offsetExists($offset): bool { @@ -98,7 +81,6 @@ public function offsetExists($offset): bool * Get the given key from the raw webhook. * * @param string $offset - * @return mixed */ public function offsetGet($offset): mixed { @@ -109,8 +91,6 @@ public function offsetGet($offset): mixed * Set the given attribute on the raw webhook array. * * @param string $offset - * @param mixed $value - * @return void */ public function offsetSet($offset, mixed $value): void { @@ -121,7 +101,6 @@ public function offsetSet($offset, mixed $value): void * Unset the given value from the raw webhook array. * * @param string $offset - * @return void */ public function offsetUnset($offset): void { diff --git a/src/Console/Commands/GenerateReceiver.php b/src/Console/Commands/GenerateReceiver.php index a5fbfa0..639aa32 100644 --- a/src/Console/Commands/GenerateReceiver.php +++ b/src/Console/Commands/GenerateReceiver.php @@ -30,9 +30,6 @@ class GenerateReceiver extends GeneratorCommand */ protected $type = 'Receiver'; - /** - * @return string - */ protected function getStub(): string { return ! $this->option('verified') @@ -43,8 +40,9 @@ protected function getStub(): string /** * Build the class with the given name. * - * @param string $name + * @param string $name * @return string + * * @throws FileNotFoundException */ protected function buildClass($name) @@ -70,7 +68,7 @@ protected function buildClass($name) /** * Get the destination class path. * - * @param string $name + * @param string $name * @return string */ protected function getPath($name) @@ -102,6 +100,8 @@ public function handle() if ($this->option('provider')) { $this->generateServiceProvider(); } + + return null; } protected function generateServiceProvider(): void diff --git a/src/Contracts/Factory.php b/src/Contracts/Factory.php index 6404e4d..05551bd 100644 --- a/src/Contracts/Factory.php +++ b/src/Contracts/Factory.php @@ -5,7 +5,7 @@ interface Factory { /** - * @param string|null $driver + * @param string|null $driver * @return mixed */ public function driver($driver = null); diff --git a/src/Contracts/Provider.php b/src/Contracts/Provider.php index 3af4521..c7f308f 100644 --- a/src/Contracts/Provider.php +++ b/src/Contracts/Provider.php @@ -10,8 +10,6 @@ public function receive(Request $request): static; /** * Get the webhook instance. - * - * @return Webhook|null */ public function webhook(): ?Webhook; } diff --git a/src/Contracts/Webhook.php b/src/Contracts/Webhook.php index e4eae0c..89e6bd8 100644 --- a/src/Contracts/Webhook.php +++ b/src/Contracts/Webhook.php @@ -4,13 +4,7 @@ interface Webhook { - /** - * @return string|array - */ public function getEvent(): string|array; - /** - * @return array - */ public function getData(): array; } diff --git a/src/Facades/Receiver.php b/src/Facades/Receiver.php index 19c79a4..48733d1 100644 --- a/src/Facades/Receiver.php +++ b/src/Facades/Receiver.php @@ -5,13 +5,15 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Facade; use Receiver\Contracts\Factory; +use Receiver\Contracts\Provider; /** * @method static \Receiver\Providers\AbstractProvider driver(string $driver = null) * @method static \Receiver\Providers\AbstractProvider receive(Request $request) * @method static \Symfony\Component\HttpFoundation\Response ok() - * @see \Receiver\Contracts\Factory - * @see \Receiver\Contracts\Provider + * + * @see Factory + * @see Provider */ class Receiver extends Facade { diff --git a/src/Providers/AbstractProvider.php b/src/Providers/AbstractProvider.php index 76fb3d9..74c0a8c 100644 --- a/src/Providers/AbstractProvider.php +++ b/src/Providers/AbstractProvider.php @@ -14,53 +14,23 @@ abstract class AbstractProvider implements ProviderContract, Responsable { /** * The cached webhook instance. - * - * @var Webhook|null */ - protected Webhook|null $webhook = null; + protected ?Webhook $webhook = null; - /** - * @var Request|null - */ - protected Request|null $request = null; + protected ?Request $request = null; - /** - * @var mixed - */ protected mixed $response = null; - /** - * @var Closure|null - */ - protected Closure|null $fallback = null; + protected ?Closure $fallback = null; - /** - * @var array - */ protected array $dispatchedEvents = []; - /** - * @var string - */ protected string $handlerNamespace = '\\App\\Http\\Handlers'; - /** - * @param string|null $secret - */ - public function __construct(protected ?string $secret = null) - { - } + public function __construct(protected ?string $secret = null) {} - /** - * @param Request $request - * @return string|array - */ abstract public function getEvent(Request $request): string|array; - /** - * @param Request $request - * @return array - */ public function getData(Request $request): array { return $request->all(); @@ -69,7 +39,6 @@ public function getData(Request $request): array /** * Set the scopes of the requested access. * - * @param Request $request * @return $this */ public function receive(Request $request): static @@ -95,9 +64,6 @@ public function receive(Request $request): static return $this; } - /** - * @return JsonResponse|Response - */ public function ok(): JsonResponse|Response { if (! $this->dispatched() && $this->fallback) { @@ -110,7 +76,6 @@ public function ok(): JsonResponse|Response } /** - * @param Closure $closure * @return $this */ public function fallback(Closure $closure): static @@ -120,49 +85,34 @@ public function fallback(Closure $closure): static return $this; } - /** - * @param $request - * @return JsonResponse|Response - */ public function toResponse($request): JsonResponse|Response { return response()->json($this->response, 200); } - /** - * @return Webhook|null - */ public function webhook(): ?Webhook { return $this->webhook; } /** - * @param string|null $key Handler class name to check (e.g. MyHandler::class) - * @return bool + * @param string|null $key Handler class name to check (e.g. MyHandler::class) */ - public function dispatched(string $key = null): bool + public function dispatched(?string $key = null): bool { return $key ? in_array($key, $this->dispatchedEvents) : ! empty($this->dispatchedEvents); } - /** - * @param Request $request - * @return Webhook - */ protected function mapWebhook(Request $request): Webhook { - return (new Webhook())->setRaw($request->all())->map([ + return (new Webhook)->setRaw($request->all())->map([ 'event' => $this->getEvent($request), 'data' => $this->getData($request), ]); } - /** - * @return AbstractProvider - */ protected function handle(): static { $events = $this->webhook->getEvent(); @@ -184,10 +134,6 @@ protected function handle(): static return $this; } - /** - * @param string $event - * @return string - */ protected function getClass(string $event): string { $className = $this->prepareHandlerClassname($event); @@ -198,25 +144,17 @@ protected function getClass(string $event): string return implode('\\', [$basepath, $driverName, $className]); } - /** - * @param string $event - * @return string - */ protected function prepareHandlerClassname(string $event): string { return (string) Str::of($event)->lower()->replaceMatches('/[^A-Za-z0-9]++/', ' ')->studly(); } - /** - * @return string - */ protected function prepareDriverClassname(): string { return Str::replace('Provider', '', class_basename(static::class)); } /** - * @param string $namespace * @return $this */ public function setHandlerNamespace(string $namespace): static @@ -226,9 +164,6 @@ public function setHandlerNamespace(string $namespace): static return $this; } - /** - * @return string - */ public function getHandlerNamespace(): string { return $this->handlerNamespace; diff --git a/src/Providers/FakeProvider.php b/src/Providers/FakeProvider.php index 0899da0..a34cbf9 100644 --- a/src/Providers/FakeProvider.php +++ b/src/Providers/FakeProvider.php @@ -7,7 +7,6 @@ class FakeProvider extends AbstractProvider { /** - * @param Request $request * @return string */ public function getEvent(Request $request): string|array @@ -15,10 +14,6 @@ public function getEvent(Request $request): string|array return $request->input('type', 'fake'); } - /** - * @param Request $request - * @return array - */ public function getData(Request $request): array { return $request->input('data', []); diff --git a/src/Providers/GithubProvider.php b/src/Providers/GithubProvider.php index 43673bd..d408f49 100644 --- a/src/Providers/GithubProvider.php +++ b/src/Providers/GithubProvider.php @@ -8,9 +8,6 @@ class GithubProvider extends AbstractProvider { /** * https://docs.github.com/en/developers/webhooks-and-events/webhooks/securing-your-webhooks. - * - * @param Request $request - * @return bool */ public function verify(Request $request): bool { @@ -21,7 +18,6 @@ public function verify(Request $request): bool } /** - * @param Request $request * @return string */ public function getEvent(Request $request): string|array diff --git a/src/Providers/HubspotProvider.php b/src/Providers/HubspotProvider.php index 02543d7..9304124 100644 --- a/src/Providers/HubspotProvider.php +++ b/src/Providers/HubspotProvider.php @@ -8,9 +8,6 @@ class HubspotProvider extends AbstractProvider { /** * https://developers.hubspot.com/docs/api/webhooks/validating-requests#validate-the-v3-request-signature. - * - * @param Request $request - * @return bool */ public function verify(Request $request): bool { @@ -33,7 +30,6 @@ public function verify(Request $request): bool } /** - * @param Request $request * @return string */ public function getEvent(Request $request): string|array diff --git a/src/Providers/PostmarkProvider.php b/src/Providers/PostmarkProvider.php index 6b7d4e0..aae1595 100644 --- a/src/Providers/PostmarkProvider.php +++ b/src/Providers/PostmarkProvider.php @@ -19,9 +19,6 @@ class PostmarkProvider extends AbstractProvider * - 'ips' Verify that the request originates from an allowed IP address * * https://postmarkapp.com/developer/webhooks/webhooks-overview#protecting-your-webhook. - * - * @param Request $request - * @return bool */ public function verify(Request $request): bool { @@ -54,7 +51,6 @@ public function verify(Request $request): bool } /** - * @param Request $request * @return string */ public function getEvent(Request $request): string|array diff --git a/src/Providers/SlackProvider.php b/src/Providers/SlackProvider.php index 81ebe78..bea0d9d 100644 --- a/src/Providers/SlackProvider.php +++ b/src/Providers/SlackProvider.php @@ -6,20 +6,13 @@ class SlackProvider extends AbstractProvider { - /** - * @param Request $request - * @return array|null - */ - public function handshake(Request $request): array|null + public function handshake(Request $request): ?array { return $request->has('challenge') ? $request->only('challenge') : null; } /** * https://api.slack.com/authentication/verifying-requests-from-slack#verifying-requests-from-slack-using-signing-secrets__a-recipe-for-security__step-by-step-walk-through-for-validating-a-request. - * - * @param Request $request - * @return bool */ public function verify(Request $request): bool { @@ -37,7 +30,6 @@ public function verify(Request $request): bool } /** - * @param Request $request * @return string */ public function getEvent(Request $request): string|array diff --git a/src/Providers/StripeProvider.php b/src/Providers/StripeProvider.php index 20a88fe..5b4b4fe 100644 --- a/src/Providers/StripeProvider.php +++ b/src/Providers/StripeProvider.php @@ -3,23 +3,17 @@ namespace Receiver\Providers; use Illuminate\Http\Request; +use Stripe\Webhook; class StripeProvider extends AbstractProvider { - /** - * @param Request $request - * @return array|null - */ - public function handshake(Request $request): array|null + public function handshake(Request $request): ?array { return $request->has('challenge') ? $request->only('challenge') : null; } /** * https://stripe.com/docs/webhooks/signatures#verify-official-libraries. - * - * @param Request $request - * @return bool */ public function verify(Request $request): bool { @@ -27,7 +21,7 @@ public function verify(Request $request): bool $signature = $request->header('STRIPE_SIGNATURE'); try { - \Stripe\Webhook::constructEvent( + Webhook::constructEvent( $payload, $signature, $this->secret @@ -40,7 +34,6 @@ public function verify(Request $request): bool } /** - * @param Request $request * @return string */ public function getEvent(Request $request): string|array @@ -48,10 +41,6 @@ public function getEvent(Request $request): string|array return $request->input('type'); } - /** - * @param Request $request - * @return array - */ public function getData(Request $request): array { return $request->input('data'); diff --git a/src/Providers/Webhook.php b/src/Providers/Webhook.php index b462855..440b538 100644 --- a/src/Providers/Webhook.php +++ b/src/Providers/Webhook.php @@ -4,6 +4,4 @@ use Receiver\AbstractWebhook; -class Webhook extends AbstractWebhook -{ -} +class Webhook extends AbstractWebhook {} diff --git a/src/ReceiverManager.php b/src/ReceiverManager.php index 0d477b6..57a90b8 100644 --- a/src/ReceiverManager.php +++ b/src/ReceiverManager.php @@ -23,9 +23,6 @@ class ReceiverManager extends Manager implements Factory { /** * Get a driver instance. - * - * @param string $driver - * @return mixed */ public function with(string $driver): mixed { @@ -166,11 +163,12 @@ protected function createPaddleDriver(): PaddleProvider /** * Build a webhook provider instance. * - * @param string $provider - * @param array $config - * @return AbstractProvider + * @template T of AbstractProvider + * + * @param class-string $provider + * @return T */ - public function buildProvider(string $provider, array $config): Providers\AbstractProvider + public function buildProvider(string $provider, array $config): AbstractProvider { return new $provider( Arr::get($config, 'webhook_secret') @@ -194,7 +192,7 @@ public function forgetDrivers(): static * * @return string * - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ public function getDefaultDriver() { diff --git a/src/ReceivesWebhooks.php b/src/ReceivesWebhooks.php index 8b6a92e..e8f2f09 100644 --- a/src/ReceivesWebhooks.php +++ b/src/ReceivesWebhooks.php @@ -3,10 +3,11 @@ namespace Receiver; use Illuminate\Http\Request; +use Illuminate\Routing\Controller; use Receiver\Facades\Receiver; /** - * @mixin \Illuminate\Routing\Controller + * @mixin Controller */ trait ReceivesWebhooks { diff --git a/tests/FacadeTest.php b/tests/FacadeTest.php index 4030b69..06653aa 100644 --- a/tests/FacadeTest.php +++ b/tests/FacadeTest.php @@ -12,7 +12,7 @@ class FacadeTest extends TestCase #[Test] public function ide_helpers(): void { - $request = new Request(); + $request = new Request; $receiver = Receiver::driver('fake')->receive($request); diff --git a/tests/Fixtures/EventA.php b/tests/Fixtures/EventA.php index e6f0478..1d642a1 100644 --- a/tests/Fixtures/EventA.php +++ b/tests/Fixtures/EventA.php @@ -8,11 +8,7 @@ class EventA { use Dispatchable; - public function __construct(public string $event, public array $data) - { - } + public function __construct(public string $event, public array $data) {} - public function handle(): void - { - } + public function handle(): void {} } diff --git a/tests/Fixtures/EventB.php b/tests/Fixtures/EventB.php index 89b73da..d3e998e 100644 --- a/tests/Fixtures/EventB.php +++ b/tests/Fixtures/EventB.php @@ -8,11 +8,7 @@ class EventB { use Dispatchable; - public function __construct(public string $event, public array $data) - { - } + public function __construct(public string $event, public array $data) {} - public function handle(): void - { - } + public function handle(): void {} } diff --git a/tests/Fixtures/FooBarred.php b/tests/Fixtures/FooBarred.php index 912bc54..19eba1a 100644 --- a/tests/Fixtures/FooBarred.php +++ b/tests/Fixtures/FooBarred.php @@ -8,9 +8,7 @@ class FooBarred { use Dispatchable; - public function __construct(public string $event, public array $data) - { - } + public function __construct(public string $event, public array $data) {} public function handle() { diff --git a/tests/Fixtures/Github/IssuesClosed.php b/tests/Fixtures/Github/IssuesClosed.php index 7b604aa..6fb880e 100644 --- a/tests/Fixtures/Github/IssuesClosed.php +++ b/tests/Fixtures/Github/IssuesClosed.php @@ -16,9 +16,7 @@ class IssuesClosed implements ShouldQueue use Queueable; use SerializesModels; - public function __construct(public string $event, public array $data) - { - } + public function __construct(public string $event, public array $data) {} public function handle() { diff --git a/tests/Fixtures/TestProvider.php b/tests/Fixtures/TestProvider.php index cbaab10..13d0144 100644 --- a/tests/Fixtures/TestProvider.php +++ b/tests/Fixtures/TestProvider.php @@ -7,28 +7,16 @@ class TestProvider extends AbstractProvider { - /** - * @param Request $request - * @return string|array - */ public function getEvent(Request $request): string|array { return $request->input('event'); } - /** - * @param Request $request - * @return array - */ public function getData(Request $request): array { return $request->input('data'); } - /** - * @param string $event - * @return string - */ protected function getClass(string $event): string { $className = $this->prepareHandlerClassname($event); diff --git a/tests/GithubProviderTest.php b/tests/GithubProviderTest.php index 01eb103..5eab973 100644 --- a/tests/GithubProviderTest.php +++ b/tests/GithubProviderTest.php @@ -69,7 +69,7 @@ protected function signUsing(Request $request, string $signature): Request /** * https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#example-delivery. */ - protected function mockPayload(string $key = null): mixed + protected function mockPayload(?string $key = null): mixed { $data = [ [ diff --git a/tests/HubspotProviderTest.php b/tests/HubspotProviderTest.php index 3e91dba..9ffac42 100644 --- a/tests/HubspotProviderTest.php +++ b/tests/HubspotProviderTest.php @@ -86,7 +86,7 @@ public function it_gets_event_from_event_type(): void $this->assertEquals('contact.creation', $provider->getEvent($request)); } - protected function mockPayload(string $key = null): mixed + protected function mockPayload(?string $key = null): mixed { $data = [ 'eventType' => 'contact.creation', diff --git a/tests/ProviderTest.php b/tests/ProviderTest.php index fc09834..0fd9a64 100644 --- a/tests/ProviderTest.php +++ b/tests/ProviderTest.php @@ -15,7 +15,7 @@ public function handles_webhook_with_existing_handler(): void { $request = new Request($this->mockPayload()); - $provider = new TestProvider(); + $provider = new TestProvider; $response = $provider ->receive($request) @@ -35,7 +35,7 @@ public function handles_webhook_with_missing_handler(): void $request = new Request($payload); - $provider = new TestProvider(); + $provider = new TestProvider; $response = $provider ->receive($request) @@ -55,7 +55,7 @@ public function handles_multiple_events_in_single_payload(): void $request = new Request(['event' => $events, 'data' => []]); - $provider = new TestProvider(); + $provider = new TestProvider; $response = $provider ->receive($request) @@ -76,7 +76,7 @@ public function handler_class_resolved_case_insensitively(): void $request = new Request($payload); - $provider = new TestProvider(); + $provider = new TestProvider; $response = $provider ->receive($request) @@ -86,7 +86,7 @@ public function handler_class_resolved_case_insensitively(): void $this->assertTrue($provider->dispatched()); } - protected function mockPayload(string $key = null): mixed + protected function mockPayload(?string $key = null): mixed { $payload = [ 'event' => 'foo.barred', diff --git a/tests/SlackProviderTest.php b/tests/SlackProviderTest.php index 5fb2e9f..82763d8 100644 --- a/tests/SlackProviderTest.php +++ b/tests/SlackProviderTest.php @@ -90,7 +90,7 @@ protected function signUsing(Request $request, int $timestamp, string $signature /** * https://api.slack.com/apis/connections/events-api#the-events-api__receiving-events__event-type-structure. */ - protected function mockPayload(string $key = null): mixed + protected function mockPayload(?string $key = null): mixed { $data = [ 'token' => 'z26uFbvR1xHJEdHE1OQiO6t8', diff --git a/tests/StripeProviderTest.php b/tests/StripeProviderTest.php index 7d4cd3a..79ef2de 100644 --- a/tests/StripeProviderTest.php +++ b/tests/StripeProviderTest.php @@ -91,7 +91,7 @@ public function it_gets_data_from_data_key(): void $this->assertEquals(['object' => ['id' => 'cus_123']], $provider->getData($request)); } - protected function mockPayload(string $key = null): mixed + protected function mockPayload(?string $key = null): mixed { $data = [ 'type' => 'customer.created', diff --git a/tests/TestCase.php b/tests/TestCase.php index 2fbb20d..32751c2 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -8,7 +8,6 @@ class TestCase extends \Orchestra\Testbench\TestCase { /** - * @param $app * @return void */ protected function getEnvironmentSetUp($app) From bce31ab4402f57242562ff69f6f79a29e0711498 Mon Sep 17 00:00:00 2001 From: Adam Campbell Date: Thu, 30 Apr 2026 10:47:08 -0500 Subject: [PATCH 7/7] ci: Exclude L13+PHP8.2 (L13 requires ^8.3), add --dev to composer require Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/php.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index cfb5450..cd78eab 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -66,6 +66,9 @@ jobs: # Laravel 10 was released before PHP 8.4 - laravel: '10.*' php: '8.4' + # Laravel 13 requires PHP ^8.3 + - laravel: '13.*' + php: '8.2' steps: - uses: actions/checkout@v4 @@ -92,7 +95,7 @@ jobs: - name: Install dependencies if: steps.composer-cache.outputs.cache-hit != 'true' run: | - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update + composer require --dev "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update composer update --prefer-dist --no-interaction --no-progress - name: Run test suite