Refactoring Payment Webhooks in Laravel: From a 600-Line Controller to Clean Architecture
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:
- 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.
- Concurrency Hazards: Multiple duplicate webhook retries arriving in parallel can cause race conditions, resulting in double-crediting wallets or duplicate payouts.
- Violations of SOLID: A 600+ line “God Controller” that mixes signature verification, routing logic, domain execution, and logging becomes fragile and difficult to test.
- 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:
- Atomically persist the raw payload into a
paystack_webhooksinbox table. - Dispatch a queued background job (
ProcessPaystackWebhookJob) and immediately returnHTTP 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
- Constant-Time Sub-50ms Responses: The HTTP endpoint acknowledges Paystack instantly, eliminating webhook timeout retries.
- Guaranteed Idempotency: Atomic database deduplication combined with pessimistic row locking (
lockForUpdate()) ensures zero duplicate credits or double payouts. - 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.
- Focused Unit & Feature Testing: Every component (middleware, factory, handlers, and domain actions) is independently testable without complex end-to-end database setups.