Midtrans is one of the most popular payment gateways in Indonesia, supporting credit cards, bank transfers (virtual accounts), e-wallets like GoPay and ShopeePay, and QRIS. This tutorial shows a Midtrans Snap integration in Laravel from scratch: installing the SDK, creating a payment token, showing the payment popup, then handling the notification webhook to update order status correctly.
Before diving into code, it helps to understand the big picture. Midtrans works with a server-to-server model: your application never touches the customer's card data or e-wallet balance directly. Instead, your server requests a token from Midtrans, the user completes payment on Midtrans's own interface, and Midtrans notifies your server via a webhook when the status changes. This separation makes your app far safer and reduces your PCI-DSS compliance burden, because sensitive data never passes through your database.
1. Installing the Midtrans SDK
Install the official Midtrans package via Composer:
composer require midtrans/midtrans-php
Register on the Midtrans dashboard, then grab your Server Key and Client Key from Settings → Access Keys. During development use sandbox mode.
2. Storing Configuration in .env
Never hard-code keys. Store them in .env:
MIDTRANS_SERVER_KEY=SB-Mid-server-xxxxxxxxxxxx
MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxxxxxxxxxx
MIDTRANS_IS_PRODUCTION=false
Create a config/midtrans.php file to keep things tidy:
return [
'server_key' => env('MIDTRANS_SERVER_KEY'),
'client_key' => env('MIDTRANS_CLIENT_KEY'),
'is_production' => env('MIDTRANS_IS_PRODUCTION', false),
];
By placing the keys in config, you can call config('midtrans.server_key') anywhere without touching env() directly. This matters because once you run php artisan config:cache in production, any env() call outside a config file returns null. Distinguishing the Server Key from the Client Key is also crucial: the Server Key is secret and used only on the backend to create tokens and verify webhooks, while the Client Key may appear in HTML because it only loads the payment interface.
3. Creating a Snap Token
Snap is Midtrans's ready-made payment interface. The flow: your server creates a Snap token, then the frontend uses it to open a payment popup. The advantage of Snap is that you don't need to build a payment page for each method yourself; Midtrans provides a complete UI that already supports virtual accounts, e-wallets, cards, and QRIS at once. Create a controller to process checkout:
use Midtrans\Config;
use Midtrans\Snap;
class PaymentController extends Controller
{
public function __construct()
{
Config::$serverKey = config('midtrans.server_key');
Config::$isProduction = config('midtrans.is_production');
Config::$isSanitized = true;
Config::$is3ds = true;
}
public function pay(Order $order)
{
$params = [
'transaction_details' => [
'order_id' => $order->code,
'gross_amount' => (int) $order->total,
],
'customer_details' => [
'first_name' => $order->customer_name,
'email' => $order->customer_email,
],
];
$snapToken = Snap::getSnapToken($params);
return view('checkout', compact('order', 'snapToken'));
}
}
Important: order_id must be unique for every transaction. If you reuse the same ID twice, Midtrans rejects it. Use a unique order code, not a bare auto-increment ID that could repeat on retry.
4. Showing the Payment Popup with snap.js
In the checkout view, load Midtrans's snap.js script and call snap.pay() with the token:
<script src="https://app.sandbox.midtrans.com/snap/snap.js"
data-client-key="{{ config('midtrans.client_key') }}"></script>
<button id="pay-button">Pay Now</button>
<script>
document.getElementById('pay-button').onclick = function () {
snap.pay('{{ $snapToken }}', {
onSuccess: function (result) {
window.location.href = '/order/success';
},
onPending: function (result) {
window.location.href = '/order/pending';
},
onError: function (result) {
alert('Payment failed.');
}
});
};
</script>
For production, change the script URL to https://app.midtrans.com/snap/snap.js.
5. Handling the Notification Webhook
The JavaScript callbacks above are for UX only; never trust them to change payment status. The source of truth is the server-to-server notification webhook from Midtrans. Create a POST endpoint to receive it:
use Midtrans\Notification;
public function notification(Request $request)
{
Config::$serverKey = config('midtrans.server_key');
Config::$isProduction = config('midtrans.is_production');
$notif = new Notification();
$order = Order::where('code', $notif->order_id)->firstOrFail();
$status = $notif->transaction_status;
$fraud = $notif->fraud_status;
if ($status === 'capture' && $fraud === 'accept') {
$order->update(['status' => 'paid']);
} elseif ($status === 'settlement') {
$order->update(['status' => 'paid']);
} elseif (in_array($status, ['cancel', 'deny', 'expire'])) {
$order->update(['status' => 'failed']);
} elseif ($status === 'pending') {
$order->update(['status' => 'pending']);
}
return response()->json(['message' => 'OK']);
}
Register the route and exclude it from CSRF because the request comes from Midtrans's servers, not the user's browser:
Route::post('/midtrans/notification', [PaymentController::class, 'notification']);
In Laravel 11, exclude this URI in bootstrap/app.php via validateCsrfTokens(except: ['midtrans/*']). Finally, register this webhook URL in the Midtrans dashboard under Settings → Configuration → Payment Notification URL.
6. Security Practices You Must Follow
- Verify the signature: the
Notificationclass validates automatically against your server key, so always use the official SDK rather than reading the raw payload. - Re-check the amount: compare the notification's
gross_amountwith the order total in your database before marking it paid. - Be idempotent: webhooks can be sent multiple times. Make status updates safe to repeat without duplicating side effects (e.g. send the email only once).
- Always return HTTP 200: if you return an error, Midtrans keeps retrying the notification.
7. Testing and Debugging in Sandbox
When developing locally, Midtrans's webhook cannot reach localhost. Use a tunnel like Ngrok to expose your local server to the internet, then register that tunnel URL as the Payment Notification URL in the dashboard. To simulate payments, Midtrans provides test card numbers and status simulation buttons in the sandbox dashboard, so you can exercise the entire flow — success, pending, failed, and expired — without real money. Always log the incoming notification payload during development so you know exactly which fields Midtrans sends for each scenario.
You should also understand Midtrans's transaction statuses so your logic is correct. For card payments, the initial status is capture, which still needs its fraud_status checked. For other methods like virtual accounts or e-wallets, a paid state is marked settlement. A pending status means the user chose a method but hasn't paid yet, e.g. a VA number was issued but not transferred. Understanding this status map prevents the common mistake of marking an order paid too early.
With a Snap token from the server, a snap.js popup on the frontend, and the webhook as the source of truth, you have a secure, production-ready Midtrans integration. This pattern is identical for all payment methods because Snap unifies virtual accounts, e-wallets, and cards into one flow. Once this foundation works, you can extend it further by sending automatic receipt emails, showing a transaction history, or adding a re-payment feature for expired orders.