Webhook & Event System: Notify external systems when content changes
- Ngôn ngữ chính
- PHP
- Star
- 586
- Fork
- 14
- Merge trung bình
- 4 giờ 57 phút
- Pull request đã merge (30 ngày)
- 19
Mô tả
## Context
Pagible CMS has no way to notify external systems when content changes. This prevents integrations with CDN purging, Slack notifications, CI/CD pipelines, and marketing tools. We're adding a hybrid event + webhook system split into two parts: a lightweight event system in core, and a separate `aimeos/pagible-webhooks` package for webhook delivery.
## Mono-Repo Structure
```
src/ ← existing core package (aimeos/pagible)
Events/CmsEvent.php ← NEW: event class + emit() helper
Concerns/Events.php ← NEW: trait for delete/restore/purge events
...existing code...
webhooks/ ← NEW separate package (aimeos/pagible-webhooks)
src/
Models/Webhook.php
Listeners/WebhookListener.php
GraphQL/Mutations/{Add,Save,Drop}Webhook.php
WebhookServiceProvider.php
database/migrations/
graphql/cms-webhook.graphql
admin/ ← Vue component for webhook management
composer.json
```
## Mono-Repo Packaging
The repo root IS `aimeos/pagible` — published directly to Packagist from the repo.
`aimeos/pagible-webhooks` is split from `webhooks/` into its own read-only repo via `splitsh/lite`. A GitHub Actions workflow runs on push to `master` (or on tag), pushing `webhooks/` to a separate `aimeos/pagible-webhooks` repo.
Add `/webhooks/ export-ignore` to `.gitattributes` so `webhooks/` is excluded from dist archives of the core package.
For local development, use Composer `path` repositories to symlink:
```json
{
"repositories": [
{ "type": "path", "url": "./webhooks" }
]
}
```
## What Changes in Core (`aimeos/pagible`)
### New files (2)
| File | Purpose |
|------|---------|
| `src/Events/CmsEvent.php` | Event class with `emit()` static helper |
| `src/Concerns/Events.php` | Trait that hooks into Eloquent model events (delete/restore/purge) |
### Modified files (7)
| File | Change |
|------|--------|
| `src/Models/Page.php` | Add `use Concerns\Events` trait + `CmsEvent::emit('page.published', $this)` in `publish()` |
| `src/Models/Element.php` | Add `use Concerns\Events` trait + `CmsEvent::emit('element.published', $this)` in `publish()` |
| `src/Models/File.php` | Add `use Concerns\Events` trait + `CmsEvent::emit('file.published', $this)` in `publish()` |
| `src/GraphQL/Mutations/MovePage.php` | Add `CmsEvent::emit('page.moved', $page)` |
| `src/Tools/MovePage.php` | Add `CmsEvent::emit('page.moved', $page)` |
| `src/Permission.php` | Add `config:webhook` permission bit |
| `composer.json` | Add `aimeos/pagible-webhooks` to `suggest` |
### Not changed in core
- No webhook model, migration, listener, GraphQL schema, config, or admin UI
- Core fires events with zero cost if no listener exists
- Events are useful beyond webhooks (custom listeners, logging, etc.)
## What's in the Webhook Package (`aimeos/pagible-webhooks`)
### `composer.json`
```json
{
"name": "aimeos/pagible-webhooks",
"type": "library",
"license": "LGPL-3.0-or-later",
"require": {
"aimeos/pagible": "^1.0",
"spatie/laravel-webhook-server": "^3.0"
},
"autoload": {
"psr-4": { "Aimeos\\Cms\\": "src/" }
},
"extra": {
"laravel": {
"providers": ["Aimeos\\Cms\\WebhookServiceProvider"]
}
}
}
```
### `src/WebhookServiceProvider.php`
- Auto-discovered by Laravel (Spatie is always available since it's a hard requirement)
- Registers:
- `WebhookListener` for `CmsEvent`
- Migration from `database/migrations/`
- Publishes `graphql/cms-webhook.graphql` to `base_path('graphql')` (tag: `admin`)
- Admin UI assets (publishable)
- No Lighthouse namespace registration needed — `Aimeos\Cms\Models` and `Aimeos\Cms\GraphQL\Mutations` are already configured by the app for core
- Registers admin panel dynamically (see Admin UI section)
### `src/Models/Webhook.php`
- Table: `cms_webhooks`
- Uses `HasUuids`, `Aimeos\Cms\Concerns\Tenancy` traits
- Fields: `id` (UUID), `tenant_id`, `url`, `secret`, `events` (JSON array of exact event names), `status` (smallint), `failures` (int), `last_error` (JSON nullable), `editor`, timestamps
### `src/Listeners/WebhookListener.php`
- **Synchronous** — indexed DB query + Spatie job dispatch
- Listens for `Aimeos\Cms\Events\CmsEvent`
- Queries webhooks with `Webhook::withoutTenancy()->where('tenant_id', $event->tenantId)->where('status', 1)->get()` — bypasses Tenancy global scope to work in CLI context (`cms:publish` cron) where `Tenancy::value()` may be empty
- Also listens for Spatie's `FinalWebhookCallFailedEvent` to atomically `$webhook->increment('failures')`, set `last_error` (response code + body truncated to 64KB + timestamp), `Log::warning(...)`
- Passes `webhook_id` via Spatie's `->meta(['webhook_id' => $webhook->id])`
- Event matching: `in_array($event->event, $webhook->events)`
### `src/GraphQL/Mutations/{Add,Save,Drop}Webhook.php`
- `Permission::can('config:webhook', Auth::user())` check
- URL validation via `Aimeos\Cms\Utils::isValidUrl()`
- Editor set via `Auth::user()->name ?? request()->ip()`
- `addWebhook` auto-generates secret (`Str::random(32)`), returns it; queries return `secret: null`
- `addWebhook` enforces hard limit of 100 webhooks per tenant
### `database/migrations/xxxx_create_cms_webhooks_table.php`
```
cms_webhooks:
id uuid PK
tenant_id string
status smallint default 1
failures integer default 0
created_at timestamp
updated_at timestamp
editor string
secret string(32)
url string(500)
events json
last_error json nullable
INDEX: idx_cms_webhooks_tenant_status (tenant_id, status)
```
### `graphql/cms-webhook.graphql`
Published to `base_path('graphql/cms-webhook.graphql')` alongside core's `cms.graphql`. The app's main schema file must add `#import cms-webhook.graphql` (update cms:install command). Uses Lighthouse's `extend type` to add to existing schema:
```graphql
type Webhook {
id: ID!
status: Int!
failures: Int!
url: String!
secret: String
events: JSON!
last_error: JSON
editor: String
created_at: DateTime
updated_at: DateTime
}
input WebhookInput { url: String!, events: [String!]!, status: Int }
extend type Query {
webhook(id: ID!): Webhook @guard @find
webhooks: [Webhook!] @guard @paginate(defaultCount: 50)
}
extend type Mutation {
addWebhook(input: WebhookInput!): Webhook @guard
saveWebhook(id: ID!, input: WebhookInput!): Webhook @guard
dropWebhook(id: [ID!]!): [Webhook]! @guard
}
```
### Admin UI — `admin/src/views/WebhookList.vue`
- Single component with inline `v-dialog` for create/edit
- Shows: URL, events, status toggle, failure count, last error
- Secret shown in creation dialog only (copy-to-clipboard)
- Registered dynamically as a panel (assumes core supports dynamic panel registration)
- Nav entry gated by `config:webhook` permission
## Event System (in Core)
### `src/Events/CmsEvent.php`
```php
class CmsEvent {
public string $event;
public string $tenantId;
public array $data;
public string $editor;
public Carbon $timestamp;
public static function emit(string $event, Model $model): void {
$fn = fn() => event(new static($event, $model));
DB::transactionLevel() > 0 ? DB::afterCommit($fn) : $fn();
}
public function toPayload(): array { /* key fields per model type */ }
}
```
- `toPayload()` uses `$model->getAttributes()` and returns model-specific key fields (Page: id/name/tag/lang/path/domain; Element: id/name/lang/type; File: id/name/lang/mime) returned by `$model->toEvent()`
- Reads `$model->tenant_id` directly (works in CLI/cron without `Tenancy::$callback`)
### `src/Concerns/Events.php`
Laravel auto-calls `boot{TraitName}()` for each trait — no conflict with `Tenancy` trait (which uses `booted()`).
```php
trait Events
{
public static function bootEvents(): void
{
static::deleted( function( Model $model ) {
if( !$model->isForceDeleting() ) {
CmsEvent::emit( strtolower( class_basename( $model ) ) . '.deleted', $model );
}
});
static::restored( function( Model $model ) {
CmsEvent::emit(strtolower( class_basename( $model ) ) . '.restored', $model );
});
static::forceDeleted( function( Model $model ) {
CmsEvent::emit(strtolower( class_basename( $model ) ) . '.purged', $model );
});
}
}
```
Uses `strtolower( class_basename( $model ) )` to derive `'page'`, `'element'`, or `'file'` from the class where the trait is included. Nav is read-only and doesn't use the trait. No method to implement in models.
Note: `forceDelete()` triggers both `deleted` and `forceDeleted`. The `isForceDeleting()` guard prevents duplicate events.
### Event Types (14 total)
| Event | Models | Source |
|-------|--------|--------|
| `*.published` | page, element, file | Explicit call in `publish()` |
| `*.moved` | page only | Explicit call in MovePage mutation/tool |
| `*.deleted` | page, element, file | Events trait (Eloquent event) |
| `*.restored` | page, element, file | Events trait (Eloquent event) |
| `*.purged` | page, element, file | Events trait (Eloquent event) |
No wildcard matching — webhooks subscribe to exact event names. Simple `in_array()` check.
## Design Decisions
### Package boundary
- **Core** owns events + Events trait (always active, zero cost without listener)
- **Webhook package** owns delivery infrastructure (model, listener, GraphQL, UI)
### Security
- **SSRF**: Validate URLs with `Utils::isValidUrl()` (from core)
- **Secret**: `Str::random(32)`, returned only in `addWebhook` response
- **Limit**: Hard limit of 100 webhooks per tenant
- **Idempotency**: Spatie includes `uuid` per call
- **Atomic failure tracking**: `$webhook->increment('failures')` to prevent race conditions
### Performance
- No caching needed — tiny table, composite index, infrequent events
- Spatie manages its own queue/timeout/retries via `config/webhook-server.php`
### Delivery tracking
- On final failure: atomic increment `failures`, set `last_error`, `Log::warning(...)`
- On success: no action (failure count is cumulative)
### Activation
- Webhook package auto-discovered via Laravel provider auto-discovery
- Spatie is a hard requirement of the webhook package — always available when package is installed
## Webhook Payload
```json
{
"event": "page.published",
"tenant_id": "abc123",
"timestamp": "2026-03-20T12:00:00Z",
"editor": "user@example.com",
"data": { "id": "uuid", "name": "My Page", "tag": "my-page", "lang": "en", "path": "my-page", "domain": "example.com" }
}
```
Fields in `data` vary per model type.
## Implementation Order
### Phase 1: Core changes
1. Add `CmsEvent` event class with `emit()` static helper
2. Add `Concerns\Events` trait with `bootEvents()` for delete/restore/purge
3. Add `use Concerns\Events` to Page, Element, File models
4. Add `CmsEvent::emit()` in 5 explicit locations (3 publish + 2 move)
5. Add `config:webhook` permission bit to `Permission.php`
### Phase 2: Webhook package
6. Create `webhooks/` package structure + `composer.json`
7. `Webhook` model + migration
8. `WebhookListener` (CmsEvent → delivery, Spatie failure → tracking)
9. `WebhookServiceProvider` — registration
10. GraphQL schema (`cms-webhook.graphql`) + mutations
11. Admin UI (`WebhookList.vue` + dynamic panel registration)
### Phase 3: Integration
12. Add `aimeos/pagible-webhooks` to core's `suggest`
13. Add `.gitattributes` with `/webhooks/ export-ignore`
14. Add GitHub Actions workflow for `splitsh/lite` subtree split
15. Add `webhook-test` job to `.circleci/config.yml` (see below)
16. Tests for both packages
### CircleCI — `.circleci/config.yml`
Add a new job `webhook-test` using SQLite (same pattern as `php85-sqlite`):
```yaml
"webhook-test":
docker:
- image: aimeos/ci-php:8.5
environment:
DB_DRIVER: sqlite
steps:
- checkout
- restore_cache:
keys:
- php85-{{ checksum "composer.json" }}
- run: composer update -n --prefer-dist
- run: cd webhooks && composer update -n --prefer-dist
- save_cache:
key: php85-{{ checksum "composer.json" }}
paths: [./vendor]
- run: cd webhooks && ../vendor/bin/phpunit
```
Add `"webhook-test"` to the `unittest` workflow jobs list.
## Tests
All tests extend `Tests\TestAbstract` (Orchestra Testbench, SQLite in-memory, `RefreshDatabase`). GraphQL tests use `MakesGraphQLRequests` + `RefreshesSchemaCache` traits. User created with `cmseditor => PHP_INT_MAX` for full permissions.
### Core Tests — `tests/CmsEventTest.php`
| Test | What it verifies |
|------|-----------------|
| `testEmitOutsideTransaction` | `CmsEvent::emit()` dispatches event immediately when no transaction active |
| `testEmitInsideTransaction` | `CmsEvent::emit()` defers via `DB::afterCommit()`, event fires after commit |
| `testEmitInsideTransactionRollback` | Event does NOT fire if transaction rolls back |
| `testEventProperties` | `CmsEvent` constructor sets `event`, `tenantId`, `data`, `editor`, `timestamp` correctly |
| `testTenantIdFromModel` | `tenantId` reads `$model->tenant_id` directly (not `Tenancy::$callback`) |
| `testToPayloadPage` | `toPayload()` returns id, name, tag, lang, path for Page |
| `testToPayloadElement` | `toPayload()` returns id, name, lang, type for Element |
| `testToPayloadFile` | `toPayload()` returns id, name, lang, mime for File |
### Core Tests — `tests/EventsTraitTest.php`
| Test | What it verifies |
|------|-----------------|
| `testDeletedFiresEvent` | Soft delete dispatches `page.deleted` / `element.deleted` / `file.deleted` |
| `testRestoredFiresEvent` | Restore dispatches `page.restored` / `element.restored` / `file.restored` |
| `testForceDeletedFiresPurgedEvent` | `forceDelete()` dispatches `*.purged` |
| `testForceDeletedDoesNotFireDeletedEvent` | `isForceDeleting()` guard prevents duplicate `*.deleted` on `forceDelete()` |
Use `Event::fake([CmsEvent::class])` then `Event::assertDispatched(CmsEvent::class, fn($e) => $e->event === 'page.deleted')`.
### Core Tests — `tests/PermissionTest.php` (add to existing)
| Test | What it verifies |
|------|-----------------|
| `testCanConfigWebhook` | `config:webhook` permission bit works with `can()` / `add()` / `del()` |
### Webhook Package Tests — `webhooks/tests/WebhookTest.php`
| Test | What it verifies |
|------|-----------------|
| `testWebhookModel` | Model attributes, UUID, tenant scope, JSON casts for `events` and `last_error` |
| `testWebhookTenantIsolation` | Webhooks scoped to tenant via `Tenancy` trait |
### Webhook Package Tests — `webhooks/tests/WebhookListenerTest.php`
| Test | What it verifies |
|------|-----------------|
| `testDispatchesToMatchingWebhook` | `CmsEvent` with matching event name triggers `WebhookCall::create()` |
| `testSkipsNonMatchingWebhook` | Webhook subscribed to `page.published` ignores `element.deleted` |
| `testSkipsInactiveWebhook` | Webhook with `status = 0` is not triggered |
| `testTenantIsolation` | Listener only queries webhooks for `$event->tenantId`, not other tenants |
| `testPayloadFormat` | `WebhookCall` receives correct payload structure (event, tenant_id, timestamp, editor, data) |
| `testHmacSigning` | `WebhookCall` uses webhook's `secret` for HMAC signing |
| `testCustomHeaders` | `X-Cms-Event` and `X-Cms-Tenant` headers are set |
| `testFailureIncrement` | `FinalWebhookCallFailedEvent` increments `failures` atomically and sets `last_error` |
| `testFailureLogging` | `FinalWebhookCallFailedEvent` logs via `Log::warning()` |
Use `Queue::fake()` to capture dispatched `WebhookCall` jobs without executing HTTP requests.
### Webhook Package Tests — `webhooks/tests/GraphqlWebhookTest.php`
| Test | What it verifies |
|------|-----------------|
| `testAddWebhook` | Creates webhook, returns secret, sets editor |
| `testAddWebhookUrlValidation` | Rejects invalid/private URLs via `Utils::isValidUrl()` |
| `testAddWebhookLimit` | Rejects when tenant has 100 webhooks |
| `testAddWebhookPermission` | Denied without `config:webhook` permission |
| `testSaveWebhook` | Updates URL and events, secret unchanged |
| `testSaveWebhookSecretNotReturned` | `secret` is null in save/query responses |
| `testDropWebhook` | Deletes webhook(s) by ID |
| `testDropWebhookPermission` | Denied without `config:webhook` permission |
| `testQueryWebhooks` | Lists webhooks for current tenant only |
| `testQueryWebhookById` | Fetches single webhook |
### Integration Test — `webhooks/tests/WebhookIntegrationTest.php`
| Test | What it verifies |
|------|-----------------|
| `testPublishPageTriggersWebhook` | Create webhook subscribed to `page.published`, publish a page, assert `WebhookCall` dispatched with correct payload |
| `testDeletePageTriggersWebhook` | Soft-delete a page, assert `page.deleted` webhook dispatched |
| `testNoWebhooksShortCircuits` | Tenant with no webhooks — listener returns early after empty DB query |
## Verification
1. **Run core tests**: `vendor/bin/paratest -p 2 --exclude-group network` — no regressions from Events trait
2. **Run webhook tests**: `cd webhooks && ../vendor/bin/phpunit` — all webhook tests pass
3. **Manual test**: Register webhook via GraphQL pointing to request bin URL, publish a page, verify signed HTTP POST arrives
Hướng dẫn đóng góp
Chưa lập chỉ mục được hướng dẫn đóng góp cho kho mã nguồn này
Đánh giá
Issue này chưa được đánh giá.