Back to articles

Refactoring Payment Webhooks in Laravel: From a 600-Line Controller to Clean Architecture

August 29, 20265 min read
PHP
Laravel
Architecture
Design Patterns
Fintech

Webhooks are fundamentally asynchronous events, yet backend systems frequently treat them as synchronous HTTP scripts.

When a payment gateway like Paystack notifies an API of a successful charge, wallet top-up, or hauler payout transfer, running cryptographic validation, database mutations, double-entry ledger bookkeeping, and notification dispatches inside a single HTTP controller introduces critical failure modes:

  1. Gateway Timeout Cascades: If a database lock or downstream service slows down the request, the gateway times out and resends the webhook—triggering cascading retries that saturate application workers.
  2. Concurrency Hazards: Multiple duplicate webhook retries arriving in parallel can cause race conditions, resulting in double-crediting wallets or duplicate payouts.
  3. Violations of SOLID: A 600+ line “God Controller” that mixes signature verification, routing logic, domain execution, and logging becomes fragile and difficult to test.
  4. PII Exposure in Logs: Logging raw webhook payloads risks leaking customer emails, phone numbers, and payment credentials.

Here is how we completely re-architected our Paystack webhook infrastructure in Laravel by implementing the Transactional Inbox Pattern, dedicated HMAC Middleware, and Factory / Strategy / Action design patterns.


1. The Legacy Implementation: The 600-Line Monolith

Initially, a single PaystackPaymentWebhookController handled everything procedurally:

[Paystack] ──POST──> [ Controller (608 Lines) ]
                       ├── Validate HMAC
                       ├── DB: Find & Update Invoice
                       ├── DB: Lock & Credit Wallet Ledger
                       ├── DB: Update Hauler Transfer Status
                       ├── Dispatch Notification Jobs
                       └── Log Raw Payload

Because every operation executed synchronously before returning an HTTP response, any database delay caused Paystack to retry the request. If two retries were processed concurrently, both threads could evaluate the top-up as “pending” and credit the wallet twice.


2. Security at the Edge: HMAC Signature Middleware

Cryptographic validation does not belong inside controllers. We extracted the SHA-512 HMAC signature verification into a dedicated HTTP middleware (VerifyPaystackWebhookSignature).

Invalid or tampered payloads are rejected at the application boundary before any controller or database layer is initialized:

declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;

class VerifyPaystackWebhookSignature
{
    public function handle(Request $request, Closure $next): Response
    {
        $secretKey = (string) config('services.payments.processors.paystack.secret_key', '');
        $signature = hash_hmac('sha512', $request->getContent(), $secretKey);

        if (!hash_equals((string) $request->header('x-paystack-signature'), $signature)) {
            Log::alert("The expected x-paystack-signature isn't present or does not match.", [
                'signature_present' => $request->hasHeader('x-paystack-signature'),
            ]);

            return new JsonResponse(['message' => 'Invalid signature'], 404);
        }

        return $next($request);
    }
}

3. The Transactional Inbox & Ultra-Lean Controller

To eliminate gateway timeouts, we adopted the Transactional Inbox Pattern. The controller now has only two jobs:

  1. Atomically persist the raw payload into a paystack_webhooks inbox table.
  2. Dispatch a queued background job (ProcessPaystackWebhookJob) and immediately return HTTP 200 OK.

The controller shrunk from 608 lines to 34 lines:

declare(strict_types=1);

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use App\Jobs\ProcessPaystackWebhookJob;
use App\Support\Logging\SafeLogContext;
use App\Actions\Payments\Paystack\RecordPaystackWebhookAction;

class PaystackPaymentWebhookController extends Controller
{
    public function __invoke(Request $request): JsonResponse
    {
        $payload = json_decode($request->getContent());

        Log::info('Received paystack webhook payload.', [
            ...SafeLogContext::paystackWebhook($payload),
        ]);

        $webhook = new RecordPaystackWebhookAction()->execute($payload);

        if (!$webhook->isProcessed()) {
            ProcessPaystackWebhookJob::dispatch($webhook->id);
        }

        return new JsonResponse(['status' => 'ok'], 200);
    }
}

Atomic Ingestion with Database Constraints

RecordPaystackWebhookAction handles duplicate deliveries cleanly using unique composite constraints on ['event_type', 'reference', 'refund_reference']:

public function execute(object $payload): PaystackWebhook
{
    $attributes = [
        'event_type' => (string) ($payload->event ?? 'unknown'),
        'reference' => (string) ($payload->data->reference ?? $payload->data->transaction_reference ?? ''),
        'refund_reference' => (string) ($payload->data->refund_reference ?? ''),
    ];

    $values = [
        'status' => (string) ($payload->data->status ?? 'unknown'),
        'amount' => (float) ($payload->data->amount ?? 0),
        'channel' => $payload->data->channel ?? null,
        'customer_email' => $payload->data->customer->email ?? null,
        'payload' => json_decode(json_encode($payload), true),
    ];

    try {
        return PaystackWebhook::query()->firstOrCreate($attributes, $values);
    } catch (UniqueConstraintViolationException|QueryException $e) {
        $existing = PaystackWebhook::query()->where($attributes)->first();

        if ($existing !== null) {
            return $existing;
        }

        throw $e;
    }
}

4. Event Routing via Factory & Strategy Patterns

In the asynchronous worker, we route webhook events to dedicated handler strategies instead of maintaining a giant switch or if/else ladder.

The Strategy Interface & Template Method

Every handler implements PaystackWebhookHandler. An abstract base class encapsulates idempotency checks and marks the webhook as processed upon completion:

interface PaystackWebhookHandler
{
    public function handle(WebhookContext $context): void;
}

abstract class AbstractPaystackWebhookHandler implements PaystackWebhookHandler
{
    public function handle(WebhookContext $context): void
    {
        // Guard against duplicate execution in concurrent queue workers
        if ($context->webhook->isProcessed()) {
            Log::info('Webhook already processed, skipping duplicate', [
                'event' => $context->webhook->event_type,
                'reference' => SafeLogContext::paymentReference($context->webhook->reference),
            ]);

            return;
        }

        $this->process($context);

        if (!$context->webhook->isProcessed()) {
            $context->webhook->markAsProcessed();
        }
    }

    abstract protected function process(WebhookContext $context): void;
}

The Handler Factory

PaystackWebhookHandlerFactory resolves the appropriate strategy dynamically:

class PaystackWebhookHandlerFactory
{
    public function getHandlerClass(string $event, ?string $status = null): string
    {
        return match ($event) {
            PaymentWebhookEventEnum::REFUND_PROCESSED->value => RefundProcessedHandler::class,
            PaymentWebhookEventEnum::REFUND_PENDING->value,
            PaymentWebhookEventEnum::REFUND_FAILED->value => RefundLifecycleHandler::class,
            PaymentWebhookEventEnum::SUCCESSFUL_TRANSFER->value => SuccessfulTransferHandler::class,
            PaymentWebhookEventEnum::FAILED_TRANSFER->value => FailedTransferHandler::class,
            PaymentWebhookEventEnum::SUCCESSFUL_CHARGE->value => $status === 'success'
                ? SuccessfulChargeHandler::class
                : FailedChargeHandler::class,
            default => str_starts_with($event, 'charge.') ? FailedChargeHandler::class : NoOpHandler::class,
        };
    }

    public function make(WebhookContext $context): PaystackWebhookHandler
    {
        $event = (string) ($context->payload->event ?? $context->webhook->event_type);
        $status = $context->payload->data->status ?? $context->webhook->status;

        $handlerClass = $this->getHandlerClass($event, $status ? (string) $status : null);

        return app($handlerClass);
    }
}

5. Domain Isolation & Pessimistic Ledger Locking

Specific business operations are encapsulated inside dedicated Action classes. For wallet top-ups, we acquire a pessimistic database lock (lockForUpdate()) within a transaction to guarantee that simultaneous webhook jobs cannot credit a customer twice:

class CreditWalletFromTopUpWebhookAction
{
    public function __construct(
        private readonly WalletLedgerService $walletLedgerService,
    ) {}

    public function execute(WalletTopUp $topUp, object $payload): ?WalletTransaction
    {
        $ledgerEntry = DB::transaction(function () use ($topUp, $payload) {
            // Lock the top-up row against concurrent workers
            $lockedTopUp = WalletTopUp::query()
                ->whereKey($topUp->id)
                ->with('wallet.organisation')
                ->lockForUpdate()
                ->firstOrFail();

            if ($lockedTopUp->status === PaymentStatus::SUCCESSFUL->value) {
                return WalletTransaction::query()->where('reference', $lockedTopUp->reference)->first();
            }

            $channel = ucwords(str_replace('_', ' ', (string) ($payload->data->channel ?? 'Unknown')));

            $lockedTopUp->update([
                'channel' => $channel,
                'status' => PaymentStatus::SUCCESSFUL->value,
                'processor_transaction_reference' => $payload->data->reference ?? null,
            ]);

            return $this->walletLedgerService->credit($lockedTopUp->wallet, [
                'amount' => $lockedTopUp->amount,
                'currency' => $lockedTopUp->currency,
                'channel' => $lockedTopUp->channel,
                'reference' => $lockedTopUp->reference,
                'processor' => $lockedTopUp->processor,
                'description' => $lockedTopUp->description,
            ]);
        });

        if ($ledgerEntry) {
            $this->createWalletTopUpTimelineActivity($ledgerEntry->load('wallet.organisation'));
        }

        return $ledgerEntry;
    }
}

6. PII Masking & Safe Observability

To prevent sensitive customer information and card credentials from leaking into monitoring tools, we built SafeLogContext:

class SafeLogContext
{
    public static function paystackWebhook(object $payload): array
    {
        return [
            'event' => $payload->event ?? null,
            'reference' => self::paymentReference($payload->data->reference ?? null),
            'transfer_code' => self::transferCode($payload->data->transfer_code ?? null),
            'status' => $payload->data->status ?? null,
            'amount' => $payload->data->amount ?? null,
            'customer_email' => self::email($payload->data->customer->email ?? null),
        ];
    }

    public static function email(?string $email): ?string
    {
        if (!$email || !str_contains($email, '@')) return $email;
        [$local, $domain] = explode('@', $email, 2);
        return mb_substr($local, 0, min(2, mb_strlen($local))) . '***@' . $domain;
    }

    public static function paymentReference(?string $reference): ?string
    {
        if (!$reference) return $reference;
        return mb_strlen($reference) <= 8
            ? mb_substr($reference, 0, 1) . '***' . mb_substr($reference, -1)
            : mb_substr($reference, 0, 6) . '...' . mb_substr($reference, -4);
    }
}

Benefits of this Approach

  1. Constant-Time Sub-50ms Responses: The HTTP endpoint acknowledges Paystack instantly, eliminating webhook timeout retries.
  2. Guaranteed Idempotency: Atomic database deduplication combined with pessimistic row locking (lockForUpdate()) ensures zero duplicate credits or double payouts.
  3. Open/Closed Extensibility: Adding support for a new event (e.g. disputes, subaccount splits) requires only creating a new handler class and registering it in the factory, without touching existing payment paths.
  4. Focused Unit & Feature Testing: Every component (middleware, factory, handlers, and domain actions) is independently testable without complex end-to-end database setups.