Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/admin/src/Livewire/Pages/Settings/Webhooks.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
use Shopper\Core\Models\Traits\HasPublicId;
use Shopper\Core\Models\WebhookDelivery;
use Shopper\Core\Models\WebhookSubscription;
use Shopper\Core\Webhooks\WebhookRegistry;
use Shopper\Core\Webhooks\WebhookUrl;
use Shopper\Livewire\Concerns\WithSettingsBreadcrumbs;
use Shopper\Sidebar\Breadcrumbs\Breadcrumb;
Expand Down Expand Up @@ -260,7 +261,7 @@ protected function getWebhookFormSchema(): array
}),
CheckboxList::make('events')
->label(__('shopper::pages/settings/webhooks.events'))
->options(collect(config('shopper.webhooks.events', []))
->options(collect(resolve(WebhookRegistry::class)->events())
->values()
->mapWithKeys(fn (string $name): array => [$name => $name])
->all())
Expand Down
6 changes: 2 additions & 4 deletions packages/cart/src/CartServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use Shopper\Cart\Pipelines\CartPipelineRunner;
use Shopper\Core\Enum\WebhookEventType;
use Shopper\Core\Traits\HasRegisterConfigAndMigrationFiles;
use Shopper\Core\Webhooks\Facades\Webhooks;
use Spatie\LaravelPackageTools\Package;
use Spatie\LaravelPackageTools\PackageServiceProvider;

Expand Down Expand Up @@ -47,10 +48,7 @@ public function packageBooted(): void
$schedule->command('shopper:prune-carts')->daily();
});

$this->app['config']->set(
'shopper.webhooks.events.'.CartCompleted::class,
WebhookEventType::CartCompleted->value,
);
Webhooks::register(CartCompleted::class, WebhookEventType::CartCompleted->value);
}

public function packageRegistered(): void
Expand Down
5 changes: 3 additions & 2 deletions packages/core/config/webhooks.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@
|--------------------------------------------------------------------------
|
| Here you may map internal event classes to the public names webhook
| subscriptions listen to. Packages and addons append their own entries.
| The public name is the wire contract and must stay stable.
| subscriptions listen to. Packages and addons register their own events
| through the Webhooks facade in their service provider. The public name
| is the wire contract and must stay stable.
|
*/

Expand Down
10 changes: 3 additions & 7 deletions packages/core/src/CoreServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
use Shopper\Core\Contracts\TaxCalculationProvider;
use Shopper\Core\Contracts\WebhookPayloadSerializer;
use Shopper\Core\Import\ImportManager;
use Shopper\Core\Listeners\DispatchWebhooksListener;
use Shopper\Core\Models\Address;
use Shopper\Core\Models\Attribute;
use Shopper\Core\Models\Category;
Expand Down Expand Up @@ -50,6 +49,7 @@
use Shopper\Core\Taxes\TaxCalculator;
use Shopper\Core\Traits\HasRegisterConfigAndMigrationFiles;
use Shopper\Core\Webhooks\DefaultWebhookPayloadSerializer;
use Shopper\Core\Webhooks\WebhookRegistry;
use Spatie\LaravelPackageTools\Package;
use Spatie\LaravelPackageTools\PackageServiceProvider;

Expand All @@ -72,6 +72,7 @@ final class CoreServiceProvider extends PackageServiceProvider
ChannelManager::class => ChannelManager::class,
ImportManager::class => ImportManager::class,
WebhookPayloadSerializer::class => DefaultWebhookPayloadSerializer::class,
WebhookRegistry::class => WebhookRegistry::class,
];

/** @var string[] */
Expand Down Expand Up @@ -122,12 +123,7 @@ public function packageRegistered(): void
protected function registerWebhookListener(): void
{
$this->app->booted(function (): void {
/** @var array<class-string, string> $events */
$events = (array) config('shopper.webhooks.events', []);

if ($events !== []) {
$this->app['events']->listen(array_keys($events), DispatchWebhooksListener::class);
}
$this->app->make(WebhookRegistry::class)->activate();
});
}

Expand Down
43 changes: 21 additions & 22 deletions packages/core/src/Listeners/DispatchWebhooksListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,30 +11,13 @@
use Shopper\Core\Models\WebhookDelivery;
use Shopper\Core\Models\WebhookEvent;
use Shopper\Core\Models\WebhookSubscription;
use Shopper\Core\Webhooks\WebhookRegistry;
use Throwable;

/**
* Fans a domain event out to the webhook subscriptions listening to it.
*
* Runs synchronously in the dispatching request. Do not make this listener
* queued: domain events use `SerializesModels`, so a queued listener
* re-fetches the model on wake-up and throws `ModelNotFoundException` for
* every `*Deleted` event. The payload is therefore snapshotted here, and
* the queued `DeliverWebhookJob` only posts the frozen JSON.
*
* Per event: resolves the public name from `shopper.webhooks.events`,
* reads the cached active-subscription list (no query on stores without
* webhooks), creates one `WebhookEvent` row (payload stored once), then one
* pending `WebhookDelivery` plus one queued job per matching subscription.
*
* Every failure path is contained: a failing subscription never aborts the
* remaining ones, and no exception escapes `handle()` — the listener runs
* in an after-commit callback of a business transaction (checkout, payment)
* and must never turn a committed order into a 500.
*/
final readonly class DispatchWebhooksListener
{
public function __construct(
private WebhookRegistry $registry,
private WebhookPayloadSerializer $serializer,
) {}

Expand All @@ -52,9 +35,9 @@ public function handle(object $event): void

private function dispatchWebhooks(object $event): void
{
$name = config('shopper.webhooks.events.'.$event::class);
$name = $this->registry->nameFor($event::class);

if (! is_string($name)) {
if ($name === null) {
return;
}

Expand All @@ -65,7 +48,7 @@ private function dispatchWebhooks(object $event): void
return;
}

$serialized = $this->serializer->serialize($event);
$serialized = $this->registry->serialize($event) ?? $this->normalize($this->serializer->serialize($event));

$webhookEvent = WebhookEvent::query()->create([
'name' => $name,
Expand Down Expand Up @@ -97,4 +80,20 @@ private function dispatchWebhooks(object $event): void
}
}
}

/**
* A rebound serializer may not honour the contract's declared shape at
* runtime, so the payload is normalised before it is persisted.
*
* @param array<string, mixed> $payload
* @return array{resource_type: ?string, resource_id: ?string, data: array<string, mixed>}
*/
private function normalize(array $payload): array
{
return [
'resource_type' => $payload['resource_type'] ?? null,
'resource_id' => $payload['resource_id'] ?? null,
'data' => $payload['data'] ?? [],
];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Shopper\Core\Webhooks;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
use Shopper\Core\Contracts\WebhookPayloadSerializer;
use Shopper\Core\Models\Contracts\Order;
use Shopper\Core\Models\Contracts\Product;
Expand All @@ -21,6 +22,10 @@ public function serialize(object $event): array
$resource = $this->resourceOf($event);

if (! $resource instanceof Model) {
Log::warning('Webhook payload is empty: the event exposes no known resource. Register a serializer for it.', [
'event' => $event::class,
]);

return ['resource_type' => null, 'resource_id' => null, 'data' => []];
}

Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/Webhooks/Facades/Webhooks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Shopper\Core\Webhooks\Facades;

use Illuminate\Support\Facades\Facade;
use Shopper\Core\Webhooks\WebhookRegistry;

/**
* @method static WebhookRegistry register(string $eventClass, string $name, \Closure|string|null $serializer = null)
* @method static array<class-string, string> events()
* @method static ?string nameFor(string $eventClass)
*
* @see WebhookRegistry
*/
final class Webhooks extends Facade
{
protected static function getFacadeAccessor(): string
{
return WebhookRegistry::class;
}
}
Loading