Cara Membuat Report (Laporan) di PHP dengan MySQL dan Export Excel

| | PHP
Cara Membuat Report (Laporan) di PHP dengan MySQL dan Export Excel

Report atau laporan itu fitur yang hampir selalu ada di aplikasi bisnis, entah laporan penjualan, stok barang, atau keuangan. Untungnya di PHP dengan MySQL bikin report cukup fleksibel: datanya bisa ditampilkan dalam tabel, difilter berdasarkan tanggal, dan di-export ke Excel atau PDF.

Di tutorial ini saya bikin sistem laporan penjualan lengkap dengan filter tanggal, ringkasan, export ke Excel (CSV), dan fitur cetak. Kodenya bisa langsung kamu pakai untuk project sendiri.

Fitur Report yang Akan Dibuat

  • Laporan penjualan dengan tabel data
  • Filter tanggal — dari tanggal s/d tanggal
  • Filter kategori produk
  • Ringkasan — total penjualan, rata-rata, jumlah transaksi
  • Export Excel — download laporan dalam format CSV/Excel
  • Cetak — print laporan langsung dari browser

Persiapan Database

CREATE DATABASE report_db;
USE report_db;

CREATE TABLE kategori (
    id INT AUTO_INCREMENT PRIMARY KEY,
    nama_kategori VARCHAR(50) NOT NULL
);

CREATE TABLE produk (
    id INT AUTO_INCREMENT PRIMARY KEY,
    nama_produk VARCHAR(100) NOT NULL,
    kategori_id INT NOT NULL,
    harga INT NOT NULL,
    FOREIGN KEY (kategori_id) REFERENCES kategori(id)
);

CREATE TABLE penjualan (
    id INT AUTO_INCREMENT PRIMARY KEY,
    no_faktur VARCHAR(20) NOT NULL,
    tanggal DATE NOT NULL,
    produk_id INT NOT NULL,
    jumlah INT NOT NULL,
    harga_satuan INT NOT NULL,
    total INT GENERATED ALWAYS AS (jumlah * harga_satuan) STORED,
    kasir VARCHAR(50) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (produk_id) REFERENCES produk(id)
);

INSERT INTO kategori (nama_kategori) VALUES
('Elektronik'), ('Pakaian'), ('Makanan'), ('Minuman');

INSERT INTO produk (nama_produk, kategori_id, harga) VALUES
('Laptop Asus', 1, 8500000), ('Mouse Logitech', 1, 150000),
('Keyboard Mechanical', 1, 450000), ('Kaos Polos', 2, 85000),
('Kemeja Flannel', 2, 175000), ('Mie Instan', 3, 3500),
('Kopi Sachet', 4, 2000), ('Teh Botol', 4, 5000);

-- Data penjualan contoh (30 transaksi)
INSERT INTO penjualan (no_faktur, tanggal, produk_id, jumlah, harga_satuan, kasir) VALUES
('TRX-20260701-001', '2026-07-01', 1, 2, 8500000, 'Andi'),
('TRX-20260701-002', '2026-07-01', 4, 5, 85000, 'Andi'),
('TRX-20260702-001', '2026-07-02', 2, 3, 150000, 'Budi'),
('TRX-20260702-002', '2026-07-02', 5, 2, 175000, 'Budi'),
('TRX-20260703-001', '2026-07-03', 6, 20, 3500, 'Andi'),
('TRX-20260703-002', '2026-07-03', 3, 1, 450000, 'Andi'),
('TRX-20260704-001', '2026-07-04', 7, 10, 2000, 'Citra'),
('TRX-20260704-002', '2026-07-04', 8, 15, 5000, 'Citra'),
('TRX-20260705-001', '2026-07-05', 1, 1, 8500000, 'Andi'),
('TRX-20260705-002', '2026-07-05', 4, 3, 85000, 'Budi'),
('TRX-20260706-001', '2026-07-06', 2, 2, 150000, 'Andi'),
('TRX-20260706-002', '2026-07-06', 5, 4, 175000, 'Citra'),
('TRX-20260707-001', '2026-07-07', 6, 50, 3500, 'Budi'),
('TRX-20260707-002', '2026-07-07', 3, 2, 450000, 'Andi'),
('TRX-20260708-001', '2026-07-08', 1, 1, 8500000, 'Citra'),
('TRX-20260708-002', '2026-07-08', 7, 20, 2000, 'Andi'),
('TRX-20260709-001', '2026-07-09', 8, 25, 5000, 'Budi'),
('TRX-20260709-002', '2026-07-09', 4, 2, 85000, 'Andi'),
('TRX-20260710-001', '2026-07-10', 2, 1, 150000, 'Citra'),
('TRX-20260710-002', '2026-07-10', 5, 3, 175000, 'Budi');

Struktur Folder

report/
├── config/
│   └── database.php
├── laporan.php
├── export_excel.php
└── cetak.php

File Utama: Laporan dengan Filter

Buat file laporan.php:

<?php
require_once 'config/database.php';

// ===== FILTER PARAMETER =====
$tanggal_dari = $_GET['dari'] ?? date('Y-m-01'); // Default: awal bulan ini
$tanggal_sampai = $_GET['sampai'] ?? date('Y-m-d'); // Default: hari ini
$kategori_id = $_GET['kategori'] ?? '';
$kasir = $_GET['kasir'] ?? '';

// ===== BUILD QUERY =====
$where = ["p.tanggal BETWEEN ? AND ?"];
$params = [$tanggal_dari, $tanggal_sampai];

if ($kategori_id) {
    $where[] = "pr.kategori_id = ?";
    $params[] = $kategori_id;
}

if ($kasir) {
    $where[] = "p.kasir = ?";
    $params[] = $kasir;
}

$where_clause = implode(' AND ', $where);

// ===== AMBIL DATA =====
$query = "SELECT p.*, pr.nama_produk, k.nama_kategori
          FROM penjualan p
          JOIN produk pr ON p.produk_id = pr.id
          JOIN kategori k ON pr.kategori_id = k.id
          WHERE $where_clause
          ORDER BY p.tanggal DESC, p.id DESC";

$stmt = $pdo->prepare($query);
$stmt->execute($params);
$data = $stmt->fetchAll();

// ===== HITUNG RINGKASAN =====
$total_penjualan = array_sum(array_column($data, 'total'));
$total_transaksi = count($data);
$rata_rata = $total_transaksi > 0 ? $total_penjualan / $total_transaksi : 0;
$total_item = array_sum(array_column($data, 'jumlah'));

// Ringkasan per kategori
$query_kategori = "SELECT k.nama_kategori, COUNT(p.id) AS jml_transaksi,
                   SUM(p.total) AS total_penjualan
                   FROM penjualan p
                   JOIN produk pr ON p.produk_id = pr.id
                   JOIN kategori k ON pr.kategori_id = k.id
                   WHERE $where_clause
                   GROUP BY k.id ORDER BY total_penjualan DESC";
$stmt_kat = $pdo->prepare($query_kategori);
$stmt_kat->execute($params);
$per_kategori = $stmt_kat->fetchAll();

// Daftar kasir untuk filter
$kasir_list = $pdo->query('SELECT DISTINCT kasir FROM penjualan ORDER BY kasir')->fetchAll(PDO::FETCH_COLUMN);
// Daftar kategori untuk filter
$kategori_list = $pdo->query('SELECT * FROM kategori ORDER BY nama_kategori')->fetchAll();
?>
<!DOCTYPE html>
<html lang="id">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Laporan Penjualan</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: 'Segoe UI', Arial, sans-serif; background: #f3f4f6; }
        .wrapper { max-width: 1100px; margin: 0 auto; padding: 20px; }

        /* Header */
        .report-header { background: white; padding: 25px; border-radius: 12px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
        .report-header h1 { font-size: 24px; color: #1e293b; margin-bottom: 5px; }
        .report-header p { color: #6b7280; }

        /* Filter */
        .filter-box { background: white; padding: 20px; border-radius: 12px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
        .filter-form { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
        .filter-group { display: flex; flex-direction: column; gap: 4px; }
        .filter-group label { font-size: 12px; font-weight: 600; color: #6b7280; text-transform: uppercase; }
        .filter-group input, .filter-group select { padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; }
        .btn { padding: 9px 18px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; text-decoration: none; display: inline-block; }
        .btn-primary { background: #3b82f6; color: white; }
        .btn-success { background: #22c55e; color: white; }
        .btn-warning { background: #f59e0b; color: white; }
        .btn-reset { background: #6b7280; color: white; }

        /* Summary Cards */
        .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 20px; }
        .summary-card { background: white; padding: 20px; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
        .summary-card h3 { font-size: 12px; color: #6b7280; text-transform: uppercase; margin-bottom: 5px; }
        .summary-card .value { font-size: 24px; font-weight: bold; color: #1e293b; }
        .summary-card .value.green { color: #22c55e; }
        .summary-card .value.blue { color: #3b82f6; }

        /* Table */
        .table-box { background: white; padding: 20px; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); margin-bottom: 20px; }
        .table-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
        table { width: 100%; border-collapse: collapse; }
        th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #e5e7eb; font-size: 14px; }
        th { background: #f9fafb; font-size: 12px; color: #6b7280; text-transform: uppercase; }
        tr:hover { background: #f9fafb; }
        .text-right { text-align: right; }
        .text-center { text-align: center; }
        .total-row { font-weight: bold; background: #f0f9ff !important; }

        /* Category Summary */
        .kat-table { width: 100%; }
        .kat-table th { background: #1e293b; color: white; }

        /* Print */
        @media print {
            body { background: white; }
            .filter-box, .btn, .no-print { display: none !important; }
            .wrapper { max-width: 100%; padding: 0; }
            .report-header, .summary-card, .table-box { box-shadow: none; border: 1px solid #e5e7eb; }
        }
    </style>
</head>
<body>
    <div class="wrapper">
        <!-- Header -->
        <div class="report-header">
            <h1>Laporan Penjualan</h1>
            <p>Periode: <?= date('d/m/Y', strtotime($tanggal_dari)) ?> s/d <?= date('d/m/Y', strtotime($tanggal_sampai)) ?></p>
        </div>

        <!-- Filter -->
        <div class="filter-box no-print">
            <form method="GET" class="filter-form">
                <div class="filter-group">
                    <label>Dari Tanggal</label>
                    <input type="date" name="dari" value="<?= $tanggal_dari ?>">
                </div>
                <div class="filter-group">
                    <label>Sampai Tanggal</label>
                    <input type="date" name="sampai" value="<?= $tanggal_sampai ?>">
                </div>
                <div class="filter-group">
                    <label>Kategori</label>
                    <select name="kategori">
                        <option value="">Semua</option>
                        <?php foreach ($kategori_list as $k): ?>
                            <option value="<?= $k['id'] ?>" <?= $kategori_id == $k['id'] ? 'selected' : '' ?>><?= $k['nama_kategori'] ?></option>
                        <?php endforeach; ?>
                    </select>
                </div>
                <div class="filter-group">
                    <label>Kasir</label>
                    <select name="kasir">
                        <option value="">Semua</option>
                        <?php foreach ($kasir_list as $k): ?>
                            <option value="<?= $k ?>" <?= $kasir === $k ? 'selected' : '' ?>><?= $k ?></option>
                        <?php endforeach; ?>
                    </select>
                </div>
                <button type="submit" class="btn btn-primary">Filter</button>
                <a href="laporan.php" class="btn btn-reset">Reset</a>
                <a href="export_excel.php?dari=<?= $tanggal_dari ?>&sampai=<?= $tanggal_sampai ?>&kategori=<?= $kategori_id ?>&kasir=<?= $kasir ?>" class="btn btn-success">Export Excel</a>
                <button type="button" class="btn btn-warning" onclick="window.print()">Cetak</button>
            </form>
        </div>

        <!-- Summary Cards -->
        <div class="summary-grid">
            <div class="summary-card">
                <h3>Total Penjualan</h3>
                <div class="value green">Rp <?= number_format($total_penjualan, 0, ',', '.') ?></div>
            </div>
            <div class="summary-card">
                <h3>Jumlah Transaksi</h3>
                <div class="value blue"><?= $total_transaksi ?></div>
            </div>
            <div class="summary-card">
                <h3>Total Item Terjual</h3>
                <div class="value"><?= number_format($total_item) ?></div>
            </div>
            <div class="summary-card">
                <h3>Rata-rata per Transaksi</h3>
                <div class="value">Rp <?= number_format($rata_rata, 0, ',', '.') ?></div>
            </div>
        </div>

        <!-- Ringkasan per Kategori -->
        <?php if (!empty($per_kategori)): ?>
        <div class="table-box">
            <h2 style="margin-bottom:15px; font-size:16px;">Ringkasan per Kategori</h2>
            <table class="kat-table">
                <thead>
                    <tr>
                        <th>Kategori</th>
                        <th class="text-center">Jumlah Transaksi</th>
                        <th class="text-right">Total Penjualan</th>
                        <th class="text-right">Persentase</th>
                    </tr>
                </thead>
                <tbody>
                    <?php foreach ($per_kategori as $kat):
                        $persen = $total_penjualan > 0 ? ($kat['total_penjualan'] / $total_penjualan) * 100 : 0;
                    ?>
                    <tr>
                        <td><?= $kat['nama_kategori'] ?></td>
                        <td class="text-center"><?= $kat['jml_transaksi'] ?></td>
                        <td class="text-right">Rp <?= number_format($kat['total_penjualan'], 0, ',', '.') ?></td>
                        <td class="text-right"><?= number_format($persen, 1) ?>%</td>
                    </tr>
                    <?php endforeach; ?>
                </tbody>
            </table>
        </div>
        <?php endif; ?>

        <!-- Detail Transaksi -->
        <div class="table-box">
            <div class="table-header">
                <h2 style="font-size:16px;">Detail Transaksi</h2>
                <span style="color:#6b7280; font-size:14px;"><?= $total_transaksi ?> transaksi</span>
            </div>
            <table>
                <thead>
                    <tr>
                        <th>No</th>
                        <th>No. Faktur</th>
                        <th>Tanggal</th>
                        <th>Produk</th>
                        <th>Kategori</th>
                        <th class="text-center">Qty</th>
                        <th class="text-right">Harga</th>
                        <th class="text-right">Total</th>
                        <th>Kasir</th>
                    </tr>
                </thead>
                <tbody>
                    <?php if (empty($data)): ?>
                        <tr><td colspan="9" class="text-center" style="padding:30px;color:#6b7280;">Tidak ada data untuk periode yang dipilih</td></tr>
                    <?php else: ?>
                        <?php foreach ($data as $i => $row): ?>
                        <tr>
                            <td><?= $i + 1 ?></td>
                            <td><?= $row['no_faktur'] ?></td>
                            <td><?= date('d/m/Y', strtotime($row['tanggal'])) ?></td>
                            <td><?= $row['nama_produk'] ?></td>
                            <td><?= $row['nama_kategori'] ?></td>
                            <td class="text-center"><?= $row['jumlah'] ?></td>
                            <td class="text-right">Rp <?= number_format($row['harga_satuan'], 0, ',', '.') ?></td>
                            <td class="text-right">Rp <?= number_format($row['total'], 0, ',', '.') ?></td>
                            <td><?= $row['kasir'] ?></td>
                        </tr>
                        <?php endforeach; ?>
                        <tr class="total-row">
                            <td colspan="5" style="text-align:right;">TOTAL</td>
                            <td class="text-center"><?= number_format($total_item) ?></td>
                            <td></td>
                            <td class="text-right">Rp <?= number_format($total_penjualan, 0, ',', '.') ?></td>
                            <td></td>
                        </tr>
                    <?php endif; ?>
                </tbody>
            </table>
        </div>
    </div>
</body>
</html>

File Export Excel (CSV)

Buat file export_excel.php:

<?php
require_once 'config/database.php';

// Ambil parameter filter (sama seperti laporan.php)
$tanggal_dari = $_GET['dari'] ?? date('Y-m-01');
$tanggal_sampai = $_GET['sampai'] ?? date('Y-m-d');
$kategori_id = $_GET['kategori'] ?? '';
$kasir = $_GET['kasir'] ?? '';

// Build query
$where = ["p.tanggal BETWEEN ? AND ?"];
$params = [$tanggal_dari, $tanggal_sampai];

if ($kategori_id) {
    $where[] = "pr.kategori_id = ?";
    $params[] = $kategori_id;
}
if ($kasir) {
    $where[] = "p.kasir = ?";
    $params[] = $kasir;
}

$where_clause = implode(' AND ', $where);

// Ambil data
$query = "SELECT p.no_faktur, p.tanggal, pr.nama_produk, k.nama_kategori,
          p.jumlah, p.harga_satuan, p.total, p.kasir
          FROM penjualan p
          JOIN produk pr ON p.produk_id = pr.id
          JOIN kategori k ON pr.kategori_id = k.id
          WHERE $where_clause
          ORDER BY p.tanggal DESC";

$stmt = $pdo->prepare($query);
$stmt->execute($params);
$data = $stmt->fetchAll();

// ===== GENERATE CSV =====
$filename = 'Laporan_Penjualan_' . $tanggal_dari . '_' . $tanggal_sampai . '.csv';

// Header untuk download file
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Pragma: no-cache');
header('Expires: 0');

$output = fopen('php://output', 'w');

// Tambahkan BOM untuk Excel agar UTF-8 terbaca benar
fwrite($output, "\xEF\xBB\xBF");

// Header kolom
fputcsv($output, ['No', 'No Faktur', 'Tanggal', 'Produk', 'Kategori', 'Jumlah', 'Harga Satuan', 'Total', 'Kasir']);

// Data
$no = 1;
$total = 0;
foreach ($data as $row) {
    fputcsv($output, [
        $no++,
        $row['no_faktur'],
        date('d/m/Y', strtotime($row['tanggal'])),
        $row['nama_produk'],
        $row['nama_kategori'],
        $row['jumlah'],
        $row['harga_satuan'],
        $row['total'],
        $row['kasir']
    ]);
    $total += $row['total'];
}

// Baris total
fputcsv($output, ['', '', '', '', 'TOTAL', '', '', $total, '']);

fclose($output);
exit;

Penjelasan:

  • header('Content-Type: text/csv') — memberitahu browser bahwa file yang didownload adalah CSV

  • header('Content-Disposition: attachment') — memaksa browser untuk mendownload file

  • fwrite($output, "\xEF\xBB\xBF") — menambahkan BOM (Byte Order Mark) agar karakter UTF-8 (seperti “Rp”) terbaca benar di Excel

  • fputcsv() — menulis data dalam format CSV (otomatis menangani tanda kutip dan koma)

  • Fungsi IF pada Python: Memahami Percabangan Python

Kesimpulan

Membuat report di PHP dengan MySQL mencakup beberapa komponen:

  1. Filter — tanggal, kategori, kasir untuk mempersempit data
  2. Ringkasan — total penjualan, jumlah transaksi, rata-rata, per kategori
  3. Tabel detail — data transaksi lengkap dengan total di baris terakhir
  4. Export Excel — generate file CSV yang bisa dibuka di Excel
  5. Cetak — menggunakan CSS @media print untuk mengatur tampilan saat dicetak

Sistem laporan ini bisa dikembangkan lebih lanjut dengan menambahkan grafik menggunakan Chart.js, export ke PDF dengan library Dompdf, atau penjadwalan laporan otomatis via cron job.

Sigit Nurhanafi avatar
Full-stack developer & technical writer. Berpengalaman di PHP, Laravel, NodeJS, MySQL, dan Python. Aktif menulis tutorial pemrograman dan maintaining open-source projects di PemburuKode sejak 2021.