sawirricardo/laravel-midtrans
A Laravel package for integrating Midtrans payments. Configure credentials, create Snap transactions, handle callbacks and webhooks, and support common payment flows from your Laravel app with a simple, framework-friendly API.
What this package is: a thin Laravel wrapper around Midtrans, the Indonesian payment gateway (Snap, Core API, notifications). With 12 stars, a single 2023 release, and a 0.48 score, treat it as a convenience binding, not a battle-tested abstraction. Budget time to read its source before you build on it.
Minimal steps
Install:
composer require sawirricardo/laravel-midtrans
It pulls in midtrans/midtrans-php as the underlying SDK — that SDK is where most real behaviour lives.
Publish config (verify the tag in the service provider first):
php artisan vendor:publish --provider="Sawirricardo\LaravelMidtrans\MidtransServiceProvider"
Set environment variables for sandbox first:
MIDTRANS_SERVER_KEY=SB-Mid-server-xxxxxxxx
MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxxxxxx
MIDTRANS_IS_PRODUCTION=false
Where to look first, in this order:
config/midtrans.php — the actual knobs (keys, sandbox flag, base URLs).Midtrans facade and its underlying service class — this is your entire API surface. Enumerate the public methods; they are almost certainly a subset of the Midtrans SDK. Anything missing, call the SDK directly.First use case — Snap checkout:
order_id (Midtrans requires uniqueness per merchant).snap.js popup) on the frontend.The exact facade call varies; confirm it in the facade source. The shape is:
// Confirm method name in src/Facades/Midtrans.php — the package is a thin passthrough.
$snapToken = Midtrans::createSnapTransaction([
'transaction_details' => [
'order_id' => $order->code,
'gross_amount' => (int) $order->total, // IDR, integer
],
]);
Snap token + webhook (recommended default). Let Midtrans host the payment UI; you only create a token and react to notifications. Fewer PCI concerns, less UI work, and the notification payload contains everything you need (order_id, transaction_status, fraud_status, gross_amount, signature_key).
Order state machine, not status strings. Midtrans statuses (pending, capture, settlement, deny, cancel, expire, failure, refund, partial_refund, chargeback) should map to your domain states. Keep a single PaymentStatus enum and one applyNotification() action so webhook and manual reconciliation share logic:
// app/Actions/ApplyMidtransNotification.php
public function handle(array $payload): void
{
DB::transaction(function () use ($payload) {
$order = Order::where('code', $payload['order_id'])->lockForUpdate()->firstOrFail();
if (! $this->signatureIsValid($payload)) {
abort(403);
}
// Notifications can arrive out of order and more than once.
if ($order->midtrans_status === $payload['transaction_status']) {
return;
}
$order->update([
'midtrans_status' => $payload['transaction_status'],
'midtrans_transaction_id'=> $payload['transaction_id'],
'paid_at' => in_array($payload['transaction_status'], ['capture', 'settlement'], true)
&& ($payload['fraud_status'] ?? 'accept') === 'accept'
? now() : null,
]);
});
}
Verify signature yourself. Don't assume the package does it. The canonical check is:
$expected = hash('sha512',
$payload['order_id'] . $payload['status_code'] . $payload['gross_amount'] . config('midtrans.server_key')
);
hash_equals($expected, $payload['signature_key'] ?? '');
Never trust the webhook body alone for money movement. On settlement, optionally call the status endpoint to confirm before releasing goods — cheap insurance against spoofed or replayed payloads, especially if your signature check is wrong.
Service-layer wrapper. Bind your own PaymentGateway interface so tests and future gateway swaps don't touch the vendor package:
// app/Contracts/PaymentGateway.php
interface PaymentGateway { public function createToken(Order $order): string; }
// app/Services/MidtransGateway.php
final class MidtransGateway implements PaymentGateway { /* delegates to the package */ }
Then swap implementations in AppServiceProvider and fake it in tests. This is essential with a 12-star package — you want it isolated to one class.
Core API charge is available if you want a fully custom checkout (card, VA, e-wallet via direct charge). Only reach for it when Snap's UI doesn't fit; you then own tokenisation, 3DS handling, and card-data scope.
Local development. Midtrans can't call localhost. Use ngrok/expose to get a public HTTPS URL, register it as the Payment Notification URL, and add a signature-guarded artisan command to replay captured payloads (php artisan midtrans:replay storage/app/notifications/*.json). That command pays for itself fast.
Testing. Midtrans provides a sandbox simulator; combine it with your own FakePaymentGateway binding for the 90% of tests that shouldn't hit the network.
midtrans/midtrans-php directly in one file. Midtrans' SDK is stable; the wrapper is the fragile part.gross_amount is IDR and must be an integer in practice. Passing 10000.00 floats or formatted strings ("Rp 10.000") causes cryptic validation errors. Store cents/full IDR as integers in the DB too.php artisan config:cache will freeze your keys. If production shows sandbox behaviour, php artisan config:clear and check the env names match config/midtrans.php exactly.isProduction flag switches Snap/Core endpoints. Mixing a production key with isProduction=false gives confusing auth failures, not a clear error.finish redirect fires even when the payment fails or the user abandons. Never mark paid from the redirect — only from a verified notification.lockForUpdate (or a unique constraint on transaction_id + a processed-events table) and make the handler idempotent. Midtrans retries until it gets a 2xx.order_id reuse is forbidden. Generate it per attempt or append an attempt suffix; reusing a paid order number will be rejected and confuse reconciliation.transaction_status=capture with fraud_status=challenge means not yet paid. Only accept (with capture/settlement) is settled.Midtrans:: methods directly; don't fork the wrapper for small gaps. Where you need custom behaviour — signing, retries, logging, multi-merchant — write it in your own service and call the package only for transport.storage/logs/laravel.log? (2) is the signature check passing? (3) does the status endpoint show settlement? (4) did your signature compare use hash_equals and the unformatted gross_amount from the payload? That checklist resolves most incidents.How can I help you explore Laravel packages today?