Merge PDF di Laravel dengan FPDI
TutorialLaravelPHP#laravel#php#tutorial#backend

Merge PDF di Laravel dengan FPDI

A
Abd. Asis
Bagikan:

Kebutuhan ini lebih sering muncul dari yang kita kira. Laporan bulanan yang terdiri dari tiga bagian berbeda, invoice yang harus dilampirkan dengan dokumen syarat layanan, atau fitur di mana pengguna bisa menggabungkan file PDF kiriman mereka sendiri — semuanya berujung pada satu pertanyaan yang sama: bagaimana cara merge PDF di Laravel?

spatie/laravel-pdf memang sangat andal untuk generate PDF dari Blade view, tapi paket ini tidak punya fitur merge bawaan. Solusinya adalah memadukan dua package: Spatie untuk urusan generate, dan setasign/fpdi untuk urusan menggabungkan hasilnya. Kombinasi ini battle-tested, ringan, dan tidak membutuhkan binary eksternal sama sekali.

Artikel ini mencakup fondasi teknis cara kerja merge, diikuti tiga studi kasus nyata yang bisa langsung diadaptasi ke project.

Dua Package, Satu Alur Kerja

Sebelum masuk ke kode, penting untuk memahami pembagian peran antar package agar tidak bingung saat debugging nanti.

spatie/laravel-pdf bertugas mengubah Blade view menjadi file PDF. Dengan driver Dompdf, tidak ada ketergantungan eksternal — cukup PHP murni. Driver ini mendukung CSS 2.1 dan sebagian CSS 3, cocok untuk laporan dan dokumen dengan layout yang tidak terlalu kompleks.

setasign/fpdi (versi 2.6.6, dirilis Maret 2026) bertugas membaca halaman dari file PDF yang sudah ada dan menyusunnya ulang menjadi satu dokumen baru. Cara kerjanya mirip mesin fotokopi digital: ambil halaman dari dokumen A, halaman dari dokumen B, susun berurutan, cetak jadi satu berkas. FPDI tidak memodifikasi file sumber — ia hanya membaca, lalu menulis ulang ke dokumen baru.

Alurnya secara keseluruhan:

  1. Generate setiap dokumen sebagai file PDF sementara menggunakan Spatie
  2. Baca setiap halaman dari file-file sementara itu menggunakan FPDI
  3. Tulis semua halaman secara berurutan ke satu dokumen baru
  4. Simpan hasil merge ke storage
  5. Hapus file-file sementara yang sudah tidak dibutuhkan

Instalasi Package yang Dibutuhkan

Tambahkan kedua package ke composer.json:

{}JSON
{
    "require": {
        "spatie/laravel-pdf": "^2.4",
        "setasign/fpdf": "1.8.*",
        "setasign/fpdi": "^2.0"
    }
}

Jalankan instalasi:

BashBASH
composer install

Atur driver default di file .env agar Spatie menggunakan Dompdf:

CodeENV
LARAVEL_PDF_DRIVER=dompdf

Fungsi Dasar Merge PDF dengan FPDI

Ini adalah inti dari seluruh teknik yang akan digunakan di semua studi kasus berikutnya. Fungsi di bawah menerima array path file PDF dan menghasilkan satu file gabungan:

PHPPHP
use setasign\Fpdi\Fpdi;

function gabungkanPdf(array $daftarFile, string $pathOutput): void
{
    $pdf = new Fpdi();

    foreach ($daftarFile as $filePath) {
        $jumlahHalaman = $pdf->setSourceFile($filePath);

        for ($i = 1; $i <= $jumlahHalaman; $i++) {
            $templateId = $pdf->importPage($i);
            $dimensi    = $pdf->getTemplateSize($templateId);

            $pdf->AddPage($dimensi['orientation'], [$dimensi['width'], $dimensi['height']]);
            $pdf->useTemplate($templateId);
        }
    }

    $pdf->Output('F', $pathOutput);
}

Perhatikan baris $pdf->AddPage($dimensi['orientation'], [$dimensi['width'], $dimensi['height']]). Ini adalah bagian paling kritis. Jika tidak meneruskan orientasi dan ukuran dari halaman sumber, FPDI akan default ke A4 portrait — dan halaman landscape akan terpotong atau tertimpa. Selalu baca dimensi dari getTemplateSize() dan teruskan kembali saat menambah halaman baru.

FPDI versi gratis hanya bisa membaca file PDF yang tidak dienkripsi. Jika perlu merge PDF berpassword dari sistem eksternal, butuh FPDI PRO dari Setasign yang berbayar.

Studi Kasus: Laporan Keuangan Bulanan Perusahaan

Skenario ini umum di aplikasi ERP atau akuntansi: setiap akhir bulan, manajer perlu mengunduh paket laporan lengkap yang terdiri dari halaman ringkasan eksekutif (portrait), tabel transaksi detail (landscape karena banyak kolom), dan laporan rekonsiliasi (portrait). Ketiganya harus jadi satu file.

Generate Tiga Dokumen Terpisah

Buat Job yang bertanggung jawab menghasilkan dan menggabungkan ketiga dokumen ini:

PHPPHP
<?php

// app/Jobs/BuatLaporanKeuanganBulanan.php

namespace App\Jobs;

use App\Models\LaporanKeuangan;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use setasign\Fpdi\Fpdi;
use Spatie\LaravelPdf\Facades\Pdf;

class BuatLaporanKeuanganBulanan implements ShouldQueue
{
    use Queueable;

    public function __construct(
        protected LaporanKeuangan $laporan,
        protected string $periode, // format: '2026-05'
    ) {}

    public function handle(): string
    {
        $dirTemp = storage_path('app/temp');

        if (! is_dir($dirTemp)) {
            mkdir($dirTemp, 0755, true);
        }

        $fileSementara = [];

        try {
            $transaksi    = $this->laporan->transaksis()->wherePeriode($this->periode)->get();
            $rekonsiliasi = $this->laporan->rekonsiliasis()->wherePeriode($this->periode)->get();

            // Halaman ringkasan — portrait
            $fileRingkasan = $dirTemp . '/ringkasan_' . $this->laporan->id . '_' . time() . '.pdf';
            Pdf::view('pdf.keuangan.ringkasan', [
                'laporan'       => $this->laporan,
                'periode'       => $this->periode,
                'totalNilai'    => $transaksi->sum('nilai'),
                'jumlahEntri'   => $transaksi->count(),
            ])
            ->format('a4')
            ->save($fileRingkasan);
            $fileSementara[] = $fileRingkasan;

            // Tabel transaksi detail — landscape karena banyak kolom
            $fileTransaksi = $dirTemp . '/transaksi_' . $this->laporan->id . '_' . time() . '.pdf';
            Pdf::view('pdf.keuangan.transaksi', [
                'transaksi' => $transaksi,
                'periode'   => $this->periode,
            ])
            ->format('a4')
            ->landscape()
            ->save($fileTransaksi);
            $fileSementara[] = $fileTransaksi;

            // Laporan rekonsiliasi — portrait
            $fileRekonsiliasi = $dirTemp . '/rekonsiliasi_' . $this->laporan->id . '_' . time() . '.pdf';
            Pdf::view('pdf.keuangan.rekonsiliasi', [
                'rekonsiliasi' => $rekonsiliasi,
                'periode'      => $this->periode,
            ])
            ->format('a4')
            ->save($fileRekonsiliasi);
            $fileSementara[] = $fileRekonsiliasi;

            $fileGabungan = $this->gabungkanSemua($fileSementara, $dirTemp);

            foreach ($fileSementara as $file) {
                if (file_exists($file)) {
                    unlink($file);
                }
            }

            return $fileGabungan;
        } catch (\Exception $e) {
            foreach ($fileSementara as $file) {
                if (file_exists($file)) {
                    unlink($file);
                }
            }

            throw $e;
        }
    }

    private function gabungkanSemua(array $daftarFile, string $dirTemp): string
    {
        $pdf = new Fpdi();

        foreach ($daftarFile as $filePath) {
            $jumlahHalaman = $pdf->setSourceFile($filePath);

            for ($i = 1; $i <= $jumlahHalaman; $i++) {
                $tpl     = $pdf->importPage($i);
                $dimensi = $pdf->getTemplateSize($tpl);
                $pdf->AddPage($dimensi['orientation'], [$dimensi['width'], $dimensi['height']]);
                $pdf->useTemplate($tpl);
            }
        }

        $pathOutput = $dirTemp . '/laporan_' . $this->periode . '_' . uniqid() . '.pdf';
        $pdf->Output('F', $pathOutput);

        return $pathOutput;
    }
}

Dispatch dari Controller

Dari controller, cukup kirim job ke queue — tidak perlu menunggu prosesnya selesai:

PHPPHP
// app/Http/Controllers/LaporanKeuanganController.php

public function unduh(Request $request, LaporanKeuangan $laporan): \Illuminate\Http\JsonResponse
{
    $periode = $request->input('periode', now()->format('Y-m'));

    BuatLaporanKeuanganBulanan::dispatch($laporan, $periode)
        ->onQueue('laporan');

    return response()->json([
        'pesan' => 'Laporan sedang disiapkan. Kami akan kirim notifikasi saat selesai.',
    ]);
}

Hasilnya adalah satu PDF di mana halaman pertama portrait (ringkasan), diikuti halaman landscape (transaksi detail), dan ditutup halaman portrait (rekonsiliasi) — tanpa ada halaman yang terpotong atau salah orientasi.

Studi Kasus: Invoice + Lampiran Syarat Layanan

Pola ini sangat umum di aplikasi SaaS: setiap invoice yang dikirim ke klien harus disertai halaman Syarat & Ketentuan yang sudah berbentuk file PDF statis. Daripada mengirim dua file terpisah, kita gabungkan secara otomatis saat generate.

PHPPHP
<?php

// app/Jobs/BuatInvoiceLengkap.php

namespace App\Jobs;

use App\Models\Invoice;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use setasign\Fpdi\Fpdi;
use Spatie\LaravelPdf\Facades\Pdf;

class BuatInvoiceLengkap implements ShouldQueue
{
    use Queueable;

    public function __construct(protected Invoice $invoice) {}

    public function handle(): string
    {
        $dirTemp = storage_path('app/temp');

        if (! is_dir($dirTemp)) {
            mkdir($dirTemp, 0755, true);
        }

        // Generate invoice dari Blade view
        $fileInvoice = $dirTemp . '/invoice_' . $this->invoice->id . '_' . uniqid() . '.pdf';
        Pdf::view('pdf.invoice', ['invoice' => $this->invoice])
            ->format('a4')
            ->save($fileInvoice);

        // File S&K sudah ada di storage sebagai dokumen statis
        $fileSyaratLayanan = storage_path('app/statis/syarat-dan-ketentuan.pdf');

        // Gabungkan invoice + S&K
        $pdf = new Fpdi();

        foreach ([$fileInvoice, $fileSyaratLayanan] as $filePath) {
            $jumlahHalaman = $pdf->setSourceFile($filePath);

            for ($i = 1; $i <= $jumlahHalaman; $i++) {
                $tpl     = $pdf->importPage($i);
                $dimensi = $pdf->getTemplateSize($tpl);
                $pdf->AddPage($dimensi['orientation'], [$dimensi['width'], $dimensi['height']]);
                $pdf->useTemplate($tpl);
            }
        }

        $pathOutput = storage_path('app/invoices/invoice_' . $this->invoice->id . '_lengkap.pdf');
        $pdf->Output('F', $pathOutput);

        // Hapus file invoice sementara; file S&K statis tidak disentuh
        unlink($fileInvoice);

        return $pathOutput;
    }
}

FPDI tidak pernah memodifikasi file sumber — ia hanya membaca. Jadi file syarat-dan-ketentuan.pdf yang statis sama sekali tidak berisiko rusak meski dipanggil berkali-kali.

Studi Kasus: Merge File PDF Kiriman Pengguna

Skenario ketiga: aplikasi menyediakan fitur di mana pengguna bisa upload beberapa file PDF dari form, lalu mendownload hasilnya sebagai satu dokumen gabungan secara langsung.

PHPPHP
<?php

// app/Http/Controllers/GabungPdfController.php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use setasign\Fpdi\Fpdi;

class GabungPdfController extends Controller
{
    public function proses(Request $request): \Symfony\Component\HttpFoundation\BinaryFileResponse
    {
        $request->validate([
            'berkas'   => 'required|array|min:2',
            'berkas.*' => 'required|file|mimes:pdf|max:10240',
        ]);

        $dirTemp      = storage_path('app/temp');
        $berkasUpload = [];

        if (! is_dir($dirTemp)) {
            mkdir($dirTemp, 0755, true);
        }

        try {
            // Simpan semua upload ke lokasi temp dulu
            foreach ($request->file('berkas') as $berkas) {
                $path           = $berkas->store('temp');
                $berkasUpload[] = storage_path('app/' . $path);
            }

            // Gabungkan semua file yang diupload
            $pdf = new Fpdi();

            foreach ($berkasUpload as $filePath) {
                $jumlahHalaman = $pdf->setSourceFile($filePath);

                for ($i = 1; $i <= $jumlahHalaman; $i++) {
                    $tpl     = $pdf->importPage($i);
                    $dimensi = $pdf->getTemplateSize($tpl);
                    $pdf->AddPage($dimensi['orientation'], [$dimensi['width'], $dimensi['height']]);
                    $pdf->useTemplate($tpl);
                }
            }

            $pathGabungan = $dirTemp . '/gabungan_' . uniqid() . '.pdf';
            $pdf->Output('F', $pathGabungan);

            // Hapus file upload sementara
            foreach ($berkasUpload as $filePath) {
                if (file_exists($filePath)) {
                    unlink($filePath);
                }
            }

            // Stream langsung ke browser, hapus setelah download selesai
            return response()->download($pathGabungan, 'dokumen-gabungan.pdf')
                ->deleteFileAfterSend(true);
        } catch (\Exception $e) {
            foreach ($berkasUpload as $filePath) {
                if (file_exists($filePath)) {
                    unlink($filePath);
                }
            }

            throw $e;
        }
    }
}

deleteFileAfterSend(true) adalah fitur bawaan Laravel yang secara otomatis menghapus file dari disk begitu download selesai dikirim ke browser. Tidak perlu cleanup job terpisah untuk kasus ini.

Yang Perlu Diperhatikan di Production

Ada beberapa hal yang berulang kali menjadi sumber masalah jika tidak diperhatikan dari awal.

Nama file sementara harus unik. Jika dua pengguna memicu generate laporan bersamaan dan nama file-nya sama, mereka akan saling menimpa. Selalu sertakan ID model dan time() atau uniqid():

PHPPHP
// Aman untuk request bersamaan
$namaFile = 'invoice_' . $this->invoice->id . '_' . uniqid() . '.pdf';

// Berbahaya — race condition
$namaFile = 'invoice_temp.pdf';

Cleanup harus ada di blok catch. File sementara harus dihapus baik ketika proses berhasil maupun gagal. Jika cleanup hanya ada di happy path, folder storage/app/temp akan terus membesar diam-diam hingga akhirnya menyebabkan masalah disk di production.

Proses berat wajib masuk queue. Merge dokumen dengan banyak halaman atau gambar beresolusi tinggi bisa memakan waktu beberapa detik hingga puluhan detik. Jangan jalankan ini di dalam HTTP request. Kirim ke queue worker dan beri notifikasi ke pengguna via email atau websocket ketika selesai.

Untuk proyek yang butuh manipulasi PDF lebih lanjut seperti watermark, enkripsi, atau ekstraksi halaman selektif, pertimbangkan FPDI PRO dari Setasign atau eksplorasi package iio/libmergepdf sebagai alternatif modern.

Kesimpulan

Paduan spatie/laravel-pdf dan setasign/fpdi memberi kendali penuh atas dokumen PDF di Laravel — generate dengan cara yang sudah familiar (Blade view, data Eloquent), lalu gabungkan hasilnya tanpa binary eksternal. Dua hal yang paling menentukan keandalan di production: selalu bersihkan file sementara di semua jalur eksekusi (termasuk error), dan selalu jalankan proses merge berat di queue. Begitu dua itu beres, sisanya tinggal soal menyesuaikan Blade view dengan kebutuhan bisnis.

Referensi

  1. 1 Spatie laravel-pdf v2 — Dokumentasi Resmi
  2. 2 setasign/fpdi — GitHub Repository
  3. 3 Spatie laravel-pdf — Using the Dompdf Driver
Abd. Asis
Ditulis oleh
Abd. Asis

Software Developer dari Madura. Menulis tentang PHP, Laravel, dan pengembangan web modern dalam Bahasa Indonesia.