Programming Laravel PHP API 25 July 2025

Handling Webhook di Laravel: Tiga Cara dan Mana Yang Terbaik

Handling Webhook di Laravel: Tiga Cara dan Mana Yang Terbaik
Bagikan:

Webhook adalah salah satu konsep yang paling krusial dalam modern web development, terutama ketika kamu bekerja dengan third-party services seperti payment gateways, shipping providers, atau notification systems. Tapi handling webhook yang benar bukan sekadar “terima request dan proses” - ada complexity yang perlu diperhatikan, mulai dari security, performance, hingga reliability.

Di artikel ini, kita akan explore tiga pendekatan untuk handle webhook di Laravel, mulai dari yang paling basic hingga yang most recommended. Spoiler alert: method ketiga menggunakan package Spatie yang akan bikin hidup kamu jauh lebih mudah. But first, let’s understand the fundamentals.

Apa Itu Webhook dan Mengapa Penting?

Sebelum masuk ke implementation details, penting untuk understand konsep webhook secara mendalam. Webhook adalah HTTP callback yang user-defined - basically, itu adalah cara untuk notify external system bahwa event tertentu telah terjadi di aplikasi kamu.

Think of it seperti notification system antar aplikasi. Ketika customer kamu melakukan payment via Midtrans atau Xendit, mereka akan send HTTP request ke server kamu dengan payment details. Aplikasi kamu listen ke webhook tersebut, verify data authenticity, dan kemudian trigger appropriate actions seperti creating order, sending confirmation email, atau updating user subscription.

// Contoh payload webhook dari payment gateway
{
    "event": "payment.success",
    "transaction_id": "TXN_123456789",
    "amount": 150000,
    "currency": "IDR",
    "customer_email": "user@example.com",
    "status": "completed",
    "signature": "abc123def456..."
}

Webhook crucial karena provides real-time data synchronization tanpa perlu polling mechanisms yang resource-intensive. Instead of checking payment status every few seconds, payment gateway langsung kasih tau kamu when payment completed.

Karakteristik Webhook Yang Perlu Dipahami

Ada beberapa key characteristics yang perlu kamu understand sebelum implement webhook handling:

Asynchronous Nature: Webhook datang kapan saja, tidak predictable. Aplikasi kamu harus ready to handle incoming requests 24/7.

Security Critical: Webhook often carry sensitive data, jadi verification authenticity adalah mandatory. Most providers sign their webhooks using HMAC signatures.

Reliability Requirements: Webhook providers expect quick responses (biasanya dalam 2-5 detik). Kalau terlalu lama respond, mereka might consider it as failed dan retry.

Idempotency Concern: Same webhook bisa dikirim multiple times, jadi aplikasi kamu harus handle duplicate requests gracefully.

Method 1: Direct Route Handling

Approach pertama adalah yang paling straightforward - directly handle webhook request di controller. Mari kita implement basic webhook endpoint:

// routes/web.php
use App\Http\Controllers\WebhookController;

Route::post('webhook/payment', [WebhookController::class, 'handlePayment']);

Karena webhook providers nggak punya access ke CSRF token, kamu perlu exclude webhook routes dari CSRF verification:

// app/Http/Middleware/VerifyCsrfToken.php
<?php

namespace App\Http\Middleware;

use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;

class VerifyCsrfToken extends Middleware
{
    /**
     * The URIs that should be excluded from CSRF verification.
     */
    protected $except = [
        'webhook/*',
        'api/webhooks/*',
    ];
}

Sekarang create controller untuk handle webhook logic:

// app/Http/Controllers/WebhookController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class WebhookController extends Controller
{
    public function handlePayment(Request $request)
    {
        // Log incoming webhook for debugging
        Log::info('Webhook received', [
            'headers' => $request->headers->all(),
            'payload' => $request->all()
        ]);

        // Basic signature verification
        if (!$this->verifySignature($request)) {
            Log::warning('Invalid webhook signature');
            return response()->json(['error' => 'Invalid signature'], 401);
        }

        $payload = $request->all();
        
        // Process webhook based on event type
        switch ($payload['event']) {
            case 'payment.success':
                $this->handlePaymentSuccess($payload);
                break;
            case 'payment.failed':
                $this->handlePaymentFailed($payload);
                break;
            default:
                Log::info('Unhandled webhook event: ' . $payload['event']);
        }

        // Always return 200 OK to acknowledge receipt
        return response()->json(['status' => 'success'], 200);
    }

    private function verifySignature(Request $request): bool
    {
        $signature = $request->header('X-Signature');
        $payload = $request->getContent();
        $secret = config('services.payment_gateway.webhook_secret');
        
        $computed = hash_hmac('sha256', $payload, $secret);
        
        return hash_equals($signature, $computed);
    }

    private function handlePaymentSuccess(array $payload): void
    {
        // Update order status
        // Send confirmation email
        // Grant access to paid content
        // etc.
    }

    private function handlePaymentFailed(array $payload): void
    {
        // Handle failed payment logic
    }
}

Method ini works, tapi ada major problem: semua processing terjadi secara synchronous. Kalau logic kamu heavy (sending emails, complex database operations, API calls), webhook response akan jadi lambat dan potentially timeout.

Kenapa Direct Handling Bermasalah?

Direct handling approach punya several limitations yang serious:

Performance Bottleneck: Kalau webhook processing membutuhkan waktu lama, provider might timeout dan consider webhook as failed.

Resource Blocking: Heavy operations bisa block web server processes, affecting overall application performance.

Error Propagation: Kalau ada error dalam processing, entire webhook request akan fail, padahal kamu mungkin cuma butuh acknowledge receipt.

No Retry Mechanism: Kalau processing fail, kamu nggak punya built-in way untuk retry operation.

Bayangkan kalau webhook kamu perlu:

  • Query database dengan complex joins
  • Send multiple emails
  • Call external APIs
  • Process image uploads
  • Generate PDF reports

Semua ini bisa memakan waktu 10-30 detik, sementara webhook provider expect response dalam 2-5 detik. Recipe for disaster!

Method 2: Queue-Based Processing

Solution untuk performance problem di method 1 adalah queue-based processing. Instead of processing webhook immediately, kita push payload ke queue dan process asynchronously.

First, create job untuk handle webhook processing:

// Generate job class
php artisan make:job ProcessWebhookJob

Implement job logic:

// app/Jobs/ProcessWebhookJob.php
<?php

namespace App\Jobs;

use App\Models\Order;
use App\Models\User;
use App\Notifications\PaymentConfirmation;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;

class ProcessWebhookJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /**
     * The number of times the job may be attempted.
     */
    public $tries = 3;

    /**
     * The maximum number of seconds the job should be allowed to run.
     */
    public $timeout = 120;

    public function __construct(
        public array $payload,
        public string $eventType
    ) {
    }

    public function handle(): void
    {
        Log::info('Processing webhook job', [
            'event' => $this->eventType,
            'transaction_id' => $this->payload['transaction_id'] ?? null
        ]);

        try {
            switch ($this->eventType) {
                case 'payment.success':
                    $this->handlePaymentSuccess();
                    break;
                case 'payment.failed':
                    $this->handlePaymentFailed();
                    break;
                case 'subscription.activated':
                    $this->handleSubscriptionActivated();
                    break;
                default:
                    Log::warning('Unhandled webhook event in job', [
                        'event' => $this->eventType
                    ]);
            }
        } catch (\Exception $e) {
            Log::error('Webhook processing failed', [
                'event' => $this->eventType,
                'error' => $e->getMessage(),
                'payload' => $this->payload
            ]);
            
            // Re-throw to trigger job retry mechanism
            throw $e;
        }
    }

    private function handlePaymentSuccess(): void
    {
        $transactionId = $this->payload['transaction_id'];
        $amount = $this->payload['amount'];
        $customerEmail = $this->payload['customer_email'];

        // Find and update order
        $order = Order::where('transaction_id', $transactionId)->first();
        
        if (!$order) {
            Log::warning('Order not found for transaction', [
                'transaction_id' => $transactionId
            ]);
            return;
        }

        // Update order status
        $order->update([
            'status' => 'paid',
            'paid_at' => now(),
            'payment_method' => $this->payload['payment_method'] ?? null
        ]);

        // Send confirmation email
        $user = User::where('email', $customerEmail)->first();
        if ($user) {
            $user->notify(new PaymentConfirmation($order));
        }

        // Grant access to premium features
        if ($order->type === 'premium_subscription') {
            $user->update(['is_premium' => true]);
        }

        Log::info('Payment success processed', [
            'order_id' => $order->id,
            'transaction_id' => $transactionId
        ]);
    }

    private function handlePaymentFailed(): void
    {
        // Handle payment failure logic
        $transactionId = $this->payload['transaction_id'];
        
        $order = Order::where('transaction_id', $transactionId)->first();
        if ($order) {
            $order->update(['status' => 'failed']);
        }
    }

    private function handleSubscriptionActivated(): void
    {
        // Handle subscription activation
    }

    /**
     * Handle job failure.
     */
    public function failed(\Throwable $exception): void
    {
        Log::error('Webhook job permanently failed', [
            'event' => $this->eventType,
            'payload' => $this->payload,
            'error' => $exception->getMessage()
        ]);

        // You might want to send alert to admin here
    }
}

Update controller untuk dispatch job instead of direct processing:

// app/Http/Controllers/WebhookController.php
<?php

namespace App\Http\Controllers;

use App\Jobs\ProcessWebhookJob;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class WebhookController extends Controller
{
    public function handlePayment(Request $request)
    {
        // Quick signature verification
        if (!$this->verifySignature($request)) {
            Log::warning('Invalid webhook signature', [
                'ip' => $request->ip(),
                'user_agent' => $request->userAgent()
            ]);
            return response()->json(['error' => 'Invalid signature'], 401);
        }

        $payload = $request->all();
        $eventType = $payload['event'] ?? 'unknown';

        // Dispatch job for background processing
        ProcessWebhookJob::dispatch($payload, $eventType);

        // Immediately acknowledge receipt
        return response()->json(['status' => 'accepted'], 200);
    }

    private function verifySignature(Request $request): bool
    {
        $signature = $request->header('X-Signature');
        
        if (!$signature) {
            return false;
        }

        $payload = $request->getContent();
        $secret = config('services.payment_gateway.webhook_secret');
        
        $computed = hash_hmac('sha256', $payload, $secret);
        
        return hash_equals($signature, $computed);
    }
}

Setup queue configuration di .env:

QUEUE_CONNECTION=database
# atau redis untuk better performance
# QUEUE_CONNECTION=redis

Create jobs table dan run migration:

php artisan queue:table
php artisan migrate

Start queue worker:

php artisan queue:work --tries=3 --timeout=90
Keunggulan Queue-Based Approach

Queue-based processing solve major issues dari direct handling:

Fast Response Time: Controller immediately acknowledge webhook receipt tanpa wait for processing completion.

Background Processing: Heavy operations run di background tanpa block main application flow.

Retry Mechanism: Built-in retry logic untuk handle temporary failures.

Monitoring: Kamu bisa monitor job status, failed jobs, dan performance metrics.

Scalability: Bisa scale queue workers independently based on workload.

Tapi masih ada room for improvement, especially untuk signature verification dan webhook management yang more sophisticated.

Method terbaik adalah menggunakan Spatie Laravel Webhook Client package. Package ini provide comprehensive solution untuk webhook handling dengan built-in features yang biasanya kamu butuhkan.

Install package via Composer:

composer require spatie/laravel-webhook-client

Publish configuration file:

php artisan vendor:publish --provider="Spatie\WebhookClient\WebhookClientServiceProvider" --tag="webhook-client-config"

Publish migration untuk webhook storage:

php artisan vendor:publish --provider="Spatie\WebhookClient\WebhookClientServiceProvider" --tag="webhook-client-migrations"

php artisan migrate

Configure webhook di config/webhook-client.php:

<?php

return [
    'configs' => [
        [
            'name' => 'midtrans',
            'signing_secret' => env('MIDTRANS_WEBHOOK_SECRET'),
            'signature_header_name' => 'X-Midtrans-Signature',
            'signature_validator' => App\Webhooks\MidtransSignatureValidator::class,
            'webhook_profile' => \Spatie\WebhookClient\WebhookProfile\ProcessEverythingWebhookProfile::class,
            'webhook_response' => \Spatie\WebhookClient\WebhookResponse\DefaultRespondsTo::class,
            'webhook_model' => \Spatie\WebhookClient\Models\WebhookCall::class,
            'process_webhook_job' => App\Jobs\ProcessMidtransWebhookJob::class,
        ],
    ],
    'delete_after_days' => 30,
];

Create custom signature validator:

// app/Webhooks/MidtransSignatureValidator.php
<?php

namespace App\Webhooks;

use Illuminate\Http\Request;
use Spatie\WebhookClient\SignatureValidator\SignatureValidator;
use Spatie\WebhookClient\WebhookConfig;
use Spatie\WebhookClient\Exceptions\WebhookFailed;

class MidtransSignatureValidator implements SignatureValidator
{
    public function isValid(Request $request, WebhookConfig $config): bool
    {
        $signature = $request->header($config->signatureHeaderName);
        
        if (!$signature) {
            return false;
        }

        $signingSecret = $config->signingSecret;
        
        if (empty($signingSecret)) {
            throw WebhookFailed::signingSecretNotSet();
        }

        // Midtrans uses SHA512 HMAC
        $computedSignature = hash_hmac('sha512', $request->getContent(), $signingSecret);
        
        return hash_equals($signature, $computedSignature);
    }
}

Create webhook processing job:

// app/Jobs/ProcessMidtransWebhookJob.php
<?php

namespace App\Jobs;

use App\Models\Order;
use App\Models\Transaction;
use Illuminate\Support\Facades\Log;
use Spatie\WebhookClient\Jobs\ProcessWebhookJob;

class ProcessMidtransWebhookJob extends ProcessWebhookJob
{
    public function handle(): void
    {
        $payload = json_decode($this->webhookCall->payload, true);
        
        Log::info('Processing Midtrans webhook', [
            'webhook_id' => $this->webhookCall->id,
            'transaction_status' => $payload['transaction_status'] ?? 'unknown'
        ]);

        $transactionStatus = $payload['transaction_status'];
        $orderId = $payload['order_id'];
        $transactionId = $payload['transaction_id'];

        switch ($transactionStatus) {
            case 'capture':
            case 'settlement':
                $this->handleSuccessfulPayment($payload);
                break;
                
            case 'pending':
                $this->handlePendingPayment($payload);
                break;
                
            case 'deny':
            case 'cancel':
            case 'expire':
                $this->handleFailedPayment($payload);
                break;
                
            default:
                Log::warning('Unhandled Midtrans transaction status', [
                    'status' => $transactionStatus,
                    'order_id' => $orderId
                ]);
        }
    }

    private function handleSuccessfulPayment(array $payload): void
    {
        $orderId = $payload['order_id'];
        $transactionId = $payload['transaction_id'];
        $grossAmount = $payload['gross_amount'];

        // Find order
        $order = Order::where('external_id', $orderId)->first();
        
        if (!$order) {
            Log::error('Order not found for Midtrans webhook', [
                'order_id' => $orderId,
                'transaction_id' => $transactionId
            ]);
            return;
        }

        // Prevent duplicate processing
        if ($order->status === 'paid') {
            Log::info('Order already processed', ['order_id' => $order->id]);
            return;
        }

        // Create transaction record
        Transaction::create([
            'order_id' => $order->id,
            'external_id' => $transactionId,
            'amount' => $grossAmount,
            'status' => 'success',
            'payment_method' => $payload['payment_type'] ?? null,
            'raw_response' => json_encode($payload)
        ]);

        // Update order
        $order->update([
            'status' => 'paid',
            'paid_at' => now()
        ]);

        // Trigger post-payment actions
        $this->triggerPostPaymentActions($order);

        Log::info('Midtrans payment processed successfully', [
            'order_id' => $order->id,
            'transaction_id' => $transactionId
        ]);
    }

    private function handlePendingPayment(array $payload): void
    {
        // Handle pending payment logic
    }

    private function handleFailedPayment(array $payload): void
    {
        // Handle failed payment logic
    }

    private function triggerPostPaymentActions(Order $order): void
    {
        // Send confirmation email, grant access, etc.
    }
}

Setup route menggunakan webhook macro:

// routes/web.php
Route::webhooks('webhooks/midtrans');

Package ini automatically handle:

  • Request validation
  • Signature verification
  • Payload storage
  • Queue job dispatching
  • Error handling dan logging
Keunggulan Spatie Webhook Package

Package ini provide numerous advantages dibanding manual implementation:

Automatic Storage: Semua webhook calls automatically stored di database untuk auditing dan debugging.

Built-in Security: Comprehensive signature validation dengan customizable validators.

Queue Integration: Seamless integration dengan Laravel queue system.

Multiple Webhooks: Support multiple webhook endpoints dengan different configurations.

Automatic Cleanup: Old webhook records automatically deleted based on configuration.

Extensive Logging: Built-in logging untuk debugging dan monitoring.

Error Handling: Robust error handling dengan retry mechanisms.

Advanced Configuration dan Best Practices

Untuk production usage, ada several advanced configurations yang perlu diperhatikan:

Environment-specific Configuration:

// config/webhook-client.php
'configs' => [
    [
        'name' => 'payment_gateway',
        'signing_secret' => env('WEBHOOK_SECRET'),
        'signature_header_name' => env('WEBHOOK_SIGNATURE_HEADER', 'X-Signature'),
        'webhook_profile' => env('APP_ENV') === 'testing' 
            ? \Spatie\WebhookClient\WebhookProfile\ProcessNothingWebhookProfile::class
            : \Spatie\WebhookClient\WebhookProfile\ProcessEverythingWebhookProfile::class,
        // ... other configs
    ],
],

Custom Webhook Profile untuk Filtering:

// app/Webhooks/PaymentWebhookProfile.php
<?php

namespace App\Webhooks;

use Illuminate\Http\Request;
use Spatie\WebhookClient\WebhookProfile\WebhookProfile;

class PaymentWebhookProfile implements WebhookProfile
{
    public function shouldProcess(Request $request): bool
    {
        $payload = json_decode($request->getContent(), true);
        
        // Only process payment-related events
        $allowedEvents = [
            'payment.success',
            'payment.failed',
            'subscription.activated',
            'subscription.cancelled'
        ];
        
        return in_array($payload['event'] ?? '', $allowedEvents);
    }
}

Rate Limiting untuk Webhook Endpoints:

// routes/web.php
Route::middleware(['throttle:webhook'])->group(function () {
    Route::webhooks('webhooks/payment');
});

// app/Http/Kernel.php
protected $middlewareGroups = [
    'webhook' => [
        'throttle:60,1', // 60 requests per minute
    ],
];

Monitoring dan Alerting:

// app/Jobs/ProcessWebhookJob.php
public function failed(\Throwable $exception): void
{
    // Send alert to monitoring service
    \Sentry\captureException($exception);
    
    // Send Slack notification
    \Notification::route('slack', config('services.slack.webhook_url'))
        ->notify(new WebhookProcessingFailed($this->webhookCall, $exception));
}
Security Best Practices

Webhook security adalah critical aspect yang nggak boleh diremehkan:

Always Verify Signatures: Jangan pernah skip signature verification, even di development environment.

Use HTTPS Only: Webhook endpoints harus selalu menggunakan HTTPS untuk prevent man-in-the-middle attacks.

Implement Rate Limiting: Protect endpoints dari abuse dengan proper rate limiting.

IP Whitelisting: Kalau provider support IP whitelisting, always use it.

Audit Logging: Log semua webhook requests untuk forensic analysis.

Environment Isolation: Use different webhook endpoints untuk different environments.

Dengan understanding yang comprehensive tentang webhook handling di Laravel, kamu bisa build robust integrations yang reliable, secure, dan performant. Spatie package provide excellent foundation, tapi understanding underlying concepts tetap penting untuk troubleshooting dan customization yang advanced.