Back to blog

Tooling

PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI

Builds a Filament v3 admin panel with a webhook log viewer, retry controls, and signature verification status dashboard.

  • PHP
  • Laravel
  • Filament
  • Webhooks
  • Tooling

SEO Metadata

SEO Title Options

  1. PHP Webhooks With Filament: Admin Panels and Webhook
  2. PHP Webhooks With Filament: Admin Panels: Practical 2026
  3. Tooling Playbook: PHP Webhooks With Filament: Admin Panels

Meta Description Options

  1. Learn PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI with a practical Tooling framework, expert mistakes, implementation steps, examples.
  2. Builds a Filament v3 admin panel with a webhook log viewer, retry controls, and signature verification status dashboard.

URL Slug

php-webhooks-filament-admin-panels-webhook-monitoring-ui

Focus Keyword

PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI

Additional LSI Keywords

  • Tooling
  • PHP
  • Laravel
  • Filament
  • Webhooks
  • PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI
  • production checklist
  • implementation guide
  • best practices
  • architecture decisions
  • testing strategy
  • performance impact

Table of Contents

Article overview

PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI is the kind of topic that looks simple until it reaches production. Teams usually discover the real cost late: unclear boundaries, weak defaults, hidden maintenance work, and decisions that seemed harmless when the codebase was small.

The problem gets worse when the article, tutorial, or implementation guide only explains the happy path. This guide closes that gap with a practical framework, a comparison table, common mistakes, and a deep technical section you can use while planning real work.

Keep reading for the non-obvious part: the safest implementation is rarely the most impressive-looking one. It is the one your team can debug, test, document, and evolve without turning every future change into archaeology.

Key Takeaways

  • PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI should be evaluated as a production decision, not only as a syntax or tooling choice.
  • The best implementation keeps responsibilities visible, with clear ownership, tests, documentation, and rollback paths.
  • Search visibility improves when practical depth, structured answers, and expert examples live on the same page.

[IMAGE: A mobile-first technical article layout showing the main concept, decision table, implementation checklist, and FAQ blocks. Alt: PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI expert guide for Tooling]

What PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI means

PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI means applying tooling knowledge to a concrete engineering decision, then turning that decision into reliable code, documentation, and operational behavior. In practice, it combines the topic's core concepts with trade-off analysis, implementation boundaries, testing strategy, and maintenance discipline.

This is the definition worth optimizing for featured snippets because it avoids hype. It tells the reader what the topic does and what a professional implementation must include.

Why it matters now

The technical web is more crowded than it was a few years ago. Thin tutorials can still get indexed, but they rarely earn trust from senior developers, buyers, AI answer systems, or teams that need production guidance.

For tooling topics, the strongest content now has three layers:

  • a clear answer for fast scanning
  • a practical framework for implementation
  • expert context that explains what breaks later

That same structure helps search engines understand the page. It also helps readers decide whether the advice fits their project.

Implementation framework

Use this framework before adopting the approach described in this article.

  1. Define the user problem and the production risk.
  2. Identify the smallest reliable implementation boundary.
  3. Keep configuration, secrets, and environment-specific behavior outside the article's core logic.
  4. Add tests for the behavior that would hurt if it regressed.
  5. Document the trade-off, not only the final code.
  6. Measure the result with logs, metrics, or user-facing outcomes.
  7. Revisit the decision after real usage exposes edge cases.

The sequence is deliberately conservative. It keeps the work grounded in outcomes instead of novelty.

[IMAGE: A seven-step implementation framework with discovery, boundary design, configuration, tests, documentation, measurement, and iteration. Alt: PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI implementation framework]

Practical comparison

Decision areaStrong approachWeak approachWhy it matters
ScopeSolve one clear problemMix unrelated concernsFocus improves testing and search intent
ArchitecturePut logic in explicit classes or documented boundariesHide behavior in templates or incidental callbacksFuture changes stay easier to review
Data flowPass prepared data into the view or endpointQuery or compute in presentation codeReduces regressions and performance surprises
TestingCover the risky behavior directlyTest only the happy pathCatches production failures earlier
DocumentationExplain trade-offs and limitsRepeat generic definitionsBuilds E-E-A-T and reader trust
OperationsTrack logs, metrics, and rollback stepsShip without measurementMakes the decision reversible

This table is intentionally practical. It gives a reviewer something to check before the implementation becomes expensive to change.

Expert workflow

Expert tip: "Treat PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI as a system boundary. If the next developer cannot find where the decision lives, how it is tested, and when it should be avoided, the implementation is not finished."

A useful workflow is simple:

  • Start with the smallest working example.
  • Add the constraints that exist in your real project.
  • Remove anything that only demonstrates cleverness.
  • Write down the failure modes.
  • Add links to related decisions so future readers can navigate the topic cluster.

That last point matters for both humans and search systems. A single article can answer a question; a cluster proves authority.

Common mistakes

Mistake 1: Copying a pattern without its context

A pattern that works in a small demo can fail in a real application. The missing context is usually data volume, team experience, deployment process, security requirements, or observability.

Before copying the pattern, ask what assumption made it safe in the original example.

Mistake 2: Putting business logic in the wrong layer

This is the fastest way to make future debugging expensive. In Laravel, PHP, and server-rendered websites, presentation should receive prepared data, not discover rules on its own.

Keep decision logic in models, actions, services, policies, requests, jobs, or documented helpers where it can be tested directly.

Mistake 3: Optimizing for novelty instead of maintainability

Newer tools and language features can be valuable. They can also hide simple behavior behind unfamiliar syntax.

Use the option that makes the next production incident easier to understand.

Mistake 4: Publishing without a measurement plan

If the article describes a performance, SEO, security, or architecture improvement, define how success will be checked. Logs, tests, crawl diagnostics, analytics, and user behavior are all stronger than assumptions.

[IMAGE: A common-mistakes board with context loss, wrong layer, novelty bias, and missing measurement highlighted. Alt: PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI common mistakes]

Image placeholders

  • [IMAGE: A concept diagram for PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI with input, decision boundary, implementation, tests, and production feedback. Alt: PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI concept diagram]
  • [IMAGE: A mobile screenshot-style checklist for PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI. Alt: PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI mobile checklist]
  • [IMAGE: A comparison table visualization for strong versus weak implementation choices. Alt: PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI comparison table]

Video placeholder

[VIDEO: Insert a 5-8 minute YouTube walkthrough that demonstrates the main decision, the implementation boundary, the test strategy, and the production caveats for PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI.]

Internal linking opportunities

Original Technical Deep Dive

Webhook code is not finished when the endpoint returns 202.

Someone still needs to answer production questions:

  • Did the provider send the event?
  • Did signature verification pass?
  • Was the payload stored?
  • Did the worker process it?
  • Why did it fail?
  • Can this event be retried safely?
  • Which provider is failing right now?

Filament is a good fit for this because webhook monitoring is an internal operations tool: searchable tables, filters, row actions, bulk actions, read-only detail pages, and dashboard widgets.

This guide targets Filament v3 because that is the version in the prompt. As of May 7, 2026, Filament 3.x is no longer the current stable documentation line, so use the matching docs for your installed version.

The short version

Build the webhook panel around a stored delivery record:

ConcernImplementation
IntakeStore one webhook_deliveries row per provider delivery
Signature statusStore missing, invalid, verified, or skipped
Processing statusStore received, processing, processed, retry_pending, failed, or ignored
PayloadStore full payload only after verification passes
TableSearch by provider event ID, provider, event type, and status
FiltersProvider, status, signature status, date range, failed only
ActionsView details, queue retry, mark ignored
DashboardStats for failed, pending, invalid signatures, and throughput
AuthorizationPolicies decide who can view, retry, ignore, or delete
RetryQueue a job; never process in the Filament request

The admin UI must not become a second webhook processor. It should inspect state and enqueue controlled work.

Delivery table

Start with a table that models operations, not only provider payloads.

<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('webhook_deliveries', function (Blueprint $table): void {
            $table->id();
            $table->string('provider', 64);
            $table->string('provider_event_id', 191)->nullable();
            $table->string('event_type', 191)->nullable();
            $table->string('endpoint', 191);
            $table->string('signature_status', 32);
            $table->string('status', 32)->default('received');
            $table->unsignedInteger('attempts')->default(0);
            $table->unsignedInteger('max_attempts')->default(5);
            $table->char('payload_sha256', 64);
            $table->json('headers')->nullable();
            $table->json('payload')->nullable();
            $table->text('last_error')->nullable();
            $table->timestamp('available_at')->nullable();
            $table->timestamp('received_at');
            $table->timestamp('processing_started_at')->nullable();
            $table->timestamp('processed_at')->nullable();
            $table->timestamp('failed_at')->nullable();
            $table->timestamp('ignored_at')->nullable();
            $table->timestamps();

            $table->unique(['provider', 'provider_event_id']);
            $table->index(['provider', 'status', 'received_at']);
            $table->index(['signature_status', 'received_at']);
            $table->index(['status', 'available_at']);
            $table->index('payload_sha256');
        });
    }
};

Make provider_event_id nullable because invalid signatures may not be trusted enough to parse the payload. If a provider can omit event IDs, keep the SHA-256 hash so operators can correlate duplicate raw deliveries without storing unverified content.

Status enums

Use backed enums for statuses so table colors, retry rules, and tests all share one vocabulary.

<?php

declare(strict_types=1);

namespace App\Enums;

enum WebhookSignatureStatus: string
{
    case Missing = 'missing';
    case Invalid = 'invalid';
    case Verified = 'verified';
    case Skipped = 'skipped';

    /**
     * @return array<string, string>
     */
    public static function options(): array
    {
        return [
            self::Missing->value => 'Missing',
            self::Invalid->value => 'Invalid',
            self::Verified->value => 'Verified',
            self::Skipped->value => 'Skipped',
        ];
    }

    public function label(): string
    {
        return self::options()[$this->value];
    }
}
<?php

declare(strict_types=1);

namespace App\Enums;

enum WebhookDeliveryStatus: string
{
    case Received = 'received';
    case Processing = 'processing';
    case Processed = 'processed';
    case RetryPending = 'retry_pending';
    case Failed = 'failed';
    case Ignored = 'ignored';

    /**
     * @return array<string, string>
     */
    public static function options(): array
    {
        return [
            self::Received->value => 'Received',
            self::Processing->value => 'Processing',
            self::Processed->value => 'Processed',
            self::RetryPending->value => 'Retry pending',
            self::Failed->value => 'Failed',
            self::Ignored->value => 'Ignored',
        ];
    }

    public function label(): string
    {
        return self::options()[$this->value];
    }
}

Model:

<?php

declare(strict_types=1);

namespace App\Models;

use App\Enums\WebhookDeliveryStatus;
use App\Enums\WebhookSignatureStatus;
use Illuminate\Database\Eloquent\Model;

final class WebhookDelivery extends Model
{
    protected $guarded = [];

    protected $casts = [
        'headers' => 'array',
        'payload' => 'array',
        'status' => WebhookDeliveryStatus::class,
        'signature_status' => WebhookSignatureStatus::class,
        'available_at' => 'immutable_datetime',
        'received_at' => 'immutable_datetime',
        'processing_started_at' => 'immutable_datetime',
        'processed_at' => 'immutable_datetime',
        'failed_at' => 'immutable_datetime',
        'ignored_at' => 'immutable_datetime',
    ];

    public function canBeRetried(): bool
    {
        return $this->signature_status === WebhookSignatureStatus::Verified
            && in_array($this->status, [
                WebhookDeliveryStatus::Failed,
                WebhookDeliveryStatus::RetryPending,
            ], true)
            && $this->attempts < $this->max_attempts;
    }
}

Do not make the Filament resource editable. Operators should retry, ignore, or inspect events. They should not manually rewrite payloads.

Store verification status

The receiver still verifies signatures before processing. The difference is that it also records verification status for the admin UI.

<?php

declare(strict_types=1);

namespace App\Webhooks;

use App\Enums\WebhookSignatureStatus;

final readonly class WebhookSignatureResult
{
    public function __construct(
        public WebhookSignatureStatus $status,
        public ?string $reason = null,
    ) {}

    public function verified(): bool
    {
        return $this->status === WebhookSignatureStatus::Verified;
    }
}

Simple HMAC verifier:

<?php

declare(strict_types=1);

namespace App\Webhooks;

use App\Enums\WebhookSignatureStatus;

final readonly class HmacWebhookSignatureVerifier
{
    public function verify(string $rawBody, ?string $signatureHeader, string $secret): WebhookSignatureResult
    {
        if ($signatureHeader === null || $signatureHeader === '') {
            return new WebhookSignatureResult(WebhookSignatureStatus::Missing, 'Signature header missing.');
        }

        $expected = 'sha256='.hash_hmac('sha256', $rawBody, $secret);

        if (! hash_equals($expected, $signatureHeader)) {
            return new WebhookSignatureResult(WebhookSignatureStatus::Invalid, 'Signature mismatch.');
        }

        return new WebhookSignatureResult(WebhookSignatureStatus::Verified);
    }
}

The known signature goes first in hash_equals(). Keep the provider-supplied signature as the second argument.

[IMAGE: Supporting visual 1 for PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI, showing PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI decisions, examples, and PHP, Laravel, Filament. Alt: PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI php-webhooks-filament-admin-panels-webhook-monitoring-ui visual 1]

[IMAGE: Supporting visual 1 for PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI, showing PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI decisions, examples, and PHP, Laravel, Filament. Alt: PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI php-webhooks-filament-admin-panels-webhook-monitoring-ui visual 1]

Receiver controller

The receiver should persist the event and queue processing. It should not run business logic inline just because an admin panel exists.

<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Enums\WebhookDeliveryStatus;
use App\Jobs\ProcessWebhookDelivery;
use App\Models\WebhookDelivery;
use App\Webhooks\HmacWebhookSignatureVerifier;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

final readonly class ReceiveWebhookController
{
    public function __construct(private HmacWebhookSignatureVerifier $verifier) {}

    public function __invoke(Request $request, string $provider): Response
    {
        $rawBody = $request->getContent();
        $signature = $request->headers->get('X-Webhook-Signature');
        $verification = $this->verifier->verify(
            rawBody: $rawBody,
            signatureHeader: $signature,
            secret: config("services.webhooks.{$provider}.secret"),
        );

        $payload = $verification->verified()
            ? json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR)
            : null;

        $providerEventId = is_array($payload) && is_string($payload['id'] ?? null) ? $payload['id'] : null;
        $eventType = is_array($payload) && is_string($payload['type'] ?? null) ? $payload['type'] : null;

        $delivery = WebhookDelivery::query()->create([
            'provider' => $provider,
            'provider_event_id' => $providerEventId,
            'event_type' => $eventType,
            'endpoint' => $request->path(),
            'signature_status' => $verification->status,
            'status' => $verification->verified()
                ? WebhookDeliveryStatus::Received
                : WebhookDeliveryStatus::Ignored,
            'payload_sha256' => hash('sha256', $rawBody),
            'headers' => self::safeHeaders($request),
            'payload' => $payload,
            'last_error' => $verification->reason,
            'received_at' => now(),
        ]);

        if (! $verification->verified()) {
            return response()->noContent(403);
        }

        ProcessWebhookDelivery::dispatch($delivery->id);

        return response()->noContent(202);
    }

    /**
     * @return array<string, list<string>>
     */
    private static function safeHeaders(Request $request): array
    {
        return collect($request->headers->all())
            ->except(['authorization', 'cookie', 'x-webhook-signature'])
            ->all();
    }
}

Storing invalid deliveries is useful for monitoring. Storing invalid payloads is usually not. Keep the hash, endpoint, provider, and sanitized headers.

Retry job

Retries should go through the same processing path as original deliveries.

<?php

declare(strict_types=1);

namespace App\Jobs;

use App\Enums\WebhookDeliveryStatus;
use App\Models\WebhookDelivery;
use App\Webhooks\WebhookDispatcher;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Throwable;

final class ProcessWebhookDelivery implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;

    public function __construct(public int $deliveryId) {}

    /**
     * @return array<int, object>
     */
    public function middleware(): array
    {
        return [new WithoutOverlapping('webhook-delivery-'.$this->deliveryId)];
    }

    public function handle(WebhookDispatcher $dispatcher): void
    {
        $delivery = WebhookDelivery::query()->findOrFail($this->deliveryId);

        if (! $delivery->canBeRetried() && $delivery->status !== WebhookDeliveryStatus::Received) {
            return;
        }

        $delivery->forceFill([
            'status' => WebhookDeliveryStatus::Processing,
            'attempts' => $delivery->attempts + 1,
            'processing_started_at' => now(),
            'last_error' => null,
        ])->save();

        try {
            $dispatcher->dispatch($delivery);

            $delivery->forceFill([
                'status' => WebhookDeliveryStatus::Processed,
                'processed_at' => now(),
                'failed_at' => null,
            ])->save();
        } catch (Throwable $exception) {
            $delivery->forceFill([
                'status' => $delivery->attempts >= $delivery->max_attempts
                    ? WebhookDeliveryStatus::Failed
                    : WebhookDeliveryStatus::RetryPending,
                'available_at' => now()->addMinutes(5),
                'failed_at' => now(),
                'last_error' => $exception->getMessage(),
            ])->save();

            throw $exception;
        }
    }
}

The admin retry action will dispatch this job. It should not call handle() directly.

Filament resource

Generate a resource with a view page:

php artisan make:filament-resource WebhookDelivery --view

Then make it read-only:

<?php

declare(strict_types=1);

namespace App\Filament\Resources;

use App\Enums\WebhookDeliveryStatus;
use App\Enums\WebhookSignatureStatus;
use App\Filament\Resources\WebhookDeliveryResource\Pages;
use App\Jobs\ProcessWebhookDelivery;
use App\Models\WebhookDelivery;
use Filament\Forms;
use Filament\Infolists;
use Filament\Infolists\Infolist;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;

final class WebhookDeliveryResource extends Resource
{
    protected static ?string $model = WebhookDelivery::class;

    protected static ?string $navigationIcon = 'heroicon-o-arrow-path-rounded-square';

    protected static ?string $navigationGroup = 'Operations';

    protected static ?string $navigationLabel = 'Webhooks';

    protected static ?string $recordTitleAttribute = 'provider_event_id';

    public static function canCreate(): bool
    {
        return false;
    }

    public static function getEloquentQuery(): Builder
    {
        return parent::getEloquentQuery()->latest('received_at');
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\Columns\TextColumn::make('provider')
                    ->badge()
                    ->searchable()
                    ->sortable(),

                Tables\Columns\TextColumn::make('event_type')
                    ->searchable()
                    ->placeholder('Unknown')
                    ->limit(36),

                Tables\Columns\TextColumn::make('provider_event_id')
                    ->searchable()
                    ->placeholder('Unavailable')
                    ->limit(28),

                Tables\Columns\TextColumn::make('signature_status')
                    ->badge()
                    ->formatStateUsing(fn (WebhookSignatureStatus|string $state): string => self::signatureStatus($state)->label())
                    ->color(fn (WebhookSignatureStatus|string $state): string => match (self::signatureStatus($state)) {
                        WebhookSignatureStatus::Verified => 'success',
                        WebhookSignatureStatus::Skipped => 'gray',
                        WebhookSignatureStatus::Missing,
                        WebhookSignatureStatus::Invalid => 'danger',
                    }),

                Tables\Columns\TextColumn::make('status')
                    ->badge()
                    ->formatStateUsing(fn (WebhookDeliveryStatus|string $state): string => self::deliveryStatus($state)->label())
                    ->color(fn (WebhookDeliveryStatus|string $state): string => match (self::deliveryStatus($state)) {
                        WebhookDeliveryStatus::Processed => 'success',
                        WebhookDeliveryStatus::Processing,
                        WebhookDeliveryStatus::Received,
                        WebhookDeliveryStatus::RetryPending => 'warning',
                        WebhookDeliveryStatus::Failed => 'danger',
                        WebhookDeliveryStatus::Ignored => 'gray',
                    }),

                Tables\Columns\TextColumn::make('attempts')
                    ->numeric()
                    ->sortable(),

                Tables\Columns\TextColumn::make('received_at')
                    ->dateTime()
                    ->sortable(),

                Tables\Columns\TextColumn::make('last_error')
                    ->limit(48)
                    ->tooltip(fn (WebhookDelivery $record): ?string => $record->last_error),
            ])
            ->filters([
                Tables\Filters\SelectFilter::make('provider')
                    ->options(fn (): array => WebhookDelivery::query()
                        ->distinct()
                        ->orderBy('provider')
                        ->pluck('provider', 'provider')
                        ->all()),

                Tables\Filters\SelectFilter::make('status')
                    ->options(WebhookDeliveryStatus::options()),

                Tables\Filters\SelectFilter::make('signature_status')
                    ->options(WebhookSignatureStatus::options()),

                Tables\Filters\Filter::make('received_at')
                    ->form([
                        Forms\Components\DatePicker::make('from'),
                        Forms\Components\DatePicker::make('until'),
                    ])
                    ->query(fn (Builder $query, array $data): Builder => $query
                        ->when($data['from'] ?? null, fn (Builder $query, string $date): Builder => $query->whereDate('received_at', '>=', $date))
                        ->when($data['until'] ?? null, fn (Builder $query, string $date): Builder => $query->whereDate('received_at', '<=', $date))),

                Tables\Filters\Filter::make('failed')
                    ->query(fn (Builder $query): Builder => $query->where('status', WebhookDeliveryStatus::Failed->value)),
            ])
            ->actions([
                Tables\Actions\ViewAction::make(),

                Tables\Actions\Action::make('retry')
                    ->icon('heroicon-o-arrow-path')
                    ->color('warning')
                    ->requiresConfirmation()
                    ->modalHeading('Retry webhook delivery')
                    ->form([
                        Forms\Components\Textarea::make('note')
                            ->label('Operator note')
                            ->maxLength(1000),
                    ])
                    ->visible(fn (WebhookDelivery $record): bool => auth()->user()?->can('retry', $record) === true && $record->canBeRetried())
                    ->action(function (WebhookDelivery $record): void {
                        ProcessWebhookDelivery::dispatch($record->id);

                        Notification::make()
                            ->title('Webhook retry queued')
                            ->success()
                            ->send();
                    }),

                Tables\Actions\Action::make('ignore')
                    ->icon('heroicon-o-no-symbol')
                    ->color('gray')
                    ->requiresConfirmation()
                    ->visible(fn (WebhookDelivery $record): bool => auth()->user()?->can('ignore', $record) === true && $record->status === WebhookDeliveryStatus::Failed)
                    ->action(function (WebhookDelivery $record): void {
                        $record->forceFill([
                            'status' => WebhookDeliveryStatus::Ignored,
                            'ignored_at' => now(),
                        ])->save();
                    }),
            ])
            ->bulkActions([
                Tables\Actions\BulkAction::make('retry_selected')
                    ->label('Retry selected')
                    ->icon('heroicon-o-arrow-path')
                    ->requiresConfirmation()
                    ->action(function (Collection $records): void {
                        $records
                            ->filter(fn (WebhookDelivery $record): bool => $record->canBeRetried())
                            ->each(fn (WebhookDelivery $record): mixed => ProcessWebhookDelivery::dispatch($record->id));
                    }),
            ])
            ->defaultSort('received_at', 'desc')
            ->poll('15s')
            ->deferLoading()
            ->persistFiltersInSession()
            ->persistSearchInSession()
            ->paginationPageOptions([10, 25, 50, 100]);
    }

    public static function infolist(Infolist $infolist): Infolist
    {
        return $infolist->schema([
            Infolists\Components\Section::make('Delivery')
                ->schema([
                    Infolists\Components\TextEntry::make('provider')->badge(),
                    Infolists\Components\TextEntry::make('event_type')->placeholder('Unknown'),
                    Infolists\Components\TextEntry::make('provider_event_id')->placeholder('Unavailable'),
                    Infolists\Components\TextEntry::make('payload_sha256')->copyable(),
                    Infolists\Components\TextEntry::make('received_at')->dateTime(),
                ])
                ->columns(2),

            Infolists\Components\Section::make('Verification and processing')
                ->schema([
                    Infolists\Components\TextEntry::make('signature_status')->badge(),
                    Infolists\Components\TextEntry::make('status')->badge(),
                    Infolists\Components\TextEntry::make('attempts'),
                    Infolists\Components\TextEntry::make('last_error')->columnSpanFull(),
                ])
                ->columns(2),

            Infolists\Components\Section::make('Payload')
                ->schema([
                    Infolists\Components\TextEntry::make('payload')
                        ->formatStateUsing(fn (?array $state): string => $state === null
                            ? 'Payload not stored.'
                            : json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES))
                        ->copyable()
                        ->columnSpanFull(),
                ]),
        ]);
    }

    public static function getPages(): array
    {
        return [
            'index' => Pages\ListWebhookDeliveries::route('/'),
            'view' => Pages\ViewWebhookDelivery::route('/{record}'),
        ];
    }

    private static function signatureStatus(WebhookSignatureStatus|string $state): WebhookSignatureStatus
    {
        return $state instanceof WebhookSignatureStatus ? $state : WebhookSignatureStatus::from($state);
    }

    private static function deliveryStatus(WebhookDeliveryStatus|string $state): WebhookDeliveryStatus
    {
        return $state instanceof WebhookDeliveryStatus ? $state : WebhookDeliveryStatus::from($state);
    }
}

The table shows operational fields first. The payload belongs on the detail view, not in the index table.

List page widgets

Filament v3 can show resource widgets above the table. Use a stats overview for the webhook health snapshot.

<?php

declare(strict_types=1);

namespace App\Filament\Resources\WebhookDeliveryResource\Pages;

use App\Filament\Resources\WebhookDeliveryResource;
use Filament\Pages\Concerns\ExposesTableToWidgets;
use Filament\Resources\Pages\ListRecords;

final class ListWebhookDeliveries extends ListRecords
{
    use ExposesTableToWidgets;

    protected static string $resource = WebhookDeliveryResource::class;

    protected function getHeaderWidgets(): array
    {
        return [
            WebhookDeliveryResource\Widgets\WebhookDeliveryStats::class,
        ];
    }
}

Stats widget:

<?php

declare(strict_types=1);

namespace App\Filament\Resources\WebhookDeliveryResource\Widgets;

use App\Enums\WebhookDeliveryStatus;
use App\Enums\WebhookSignatureStatus;
use App\Models\WebhookDelivery;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;

final class WebhookDeliveryStats extends BaseWidget
{
    protected function getStats(): array
    {
        $today = now()->startOfDay();

        return [
            Stat::make('Received today', WebhookDelivery::query()->where('received_at', '>=', $today)->count())
                ->description('All providers')
                ->color('gray'),

            Stat::make('Processed today', WebhookDelivery::query()
                ->where('status', WebhookDeliveryStatus::Processed->value)
                ->where('processed_at', '>=', $today)
                ->count())
                ->color('success'),

            Stat::make('Failed', WebhookDelivery::query()
                ->where('status', WebhookDeliveryStatus::Failed->value)
                ->count())
                ->color('danger'),

            Stat::make('Signature failures', WebhookDelivery::query()
                ->whereIn('signature_status', [
                    WebhookSignatureStatus::Missing->value,
                    WebhookSignatureStatus::Invalid->value,
                ])
                ->where('received_at', '>=', $today)
                ->count())
                ->color('warning'),
        ];
    }
}

This is an operations dashboard, not analytics. Keep the stats tied to decisions operators make.

View page

Register the generated view page and keep it read-only.

<?php

declare(strict_types=1);

namespace App\Filament\Resources\WebhookDeliveryResource\Pages;

use App\Filament\Resources\WebhookDeliveryResource;
use Filament\Resources\Pages\ViewRecord;

final class ViewWebhookDelivery extends ViewRecord
{
    protected static string $resource = WebhookDeliveryResource::class;
}

If your payloads are large, do not render the full body by default. Store a truncated preview in the infolist and add a gated download action for users with elevated permission.

Policy

Do not rely on hiding navigation alone. Add a policy for the model.

<?php

declare(strict_types=1);

namespace App\Policies;

use App\Models\User;
use App\Models\WebhookDelivery;

final class WebhookDeliveryPolicy
{
    public function viewAny(User $user): bool
    {
        return $user->can('webhooks.view');
    }

    public function view(User $user, WebhookDelivery $delivery): bool
    {
        return $user->can('webhooks.view');
    }

    public function retry(User $user, WebhookDelivery $delivery): bool
    {
        return $user->can('webhooks.retry') && $delivery->canBeRetried();
    }

    public function ignore(User $user, WebhookDelivery $delivery): bool
    {
        return $user->can('webhooks.ignore');
    }

    public function delete(User $user, WebhookDelivery $delivery): bool
    {
        return false;
    }

    public function deleteAny(User $user): bool
    {
        return false;
    }
}

Webhook logs are audit records. Deleting them from an admin panel usually makes incident review worse.

Table performance

Webhook tables grow quickly. Tune for the common operator queries:

  • "Show failed events."
  • "Show invalid signatures today."
  • "Find this provider event ID."
  • "Show all events from provider X in the last hour."
  • "Retry all failed events that are safe."

Practical rules:

  • Index every filter used by operators.
  • Keep JSON payload out of table columns.
  • Use deferLoading() so the page chrome appears before the first query finishes.
  • Use poll('15s') only if the table query is cheap enough.
  • Keep provider options bounded; if you have hundreds of providers, use a dedicated provider table.
  • Do not bulk retry thousands of rows synchronously; queue work in chunks.

[IMAGE: Supporting visual 2 for PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI, showing PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI decisions, examples, and PHP, Laravel, Filament. Alt: PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI php-webhooks-filament-admin-panels-webhook-monitoring-ui visual 2]

For very high volume, partition or archive old deliveries. Filament is excellent for operational inspection; it is not a log warehouse.

Tests

Test the receiver, retry action, and policy separately.

Receiver test:

it('stores an invalid signature without storing the payload', function (): void {
    $payload = json_encode(['id' => 'evt_123', 'type' => 'order.created'], JSON_THROW_ON_ERROR);

    $this->postJson('/webhooks/acme', json_decode($payload, true), [
        'X-Webhook-Signature' => 'sha256=wrong',
    ])->assertForbidden();

    $delivery = WebhookDelivery::query()->latest('id')->firstOrFail();

    expect($delivery->signature_status)->toBe(WebhookSignatureStatus::Invalid)
        ->and($delivery->payload)->toBeNull()
        ->and(strlen($delivery->payload_sha256))->toBe(64);
});

Retry action test:

use App\Filament\Resources\WebhookDeliveryResource\Pages\ListWebhookDeliveries;
use App\Jobs\ProcessWebhookDelivery;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;

it('queues retry from the Filament table action', function (): void {
    Queue::fake();

    $delivery = WebhookDelivery::factory()->create([
        'signature_status' => WebhookSignatureStatus::Verified,
        'status' => WebhookDeliveryStatus::Failed,
        'attempts' => 1,
        'max_attempts' => 5,
    ]);

    $this->actingAs(User::factory()->withPermission('webhooks.retry')->create());

    Livewire::test(ListWebhookDeliveries::class)
        ->callTableAction('retry', $delivery);

    Queue::assertPushed(ProcessWebhookDelivery::class, fn (ProcessWebhookDelivery $job): bool => $job->deliveryId === $delivery->id);
});

Policy test:

it('does not allow retry after max attempts', function (): void {
    $delivery = WebhookDelivery::factory()->create([
        'signature_status' => WebhookSignatureStatus::Verified,
        'status' => WebhookDeliveryStatus::Failed,
        'attempts' => 5,
        'max_attempts' => 5,
    ]);

    expect($delivery->canBeRetried())->toBeFalse();
});

The UI should prove it queues the retry. The job tests should prove processing is idempotent.

[IMAGE: Supporting visual 2 for PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI, showing PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI decisions, examples, and PHP, Laravel, Filament. Alt: PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI php-webhooks-filament-admin-panels-webhook-monitoring-ui visual 2]

Monitoring checklist

Before shipping the panel:

  • Invalid signatures are visible but unverified payloads are not stored.
  • Failed deliveries have enough error detail to debug without exposing secrets.
  • Retry action requires confirmation and policy authorization.
  • Retry work is queued.
  • Bulk retry is bounded or chunked.
  • Payload views are read-only.
  • Delete actions are disabled unless retention policy requires them.
  • Table filters match real incident questions.
  • Dashboard stats separate signature failures from processing failures.
  • Old delivery retention is documented.

The useful Filament panel is the one operators trust during an incident. Show the facts, protect dangerous actions, and keep processing in the queue.

FAQ

What is PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI?

PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI is a practical tooling topic that should be evaluated through implementation scope, production risk, testing, documentation, and long-term maintainability.

When should a team use PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI?

Use PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI when it solves a real project constraint, improves clarity, or reduces operational risk. Avoid it when it only adds novelty or hides behavior from future maintainers.

What is the biggest risk with PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI?

The biggest risk is copying a pattern without its context. Production systems need clear boundaries, rollback options, tests, and observability before a technique becomes dependable.

How do you test PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI?

Test the smallest unit that owns the behavior, then add integration coverage for the path users or systems actually rely on. Include failure cases, configuration differences, and regression checks.

How does PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI affect SEO and AI search visibility?

It improves visibility when the article gives a direct answer, expert context, structured headings, internal links, trustworthy references, and FAQ content that matches the visible page.

Conclusion

PHP Webhooks With Filament: Admin Panels and Webhook Monitoring UI is worth doing when the implementation improves clarity, reliability, or delivery speed. It is not worth doing when it hides ownership, increases operational risk, or makes the system harder to explain.

Use the framework above as a review checklist. Then connect this topic to the rest of the project documentation so readers can move from concept to implementation without losing context.

Top