Gratis update & dukungan instalasi

Tutorial Website 13 Oct 2026

Cara Membuat Sistem Voucher Diskon di Laravel: Kupon Persen & Nominal + Anti Abuse (Panduan 2026)

R Oleh renz mobellgnd
Cara Membuat Sistem Voucher Diskon di Laravel: Kupon Persen & Nominal + Anti Abuse (Panduan 2026)

Cara membuat voucher diskon di Laravel adalah dengan membuat tabel vouchers berisi kode unik, tipe diskon (persen atau nominal), batas minimal belanja, kuota pemakaian, dan masa berlaku; lalu memvalidasinya lewat service class sebelum total checkout dikurangi. Panduan ini membawa Anda dari migrasi database sampai logika anti-abuse yang dipakai toko online produksi: batas kuota per pengguna, validasi masa berlaku dengan timezone yang benar, dan pengurangan kuota yang aman dari race condition.

Apa itu sistem voucher dan kenapa toko online membutuhkannya?

Voucher (kupon diskon) adalah kode yang dimasukkan pembeli di halaman checkout untuk mendapatkan potongan harga. Di balik layar, voucher hanyalah sebuah record database yang memengaruhi perhitungan total belanja — tapi justru di sinilah banyak implementasi asal-asalan berujung kerugian: kode bisa dipakai ulang tanpa batas, diskon persen 90% tanpa batas maksimal, atau kuota habis tapi masih bisa diklaim karena dua pembeli checkout bersamaan.

Sistem voucher yang benar memiliki tiga lapisan: data (tabel voucher + tabel pemakaian), validasi (aturan boleh/tidak boleh dipakai), dan penerapan (perhitungan total yang konsisten antara tampilan dan server). Panduan ini membangun ketiganya, dengan contoh studi kasus toko online yang menjual source code — relevan kalau Anda memakai source code toko online dan ingin menambahkan fitur promo sendiri.

Bagaimana struktur data voucher yang benar?

Sebelum menulis kode, tentukan dulu kolom-kolom yang dibutuhkan. Tabel minimal yang dipakai di produksi biasanya seperti ini:

Kolom Tipe Fungsi
codestring, uniqueKode yang diketik pembeli, mis. HEMAT50
typeenum: percent / fixedJenis potongan: persen atau nominal rupiah
valuedecimalBesarnya: 10 (artinya 10% atau Rp10.000 tergantung type)
max_discountdecimal, nullableBatas maksimal potongan untuk tipe persen
min_purchasedecimal, default 0Minimal total belanja agar voucher berlaku
usage_limitinteger, nullableKuota total pemakaian (null = tanpa batas)
used_countinteger, default 0Berapa kali sudah dipakai
per_user_limitinteger, default 1Maksimal dipakai per pengguna
starts_at / expires_atdatetime, nullableJendela masa berlaku
is_activebooleanSaklar darurat mematikan voucher

Selain itu buat tabel voucher_usages (voucher_id, user_id, order_id, discount_amount, timestamps) sebagai jejak audit: siapa memakai voucher apa, di order mana, dapat potongan berapa. Data ini penting untuk laporan dan investigasi penyalahgunaan.

Batasan jujur: desain ini memakai satu tabel voucher per kampanye. Kalau Anda butuh voucher personal yang berbeda-beda per pelanggan (mis. kode unik per email), generate satu record per kode — jangan memakai kolom code yang sama untuk semua orang.

Langkah 1: Buat migration dan model

php artisan make:migration create_vouchers_table
php artisan make:migration create_voucher_usages_table
php artisan make:model Voucher
Schema::create('vouchers', function (Blueprint $table) {
$table->id();
$table->string('code', 32)->unique();
$table->enum('type', ['percent', 'fixed']);
$table->decimal('value', 15, 2);
$table->decimal('max_discount', 15, 2)->nullable();
$table->decimal('min_purchase', 15, 2)->default(0);
$table->unsignedInteger('usage_limit')->nullable();
$table->unsignedInteger('used_count')->default(0);
$table->unsignedInteger('per_user_limit')->default(1);
$table->timestamp('starts_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});

Schema::create('voucher_usages', function (Blueprint $table) {
$table->id();
$table->foreignId('voucher_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained();
$table->foreignId('order_id')->nullable()->constrained();
$table->decimal('discount_amount', 15, 2);
$table->timestamps();
$table->unique(['voucher_id', 'user_id', 'order_id']);
});
Catatan: decimal(15,2) dipakai untuk semua kolom uang agar perhitungan rupiah presisi — jangan memakai float untuk uang. Ini praktik standar yang juga dipakai di tutorial optimasi database MySQL.

Langkah 2: Service class untuk validasi dan perhitungan

Jangan menaruh logika voucher di controller. Buat app/Services/VoucherService.php agar bisa dipakai ulang di web checkout, API mobile, maupun job:

namespace App\Services;

use App\Models\Voucher;
use Illuminate\Support\Facades\DB;

class VoucherService
{
/**
* Validasi voucher untuk user & subtotal tertentu.
* Me-return Voucher bila valid, melempar exception bila tidak.
*/
public function validate(string $code, int $userId, float $subtotal): Voucher
{
$voucher = Voucher::where('code', strtoupper(trim($code)))->first();

abort_if(! $voucher, 422, 'Kode voucher tidak ditemukan.');
abort_if(! $voucher->is_active, 422, 'Voucher sudah tidak aktif.');
abort_if($voucher->starts_at && now()->lt($voucher->starts_at), 422, 'Voucher belum berlaku.');
abort_if($voucher->expires_at && now()->gt($voucher->expires_at), 422, 'Voucher sudah kedaluwarsa.');
abort_if($subtotal < $voucher->min_purchase, 422,
'Minimal belanja Rp'.number_format($voucher->min_purchase, 0, ',', '.').' untuk memakai voucher ini.');

$usedByUser = DB::table('voucher_usages')
->where('voucher_id', $voucher->id)
->where('user_id', $userId)
->count();

abort_if($usedByUser >= $voucher->per_user_limit, 422,
'Anda sudah memakai voucher ini sebanyak batas maksimal.');

return $voucher;
}

/** Hitung nominal potongan dari subtotal. */
public function calculateDiscount(Voucher $voucher, float $subtotal): float
{
$discount = $voucher->type === 'percent'
? $subtotal * ($voucher->value / 100)
: (float) $voucher->value;

if ($voucher->type === 'percent' && $voucher->max_discount) {
$discount = min($discount, (float) $voucher->max_discount);
}

return round(min($discount, $subtotal), 2);
}
}

Tiga keputusan penting di sini yang jarang dibahas tutorial lain:

  1. Kode dinormalisasi (strtoupper(trim(...))) — pembeli mengetik hemat50 atau HEMAT50 tetap cocok. Simpan kode dalam huruf besar saat dibuat.
  2. now() memakai timezone aplikasi — pastikan APP_TIMEZONE=Asia/Jakarta di .env agar expires_at tidak meleset 7 jam.
  3. max_discount wajib untuk tipe persen — voucher "diskon 50%" tanpa batas maksimal pada belanja Rp10 juta berarti potongan Rp5 juta. Selalu isi max_discount, mis. Rp100.000.

Langkah 3: Terapkan di checkout dengan aman dari race condition

Inilah bagian yang paling sering salah. Alur yang benar: validasi → hitung ulang subtotal di server (jangan percaya total dari browser) → catat pemakaian dan tambah used_count secara atomik di dalam transaction:

use App\Services\VoucherService;
use Illuminate\Support\Facades\DB;

public function checkout(Request $request, VoucherService $vouchers)
{
$request->validate(['voucher_code' => 'nullable|string|max:32']);

return DB::transaction(function () use ($request, $vouchers) {
$subtotal = $this->hitungSubtotalDariDatabase($request->user()->cart);
$discount = 0;
$voucher = null;

if ($request->filled('voucher_code')) {
$voucher = $vouchers->validate(
$request->voucher_code, $request->user()->id, $subtotal
);
$discount = $vouchers->calculateDiscount($voucher, $subtotal);
}

$order = Order::create([
'user_id' => $request->user()->id,
'subtotal' => $subtotal,
'discount' => $discount,
'total' => $subtotal - $discount,
'voucher_id' => $voucher?->id,
]);

if ($voucher) {
// Kunci baris dulu, lalu cek kuota — mencegah dua checkout
// bersamaan melewati batas usage_limit.
$locked = Voucher::whereKey($voucher->id)->lockForUpdate()->first();

abort_if(
$locked->usage_limit && $locked->used_count >= $locked->usage_limit,
422, 'Kuota voucher sudah habis.'
);

$locked->increment('used_count');

DB::table('voucher_usages')->insert([
'voucher_id' => $voucher->id,
'user_id' => $request->user()->id,
'order_id' => $order->id,
'discount_amount' => $discount,
'created_at' => now(),
'updated_at' => now(),
]);
}

return redirect()->route('orders.show', $order);
});
}

Pola lockForUpdate() + cek kuota di dalam transaction adalah kunci anti-abuse: tanpa ini, dua pembeli yang checkout di detik yang sama bisa sama-sama lolos cek used_count < usage_limit sebelum salah satunya tersimpan. Untuk trafik tinggi, pertimbangkan antrean — pola yang sama dipakai pada notifikasi WhatsApp otomatis dengan queue agar proses berat tidak memperlambat checkout.

Bagaimana membuat kode voucher yang aman?

Jangan memakai kode yang mudah ditebak seperti DISKON1, PROMO2 — bot bisa brute-force. Generate kode acak dan pastikan unik:

use Illuminate\Support\Str;

do {
$code = strtoupper(Str::random(8)); // mis. "KX7Q2M9P"
} while (Voucher::where('code', $code)->exists());

Untuk kampanye publik (mis. HEMAT50 yang disebar di Instagram), kode yang mudah diingat memang disengaja — amankan lewat usage_limit dan per_user_limit, bukan lewat kerahasiaan kode. Untuk kode personal (mis. kompensasi ke satu pelanggan), pakai kode acak 10–12 karakter dan per_user_limit = 1.

Kapan sebaiknya pakai package, kapan buat sendiri?

Ekosistem Laravel punya beberapa package voucher. Tabel perbandingan jujur:

Package Kelebihan Kekurangan
beyondcode/laravel-vouchersPaling sederhana, API bersihFitur validasi bisnis (min. belanja, kuota per user) harus ditulis sendiri
binafy/laravel-discountFitur lengkap: generate kode massal, batasan pemakaianMenambah dependensi; perlu dipelajari konvensinya
Kode sendiri (panduan ini)Kendali penuh, tanpa dependensiAnda yang menulis & memelihara semuanya

Rekomendasi praktisi: kalau kebutuhan Anda standar (kode, persen/nominal, masa berlaku, kuota), kode sendiri seperti panduan ini lebih mudah dirawat jangka panjang — package voucher yang tidak di-maintain bisa jadi beban saat upgrade Laravel. Package layak dipakai bila Anda butuh fitur lanjutan seperti kode sekali pakai massal untuk ribuan pelanggan. Apapun pilihannya, pastikan package-nya kompatibel dengan versi Laravel Anda — cek panduan upgrade Laravel ke versi terbaru sebelum menambah dependensi baru.

Strategi kampanye voucher untuk penjual source code

Fitur voucher selesai — sekarang sisi bisnisnya. Untuk Anda yang menjual source code atau template website, tiga pola kampanye yang terbukti efektif:

  1. Voucher launching — LAUNCH20 (20%, maks. Rp150rb, kuota 100) saat merilis produk baru untuk menciptakan urgensi.
  2. Voucher reaktivasi — kirim kode personal ke pembeli lama yang 3+ bulan tidak belanja. Pasangkan dengan email otomatis via queue agar pengirimannya terjadwal rapi.
  3. Voucher bundling — minimal belanja Rp500rb untuk mendorong pembelian 2–3 produk sekaligus, menaikkan rata-rata nilai order.

Selalu catat discount_amount per order di voucher_usages — tanpa data ini Anda tidak bisa menghitung apakah kampanye voucher benar-benar menaikkan pendapatan atau sekadar memberi diskon ke orang yang memang sudah mau beli.

Cara ini tidak cocok jika...

Jujur soal batasan: desain panduan ini tidak cocok bila Anda butuh voucher yang bisa digabung (stacking) antar beberapa kode dalam satu order — itu butuh aturan prioritas dan urutan perhitungan tersendiri. Juga tidak cocok untuk skema loyalitas berbasis poin yang terakumulasi; untuk itu pakailah sistem wallet/poin, bukan voucher sekali pakai. Dan bila voucher Anda dipakai lewat REST API untuk aplikasi mobile, bungkus endpoint validasi voucher dengan rate limiting agar tidak di-brute-force.

Kesimpulan

Sistem voucher diskon yang aman di Laravel terdiri dari tabel vouchers + voucher_usages, service class untuk validasi dan perhitungan, serta penerapan di checkout dalam database transaction dengan lockForUpdate() agar kuota tidak jebol. Tambahkan max_discount untuk voucher persen, normalisasi kode ke huruf besar, dan catat setiap pemakaian untuk audit. Dengan fondasi ini, Anda bisa menjalankan kampanye promo yang agresif tanpa takut disalahgunakan — dan kalau Anda membangunnya di atas source code yang dibeli, pastikan dulu keamanannya mengikuti checklist 9 langkah mengamankan website.

FAQ

Apakah voucher persen atau nominal yang lebih baik untuk toko online? Voucher persen terasa lebih menarik di materi promosi ("Diskon 20%"), tapi wajib dipasangi max_discount agar tidak kebobolan pada order besar. Voucher nominal (Rp50.000) lebih mudah diprediksi biayanya. Banyak toko memakai keduanya untuk kampanye berbeda.

Bagaimana mencegah satu orang memakai voucher berkali-kali dengan banyak akun? per_user_limit hanya membatasi per akun. Untuk abuse multi-akun, tambahkan lapisan: verifikasi nomor HP/WhatsApp saat registrasi, batasi satu voucher per nomor HP, dan pantau pola order mencurigakan dari tabel voucher_usages.

Apakah kode voucher perlu di-hash seperti password? Tidak perlu. Kode voucher bukan kredensial rahasia — ia disebar ke publik lewat promosi. Yang dilindungi adalah logika validasinya (kuota, masa berlaku, batas per user).

Bagaimana menangani voucher yang sudah dipakai tapi order-nya dibatalkan/refund? Saat refund, kembalikan kuota: decrement('used_count') dan hapus/tandai record di voucher_usages. Putuskan kebijakan bisnisnya dulu — apakah voucher hangus atau bisa dipakai lagi — lalu implementasikan konsisten.

Bisakah voucher dipakai bersamaan dengan harga promo/flash sale? Bisa, tapi tentukan aturannya eksplisit: apakah diskon voucher dihitung dari harga normal atau harga promo. Ambiguitas di sini adalah sumber komplain pelanggan nomor satu.

Apakah implementasi ini aman untuk dipakai di API mobile? Ya, selama VoucherService dipanggil dari endpoint API dengan autentikasi (mis. Sanctum) dan rate limiting. Jangan pernah menghitung total akhir di aplikasi mobile — selalu hitung ulang di server seperti contoh checkout di atas.