Bagian 10 dari seri

Membuat Cetak Nota Kasir Laravel 58mm dan 80mm

Diterbitkan 16 September 2026

Pada tahap kesepuluh kita akan membuat cetak nota kasir untuk transaksi penjualan.

Nota digunakan sebagai bukti transaksi dan biasanya dicetak menggunakan printer thermal.

Pada tutorial ini kita akan membuat dua ukuran umum:

58 mm
80 mm

Nota akan menampilkan:

nama toko
alamat toko
telepon toko
nomor invoice
tanggal transaksi
nama kasir
nama pelanggan
daftar produk
quantity
harga
subtotal
diskon
grand total
jumlah pembayaran
kembalian
footer nota
status transaksi

Data toko akan diambil dari tabel:

settings

yang sudah dibuat pada Tahap 03.

Kita juga akan membuat:

route khusus nota
view khusus nota
CSS print
tombol cetak
pilihan ukuran 58mm dan 80mm

Hasil yang Akan Dibuat

Pada tahap ini kita akan menambahkan method pada:

SaleController

Route:

/sales/{sale}/receipt

View:

resources/views/sales/receipt.blade.php

Fitur:

  • tampil nota transaksi;
  • ukuran 58 mm;
  • ukuran 80 mm;
  • tombol print;
  • print-friendly CSS;
  • informasi toko dari settings;
  • transaksi cancelled diberi penanda;
  • layout sederhana untuk printer thermal.

Persiapan

Pastikan Tahap 01 sampai Tahap 09 sudah selesai.

Aplikasi harus sudah mempunyai:

sales
sale_details
settings
products
users
customers

Pastikan transaksi penjualan sudah tersedia.

Pastikan juga settings dari seeder sudah ada:

store_name
store_address
store_phone
receipt_footer

1. Memeriksa Settings

Jalankan:

php artisan tinker

Cek:

App\Models\Setting::pluck('value', 'key');

Hasil kurang lebih:

store_name => Toko RumahSourceCode
store_address => Denpasar, Bali
store_phone => 081234567890
receipt_footer => Terima kasih telah berbelanja.

Keluar:

exit

2. Menambahkan Route Cetak Nota

Buka:

routes/web.php

Tambahkan route di group auth:

Route::middleware(['auth'])->group(function () {
    Route::get(
        'sales/{sale}/receipt',
        [SaleController::class, 'receipt']
    )->name('sales.receipt');
});

Admin dan kasir boleh membuka nota.

3. Menambahkan Method receipt pada SaleController

Pastikan import:

use App\Models\Setting;

Kemudian tambahkan method:

public function receipt(
    Request $request,
    Sale $sale
): View {
    $size = $request->get('size', '58');

    if (! in_array($size, ['58', '80'], true)) {
        $size = '58';
    }

    $sale->load([
        'user',
        'customer',
        'details.product.unit',
    ]);

    $settings = Setting::query()
        ->pluck('value', 'key');

    return view('sales.receipt', compact(
        'sale',
        'settings',
        'size'
    ));
}

4. Penjelasan Parameter Size

URL dapat menggunakan:

?s=58

Namun pada controller kita menggunakan:

size

Contoh:

/sales/1/receipt?size=58

atau:

/sales/1/receipt?size=80

Jika value lain dikirim, controller mengembalikan default:

58

5. Mengambil Data Settings

Kode:

$settings = Setting::query()
    ->pluck('value', 'key');

menghasilkan collection key-value.

Contoh penggunaan di Blade:

{{ $settings['store_name'] ?? 'Nama Toko' }}

Jika key tidak tersedia, fallback tetap ditampilkan.

6. Menambahkan Tombol Nota pada Detail Penjualan

Buka:

resources/views/sales/show.blade.php

Tambahkan tombol:

<div class="d-flex gap-2 mb-3">
    <a
        href="{{ route('sales.receipt', [
            'sale' => $sale,
            'size' => 58,
        ]) }}"
        target="_blank"
        class="btn btn-outline-primary">
        Cetak Nota 58mm
    </a>

    <a
        href="{{ route('sales.receipt', [
            'sale' => $sale,
            'size' => 80,
        ]) }}"
        target="_blank"
        class="btn btn-outline-secondary">
        Cetak Nota 80mm
    </a>
</div>

Dengan:

target="_blank"

nota dibuka pada tab baru.

7. Membuat View Receipt

Buat:

resources/views/sales/receipt.blade.php

Isi:

<!DOCTYPE html>
<html lang="id">
<head>
    <meta charset="UTF-8">

    <meta
        name="viewport"
        content="width=device-width, initial-scale=1.0">

    <title>
        Nota {{ $sale->invoice_number }}
    </title>

    <style>
        * {
            box-sizing: border-box;
        }

        body {
            margin: 0;
            padding: 12px;
            background: #f5f5f5;
            font-family: Arial, Helvetica, sans-serif;
            color: #000;
        }

        .receipt {
            width: {{ $size === '80' ? '80mm' : '58mm' }};
            margin: 0 auto;
            background: #fff;
            padding: {{ $size === '80' ? '4mm' : '3mm' }};
            font-size: {{ $size === '80' ? '12px' : '10px' }};
            line-height: 1.35;
        }

        .text-center {
            text-align: center;
        }

        .text-right {
            text-align: right;
        }

        .fw-bold {
            font-weight: bold;
        }

        .store-name {
            font-size: {{ $size === '80' ? '18px' : '14px' }};
            font-weight: bold;
            margin-bottom: 2px;
        }

        .separator {
            border-top: 1px dashed #000;
            margin: 6px 0;
        }

        .row {
            display: flex;
            justify-content: space-between;
            gap: 8px;
        }

        .row > span:first-child {
            flex: 1;
        }

        .row > span:last-child {
            white-space: nowrap;
        }

        .item {
            margin-bottom: 5px;
        }

        .item-name {
            font-weight: bold;
        }

        .item-detail {
            display: flex;
            justify-content: space-between;
            gap: 8px;
        }

        .status-cancelled {
            border: 2px solid #000;
            padding: 5px;
            text-align: center;
            font-weight: bold;
            margin: 8px 0;
        }

        .toolbar {
            max-width: 800px;
            margin: 0 auto 12px;
            display: flex;
            gap: 8px;
            justify-content: center;
            flex-wrap: wrap;
        }

        .toolbar a,
        .toolbar button {
            border: 1px solid #333;
            background: #fff;
            padding: 8px 12px;
            cursor: pointer;
            text-decoration: none;
            color: #000;
            font-size: 14px;
        }

        @media print {
            body {
                padding: 0;
                margin: 0;
                background: #fff;
            }

            .toolbar {
                display: none !important;
            }

            .receipt {
                margin: 0;
                width: {{ $size === '80' ? '80mm' : '58mm' }};
                padding: {{ $size === '80' ? '4mm' : '3mm' }};
            }

            @page {
                size: {{ $size === '80' ? '80mm' : '58mm' }} auto;
                margin: 0;
            }
        }
    </style>
</head>

<body>
    <div class="toolbar">
        <button
            type="button"
            onclick="window.print()">
            Cetak
        </button>

        <a
            href="{{ route('sales.receipt', [
                'sale' => $sale,
                'size' => 58,
            ]) }}">
            58mm
        </a>

        <a
            href="{{ route('sales.receipt', [
                'sale' => $sale,
                'size' => 80,
            ]) }}">
            80mm
        </a>

        <a
            href="{{ route('sales.show', $sale) }}">
            Kembali
        </a>
    </div>

    <div class="receipt">
        <div class="text-center">
            <div class="store-name">
                {{ $settings['store_name'] ?? 'Nama Toko' }}
            </div>

            @if(! empty($settings['store_address']))
                <div>
                    {{ $settings['store_address'] }}
                </div>
            @endif

            @if(! empty($settings['store_phone']))
                <div>
                    Telp: {{ $settings['store_phone'] }}
                </div>
            @endif
        </div>

        <div class="separator"></div>

        <div class="row">
            <span>Invoice</span>
            <span>{{ $sale->invoice_number }}</span>
        </div>

        <div class="row">
            <span>Tanggal</span>
            <span>
                {{ $sale->sale_date->format('d/m/Y H:i') }}
            </span>
        </div>

        <div class="row">
            <span>Kasir</span>
            <span>{{ $sale->user?->name ?? '-' }}</span>
        </div>

        <div class="row">
            <span>Pelanggan</span>
            <span>{{ $sale->customer?->name ?? 'Umum' }}</span>
        </div>

        @if($sale->status === 'cancelled')
            <div class="status-cancelled">
                TRANSAKSI DIBATALKAN
            </div>
        @endif

        <div class="separator"></div>

        @foreach($sale->details as $detail)
            <div class="item">
                <div class="item-name">
                    {{ $detail->product?->name ?? 'Produk' }}
                </div>

                <div class="item-detail">
                    <span>
                        {{ $detail->quantity }}
                        x
                        {{ number_format(
                            (float) $detail->price,
                            0,
                            ',',
                            '.'
                        ) }}
                    </span>

                    <span>
                        {{ number_format(
                            (float) $detail->subtotal,
                            0,
                            ',',
                            '.'
                        ) }}
                    </span>
                </div>
            </div>
        @endforeach

        <div class="separator"></div>

        <div class="row">
            <span>Subtotal</span>
            <span>
                Rp {{ number_format(
                    (float) $sale->subtotal,
                    0,
                    ',',
                    '.'
                ) }}
            </span>
        </div>

        <div class="row">
            <span>Diskon</span>
            <span>
                Rp {{ number_format(
                    (float) $sale->discount,
                    0,
                    ',',
                    '.'
                ) }}
            </span>
        </div>

        <div class="row fw-bold">
            <span>Grand Total</span>
            <span>
                Rp {{ number_format(
                    (float) $sale->grand_total,
                    0,
                    ',',
                    '.'
                ) }}
            </span>
        </div>

        <div class="row">
            <span>Dibayar</span>
            <span>
                Rp {{ number_format(
                    (float) $sale->paid_amount,
                    0,
                    ',',
                    '.'
                ) }}
            </span>
        </div>

        <div class="row">
            <span>Kembalian</span>
            <span>
                Rp {{ number_format(
                    (float) $sale->change_amount,
                    0,
                    ',',
                    '.'
                ) }}
            </span>
        </div>

        @if($sale->notes)
            <div class="separator"></div>

            <div>
                <strong>Catatan:</strong>
            </div>

            <div>
                {!! nl2br(e($sale->notes)) !!}
            </div>
        @endif

        <div class="separator"></div>

        <div class="text-center">
            {{ $settings['receipt_footer']
                ?? 'Terima kasih telah berbelanja.' }}
        </div>
    </div>
</body>
</html>

8. Mengapa Receipt Tidak Menggunakan Layout Utama?

Halaman nota menggunakan HTML mandiri.

Kita tidak memakai:

@extends('layouts.app')

karena layout aplikasi biasanya memiliki:

navbar
sidebar
container
margin
padding
footer

Semua elemen tersebut tidak dibutuhkan pada printer thermal.

Nota sebaiknya minimal dan fokus pada isi transaksi.

9. Ukuran 58mm

Ukuran:

58mm

cocok untuk printer thermal kecil.

CSS:

.receipt {
    width: 58mm;
}

dan:

@page {
    size: 58mm auto;
}

10. Ukuran 80mm

Untuk printer 80mm:

width: 80mm;

Ukuran font juga dibuat sedikit lebih besar.

11. Mengapa Ada Toolbar?

Toolbar hanya tampil di browser.

Isi:

Cetak
58mm
80mm
Kembali

Saat print:

.toolbar {
    display: none !important;
}

Jadi tombol tidak ikut tercetak.

12. Tombol Print

Tombol:

<button onclick="window.print()">

membuka print dialog browser.

Kasir dapat memilih printer thermal yang terhubung ke komputer.

13. Auto Print Opsional

Jika ingin print dialog langsung muncul ketika halaman dibuka, tambahkan sebelum:

</body>

kode:

<script>
window.addEventListener('load', () => {
    window.print();
});
</script>

Namun tutorial ini tidak mengaktifkannya secara default.

Alasannya:

  • pengguna mungkin hanya ingin preview;
  • browser dapat membatasi auto print;
  • lebih nyaman jika kasir memilih tombol Cetak.

14. Informasi Toko

Nama toko:

{{ $settings['store_name'] ?? 'Nama Toko' }}

Alamat:

{{ $settings['store_address'] }}

Telepon:

{{ $settings['store_phone'] }}

Footer:

{{ $settings['receipt_footer'] }}

15. Jika Setting Tidak Tersedia

Kita menggunakan fallback.

Contoh:

$settings['store_name'] ?? 'Nama Toko'

Jadi halaman tidak error jika setting belum tersedia.

16. Menampilkan Status Cancelled

Jika:

status = cancelled

nota menampilkan:

TRANSAKSI DIBATALKAN

Ini penting agar nota transaksi yang sudah dibatalkan tidak terlihat seperti transaksi aktif.

17. Apakah Nota Cancelled Boleh Dicetak?

Secara teknis boleh.

Namun nota harus memiliki tanda jelas bahwa transaksi:

DIBATALKAN

Hal ini berguna untuk:

audit
arsip
pengecekan transaksi lama

18. Menampilkan Detail Produk

Setiap item menampilkan:

nama produk
quantity
harga
subtotal

Contoh:

Air Mineral
2 x 4.000            8.000

19. Harga pada Nota Berasal dari SaleDetail

Nota menggunakan:

$detail->price

bukan:

$product->selling_price

Alasannya harga produk saat ini bisa berubah.

Nota harus menggunakan harga saat transaksi terjadi.

20. Subtotal pada Nota

Subtotal item menggunakan:

$detail->subtotal

Data ini sudah disimpan saat transaksi dibuat.

21. Ringkasan Pembayaran

Nota menampilkan:

Subtotal
Diskon
Grand Total
Dibayar
Kembalian

Semua berasal dari header:

sales

22. Memformat Rupiah

Kita menggunakan:

number_format(
    (float) $value,
    0,
    ',',
    '.'
)

Contoh:

50000

menjadi:

50.000

23. Mengapa Tidak Menampilkan Rp pada Setiap Item?

Pada ukuran thermal kecil, ruang terbatas.

Karena itu harga item dapat ditampilkan:

4.000

tanpa:

Rp

Sedangkan total utama tetap menggunakan:

Rp

24. Menguji Nota 58mm

Buka salah satu transaksi.

Klik:

Cetak Nota 58mm

Pastikan:

lebar nota sempit
teks terbaca
item tidak keluar area
total terlihat jelas
toolbar tidak ikut print

25. Menguji Nota 80mm

Klik:

Cetak Nota 80mm

Pastikan tampilan lebih lebar.

26. Menguji Print Preview

Klik:

Cetak

Browser membuka print preview.

Pada print preview:

toolbar tidak tampil
background putih
nota berada pada lebar thermal

27. Printer Thermal dan Browser

Aplikasi Laravel hanya menghasilkan halaman HTML.

Proses print dilakukan browser.

Artinya Laravel tidak langsung berbicara ke printer thermal.

Browser mengirim output ke printer yang dipilih pengguna.

28. Jika Hasil Print Terpotong

Periksa:

paper size printer
margin printer
scale browser

Gunakan:

Scale 100%
Margin None

jika browser menyediakan opsi tersebut.

Printer juga harus menggunakan ukuran kertas sesuai:

58mm

atau:

80mm

29. Jika Printer 58mm Memiliki Area Cetak Lebih Kecil

Beberapa printer 58mm tidak benar-benar memiliki area cetak penuh 58mm.

Jika teks terlalu dekat pinggir, ubah:

padding: 3mm;

menjadi:

padding: 2mm;

atau kecilkan:

font-size

30. Jika Printer 80mm

Untuk 80mm, biasanya area cetak lebih luas.

Layout dapat menampilkan font sedikit lebih besar.

31. Mengapa Tidak Menggunakan Bootstrap?

Untuk halaman thermal, CSS manual lebih ringan.

Bootstrap dapat membawa:

margin
padding
font
print style

yang tidak kita perlukan.

32. Mengapa Tidak Menggunakan PDF?

Untuk printer thermal, HTML print sering lebih praktis.

PDF dapat digunakan jika:

nota perlu diunduh
nota perlu dikirim
arsip PDF dibutuhkan

Namun PDF bukan kebutuhan wajib untuk tutorial dasar.

33. Menambahkan Tombol Nota pada Daftar Penjualan

Opsional, pada:

resources/views/sales/index.blade.php

tambahkan tombol:

<a
    href="{{ route('sales.receipt', [
        'sale' => $sale,
        'size' => 58,
    ]) }}"
    target="_blank"
    class="btn btn-sm btn-outline-secondary">
    Nota
</a>

Dengan demikian kasir tidak perlu membuka detail lebih dahulu.

34. Menambahkan Pengaturan Nota ke CRUD Setting

Saat ini settings berasal dari seeder.

Jika ingin membuat halaman admin:

Pengaturan Toko

dapat dibuat CRUD khusus.

Namun untuk menjaga tutorial tetap fokus, tahap ini hanya membaca setting yang sudah tersedia.

35. Setting yang Disarankan

Minimal:

store_name
store_address
store_phone
receipt_footer

Opsional:

store_email
store_tax_number
receipt_header
receipt_footer_2

36. Menambahkan Nomor WhatsApp Opsional

Jika ingin menampilkan WhatsApp:

store_whatsapp

buat melalui data setting.

Namun belum diperlukan.

37. Menambahkan Logo pada Nota

Logo dapat digunakan, tetapi pada printer thermal:

  • hasil dapat kurang tajam;
  • printing lebih lambat;
  • file lebih besar;
  • printer murah kadang kurang bagus mencetak gambar.

Untuk tutorial dasar kita gunakan nota teks.

38. Menambahkan QR Code Opsional

QR code dapat ditambahkan untuk:

link invoice
pembayaran
WhatsApp
review toko

Namun membutuhkan library tambahan.

Belum diperlukan untuk fungsi utama kasir.

39. Menguji Transaksi Cancelled

Buka transaksi cancelled.

Cetak nota.

Pastikan teks:

TRANSAKSI DIBATALKAN

terlihat jelas.

40. Menguji Pelanggan Umum

Jika:

customer_id = NULL

nota menampilkan:

Pelanggan: Umum

41. Menguji Produk dengan Unit NULL

View menggunakan:

$detail->product?->unit

jadi jika unit tidak tersedia, nota tidak error.

42. Menguji Produk Lama

Karena produk yang sudah mempunyai transaksi tidak boleh dihapus, relasi produk biasanya tetap tersedia.

Namun null-safe operator tetap digunakan agar view aman.

43. Keamanan Nota

Route menggunakan:

auth

Jadi orang yang belum login tidak dapat membuka nota.

Jika dibutuhkan kontrol lebih ketat, dapat dibuat policy.

Untuk tutorial ini auth sudah cukup.

44. Apakah Kasir Bisa Cetak Nota Transaksi Kasir Lain?

Pada tutorial ini, semua user login dapat membuka transaksi.

Jika bisnis mengharuskan kasir hanya melihat transaksi sendiri, perlu policy atau query tambahan.

Belum diterapkan pada tutorial dasar.

45. Struktur File Setelah Tahap 10

Controller:

app/Http/Controllers/SaleController.php

Method tambahan:

receipt()

View:

resources/views/sales/receipt.blade.php

Route:

sales.receipt

46. Checklist Pengujian Tahap 10

Pastikan:

[ ] route receipt dapat dibuka
[ ] nota memuat nama toko
[ ] alamat tampil
[ ] telepon tampil
[ ] invoice tampil
[ ] tanggal tampil
[ ] kasir tampil
[ ] pelanggan tampil
[ ] item tampil
[ ] quantity tampil
[ ] harga tampil
[ ] subtotal tampil
[ ] diskon tampil
[ ] grand total tampil
[ ] pembayaran tampil
[ ] kembalian tampil
[ ] footer tampil

Ukuran:

[ ] 58mm bekerja
[ ] 80mm bekerja
[ ] tombol ukuran bekerja

Print:

[ ] tombol Cetak membuka print dialog
[ ] toolbar tidak ikut tercetak
[ ] lebar nota sesuai
[ ] margin tidak berlebihan

Status:

[ ] transaksi paid tampil normal
[ ] transaksi cancelled diberi tanda

47. Kesalahan Umum: Menggunakan Selling Price Produk

Jangan gunakan:

$product->selling_price

untuk nota transaksi lama.

Gunakan:

$detail->price

48. Kesalahan Umum: Sidebar Ikut Tercetak

Gunakan halaman receipt mandiri.

Jangan gunakan layout aplikasi utama jika sidebar ikut terbawa.

49. Kesalahan Umum: Tombol Ikut Tercetak

Pastikan:

@media print {
    .toolbar {
        display: none !important;
    }
}

50. Kesalahan Umum: Nota Terlalu Lebar

Pastikan ukuran CSS:

58mm
80mm

sesuai printer.

Jangan menggunakan:

width: 100%

tanpa batas.

51. Kesalahan Umum: Margin Browser Besar

Pada print dialog, pilih:

Margins: None

jika tersedia.

52. Kesalahan Umum: Scale Tidak 100%

Gunakan:

Scale: 100%

agar ukuran tidak berubah.

53. Kesalahan Umum: Nota Cancelled Tidak Dibedakan

Selalu tampilkan status cancelled dengan jelas.

54. Pengembangan Selanjutnya

Nota dapat dikembangkan dengan:

logo
QR code
barcode invoice
auto print
print Bluetooth
print ESC/POS
print via Android
download PDF
kirim WhatsApp
email receipt

Fitur tersebut merupakan pengembangan lanjutan.

55. Checkpoint Hasil

Setelah Tahap 10:

Login                         : selesai
Role admin/kasir              : selesai
Database                      : selesai
Model dan relasi              : selesai
Seeder                        : selesai
CRUD master                   : selesai
CRUD produk                   : selesai
Penjualan                     : selesai
Pembelian                     : selesai
Kartu stok                    : selesai
Adjustment stok               : selesai
Dashboard                     : selesai
Laporan                       : selesai
Nota 58mm                     : selesai
Nota 80mm                     : selesai
Testing                       : tahap berikutnya
Deploy                        : belum

56. Ringkasan

Pada tahap kesepuluh kita telah membuat nota kasir untuk printer thermal.

Nota mendukung:

58mm
80mm

Data toko diambil dari:

settings

Data transaksi diambil dari:

sales
sale_details

Harga item menggunakan snapshot:

sale_details.price

bukan harga produk saat ini.

Nota cancelled diberi penanda jelas:

TRANSAKSI DIBATALKAN

Print dilakukan menggunakan:

window.print()

dan CSS:

@media print

Tahap berikutnya kita akan membuat pengujian sistem kasir secara khusus sebelum masuk ke tahap deployment.

Lanjut: Pengujian Sistem Kasir