khumam / laravel-midtrans
Laravel Midtrans payment gateway integration package
Requires
- php: ^8.2
- guzzlehttp/guzzle: ^7.0
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/routing: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0
- phpunit/phpunit: ^11.0
README
Laravel package for Midtrans payment gateway integration. Supports Laravel 11, 12, and 13.
Inspired by Laravel Cashier (Paddle). Simplified flow: checkout and webhook handling.
Installation
composer require khumam/laravel-midtrans
Publish config and migrations:
php artisan vendor:publish --tag=midtrans-config php artisan vendor:publish --tag=midtrans-migrations php artisan migrate
Add to .env:
MIDTRANS_SERVER_KEY=SB-Mid-server-xxxx MIDTRANS_IS_SANDBOX=true
Setup
Add the Billable trait to your User model (or any billable model):
use Khumam\Midtrans\Billable; class User extends Model { use Billable; }
Usage
One-Time Payment
use Khumam\Midtrans\Billable; // In your controller public function pay(Request $request) { return $request->user() ->checkout(50000) ->withItemDetail([ ['id' => 'item1', 'price' => 50000, 'quantity' => 1, 'name' => 'Product A'], ]) ->secureCreditCard() ->redirectTo('payment.success'); }
The checkout() method:
- Cancels any existing pending transactions for the user
- Creates a new transaction record
- Calls the Midtrans Snap API
- Redirects user to the Midtrans payment page
Subscription
use Khumam\Midtrans\Enums\MidtransPeriod; // Monthly subscription return $user->subscribe(100000, MidtransPeriod::Monthly) ->withSubscriptionSchedule([ 'interval' => 1, 'interval_unit' => 'month', ]) ->redirectTo('subscription.success'); // Annual subscription return $user->subscribe(1000000, MidtransPeriod::Annually) ->redirectTo('subscription.success');
Available periods:
| Period | Enum Value |
|---|---|
| Monthly | MidtransPeriod::Monthly |
| Quarterly | MidtransPeriod::Quarterly |
| Semi-Annually | MidtransPeriod::SemiAnnually |
| Annually | MidtransPeriod::Annually |
Check Subscription Status
// Check if user has active subscription if ($user->subscribed()) { // User is subscribed } // Get the active subscription transaction $subscription = $user->subscription(); // Returns Transaction model or null
Checkout Builder Methods
All methods below are chainable on the Checkout object returned by checkout() or subscribe().
Payment Options
->secureCreditCard() // Enable 3DS/secure credit card ->withCreditCard([ // Custom credit card config 'token_id' => 'xxx', 'bank' => 'bni', 'installment_term' => 3, ]) ->withBankTransfer([ // Bank transfer (VA) 'bank' => 'bca', 'va_number' => '1234567890', ]) ->withGopay([ // GoPay config 'enable_callback' => true, 'callback_url' => 'https://example.com/callback', ]) ->withQris([ // QRIS config 'acquirer' => 'gopay', ])
Transaction Details
->withItemDetail([ ['id' => 'item1', 'price' => 50000, 'quantity' => 1, 'name' => 'Product A'], ['id' => 'item2', 'price' => 25000, 'quantity' => 2, 'name' => 'Product B'], ]) ->withCustomerDetail([ // Override customer details 'first_name' => 'John', 'last_name' => 'Doe', 'email' => 'john@example.com', 'phone' => '08123456789', 'billing_address' => [ 'first_name' => 'John', 'address' => 'Jl. Sudirman No. 1', 'city' => 'Jakarta', 'postal_code' => '12190', 'country_code' => 'IDN', ], ]) ->withCustomExpiry([ // Custom expiration 'expiry_duration' => '60', 'unit' => 'minute', ])
Default customer details are auto-populated from the billable model's name and email fields. Override with custom field names:
$user->checkout(50000, [ 'name_field' => 'full_name', 'email_field' => 'email_address', ]);
Subscription Schedule
->withSubscriptionSchedule([ 'interval' => 1, 'interval_unit' => 'month', 'max_interval' => 12, ])
Tax
->withTax(5000) // Fixed tax: adds 5000 to gross_amount ->withTaxPercentage(11) // Percentage tax: 11% of gross_amount
withTax(float $taxPrice)— adds fixed tax amount togross_amount, storestax_amount, adds a "Tax" item detailwithTaxPercentage(float $percent)— calculatesgross_amount * percent / 100, adds togross_amount, storestax_amountandtax_percentage, adds a "Tax" item detail
Finalize
->redirectTo('route.name') // Redirects user to Midtrans Snap payment page ->getRedirectUrl() // Returns the Snap redirect URL as string (no redirect)
Use getRedirectUrl() when you need the URL without auto-redirecting (SPA, API responses, etc.):
// Return URL as JSON for frontend $url = $user->checkout(50000)->getRedirectUrl(); return response()->json(['payment_url' => $url]); // Or redirect manually return redirect($user->checkout(50000)->getRedirectUrl());
Webhook
The package automatically registers a webhook endpoint at POST /midtrans/webhook.
Configure this URL in your Midtrans Dashboard under Settings > Payment Notification URL:
https://yourdomain.com/midtrans/webhook
Webhook Flow
- Receives POST from Midtrans
- Validates signature:
SHA512(order_id + status_code + gross_amount + server_key) - Updates transaction status
- Stores full response in
midtrans_transaction_responsestable
Transaction Statuses
| Status | Meaning |
|---|---|
capture |
Card payment captured successfully. Funds received. |
settlement |
Transaction settled. Funds credited to account. |
pending |
Awaiting customer payment. |
deny |
Payment rejected by provider or fraud system. |
cancel |
Transaction cancelled. |
expire |
Payment window expired. |
failure |
Unexpected error during processing. |
refund |
Full refund issued. |
partial_refund |
Partial refund issued. |
authorize |
Card pre-authorized (advanced feature). |
Transaction Model
use Khumam\Midtrans\Models\Transaction; $transaction = Transaction::where('order_id', $orderId)->first(); $transaction->isPaid(); // true if capture or settlement $transaction->isPending(); // true if pending $transaction->isFailed(); // true if deny/cancel/expire/failure $transaction->isRefunded(); // true if refund or partial_refund $transaction->onGracePeriod(3); // true if ends_at passed < 3 days ago $transaction->billable; // The User model (morph relationship) $transaction->responses; // All webhook responses (HasMany) $transaction->latestResponse; // Latest webhook response (HasOne) $transaction->items; // Transaction items (HasMany TransactionItem) $transaction->tax_amount; // Tax amount (decimal) $transaction->tax_percentage; // Tax percentage (decimal)
Transaction Items
When you use withItemDetail(), items are persisted and linked to the transaction. Items cascade delete with the parent transaction.
use Khumam\Midtrans\Models\TransactionItem; $transaction->items; // Collection of TransactionItem $transaction->items->first()->name; // 'Product A' $item = TransactionItem::find(1); $item->transaction; // BelongsTo Transaction
Note: Must be an indexed array of items (
[[...], [...]]), not a single associative array.
Customers
Customer details are automatically upserted when withCustomerDetail() is called (happens by default on every checkout). One customer record per billable model — subsequent checkouts update the existing record.
use Khumam\Midtrans\Models\Customer; $user->midtransCustomer; // Customer model or null $customer = Customer::find(1); $customer->billable; // The User model (morph relationship)
Testing
composer test
Or directly:
vendor/bin/phpunit
Writing Tests
This package uses Orchestra Testbench for Laravel package testing. Extend the base TestCase:
namespace Khumam\Midtrans\Tests\Feature; use Khumam\Midtrans\Tests\TestCase; class MyTest extends TestCase { // database migrations run automatically // config: midtrans.server_key and midtrans.is_sandbox are pre-set }
Test Structure
tests/
├── TestCase.php # Base test (Orchestra Testbench)
├── Unit/
│ ├── ItemDetailObjectTest # Item detail validation, types, edge cases
│ └── TaxObjectTest # Tax calculations, fixed & percentage
└── Feature/
├── TransactionItemTest # Item relations, DB, cascade delete
├── CustomerTest # Customer upsert, relations, billing/shipping
└── CheckoutRedirectTest # getRedirectUrl, redirectTo, mocked API
License
MIT