Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Pdf Merger Laravel Package

grofgraf/laravel-pdf-merger

Laravel PDF Merger offers a simple API for combining multiple PDF files into a single document within Laravel apps. It supports stored or uploaded PDFs and integrates with Laravel's filesystem for straightforward document assembly.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Check compatibility before installing. The last release is March 2019, so the composer.json constraints almost certainly point at Laravel 5.x and PHP 7.0–7.2. Before you run anything, open the repo's composer.json on GitHub (or composer show grofgraf/laravel-pdf-merger --all) and compare the illuminate/support and php constraints against your app. On Laravel 9+ you have three realistic paths, in order of preference:

  • Skip the wrapper. The package is a thin Laravel service-provider wrapper around iio/libmergepdf (the actual merging engine). Installing composer require iio/libmergepdf directly gives you the same capability with a maintained dependency and no framework-version constraint.
  • Fork and bump constraints in your own composer.json repository entry.
  • Install with --ignore-platform-reqs only if you accept the risk and have tests covering the merge path.

2. Install and register (Laravel ≤ 5.4, or if discovery is absent).

composer require grofgraf/laravel-pdf-merger

If the package predates package auto-discovery, add the provider to config/app.php:

'providers' => [
    // ...
    Grofgraf\LaravelPdfMerger\PdfMergerServiceProvider::class,
],
'aliases' => [
    'PdfMerger' => Grofgraf\LaravelPdfMerger\Facades\PdfMerger::class,
],

3. Read the source first. The entire public API is one class and a facade — vendor/grofgraf/laravel-pdf-merger/src/. It will take five minutes to read and will tell you definitively whether merge() returns the PDF as a string or writes to a path, whether addFile() takes a page-range argument, and whether a config file is publishable. Do this before writing any application code; the package has no real documentation and the README is minimal.

4. First use case — merge two stored PDFs into a download. This is the workflow you will actually ship on day one (combining an invoice with a terms-and-conditions page):

use Illuminate\Support\Facades\Storage;

$merger = app(\Grofgraf\LaravelPdfMerger\PdfMerger::class); // or PdfMerger::make()

$merger->addFile(Storage::path('invoices/'.$invoice->id.'.pdf'));
$merger->addFile(storage_path('app/static/terms.pdf'));

$binary = $merger->merge(); // verify: string vs. file path

return response($binary)
    ->header('Content-Type', 'application/pdf')
    ->header('Content-Disposition', 'inline; filename="invoice-'.$invoice->id.'.pdf"');

Once that round-trips, everything else in this package is the same call with a different file list.

Implementation Patterns

Wrap it behind your own interface from the first commit. The package is a single-author 2019 wrapper with 28 stars; the probability you outlive it is high. Define a contract in your domain and bind it in a service provider:

// app/Contracts/PdfMerger.php
interface PdfMergerContract
{
    public function mergeFiles(array $absolutePaths, ?string $outputPath = null): string;
}

// app/Services/PdfMergerService.php
class PdfMergerService implements PdfMergerContract
{
    public function mergeFiles(array $absolutePaths, ?string $outputPath = null): string
    {
        $merger = app(\Grofgraf\LaravelPdfMerger\PdfMerger::class);

        foreach ($absolutePaths as $path) {
            $merger->addFile($path);
        }

        $binary = $merger->merge();

        if ($outputPath !== null) {
            Storage::put($outputPath, $binary);
        }

        return $binary;
    }
}

Everything below then depends only on your contract, so swapping to iio/libmergepdf directly — or to qpdf via Process — is a one-class change.

Ordering is the API. There is no sort parameter; output page order is exactly the order of addFile() / addRaw() calls. Drive the list from data with an explicit sort_order column rather than relying on insertion order:

$paths = $batch->documents()
    ->orderBy('sort_order')
    ->pluck('path')
    ->map(fn ($p) => Storage::path($p))
    ->all();

$merged = app(PdfMergerContract::class)->mergeFiles($paths, "batches/{$batch->id}.pdf");

Merge stored files with generated ones in the same pass. The package handles both stored paths and raw PDF bytes (that's the "flexible input" claim). Use the raw-input method to prepend a cover sheet produced by barryvdh/laravel-dompdf without ever writing it to disk:

$cover = PDF::loadView('pdf.cover', ['batch' => $batch])->output(); // string

$merger->addRaw($cover);                    // verify method name: addRaw/addString
$merger->addFile(Storage::path('reports/q1.pdf'));
$merger->addFile(Storage::path('reports/q2.pdf'));

Storage::put("batches/{$batch->id}.pdf", $merger->merge());

Push large merges into a queued job. Merging is CPU- and memory-bound and synchronous. Anything over a handful of documents belongs in a job with a ShouldQueue batch, writing to storage, then notifying the user:

class MergeInvoiceBatch implements ShouldQueue
{
    public $timeout = 120;
    public $tries = 1;

    public function __construct(public int $batchId) {}

    public function handle(PdfMergerContract $merger): void
    {
        $batch = InvoiceBatch::with('documents')->findOrFail($this->batchId);

        $merger->mergeFiles(
            $batch->documents->sortBy('sort_order')->map->absolutePath()->all(),
            "batches/{$batch->id}.pdf"
        );

        $batch->update(['status' => 'ready']);
    }
}

Serve the result without buffering twice. Because the library returns a string, you already hold the whole PDF in memory. For downloads, prefer response()->file() off a temp/local disk (the framework streams it and handles range requests) over response($binary):

return response()->file(Storage::path("batches/{$batch->id}.pdf"), [
    'Content-Type' => 'application/pdf',
]);

Use the in-memory string only when the file is small or you need to attach it to a mail via Attachment::fromData().

Test with fixture PDFs, not mocks. Put two tiny PDFs in tests/Fixtures/ and assert on the merged output:

Storage::fake('local');

$service = app(PdfMergerContract::class);
$out = $service->mergeFiles([
    base_path('tests/Fixtures/a.pdf'),
    base_path('tests/Fixtures/b.pdf'),
]);

$this->assertStringStartsWith('%PDF-', $out);
$this->assertSame(2, substr_count($out, '/Type /Page')); // crude page count; use FPDI/pdfinfo for accuracy

This is your regression net against a package that will never ship one.

Normalise inputs up front. If you accept user-uploaded PDFs anywhere in the pipeline, add a validation step that opens each file with your merger before accepting it — the failure mode for incompatible inputs is an exception at merge time, far from the upload.

Gotchas and Tips

FPDI is underneath, and it is picky. The merging engine parses PDFs page by page. Files using PDF 1.5+ compressed object streams / cross-reference streams, or produced by certain modern generators, fail with errors like "Unable to find xref table" or "Cannot read PDF". This is the number-one support issue for every FPDI-based merger. Mitigation: normalise offending files first with qpdf --qdf or gs -sDEVICE=pdfwrite and cache the normalised copy. Test with a real file from each of your generators (DomPDF, Snappy/wkhtmltopdf, TCPDF, a scanned document) before assuming it works.

Encrypted or password-protected PDFs will not merge. There is no meaningful password-handling surface here. Reject or decrypt (via qpdf --decrypt) upstream.

Everything is in memory. merge() concatenates the entire output as a string before returning. A 200-page merged report can easily be 50–200 MB of PHP string plus FPDI's parse overhead. Raise memory_limit for the job/command, or write to a file and unset($binary) immediately after. Never hold both the merged string and a Storage::get() copy simultaneously.

Storage::path() only works on local disks. For S3 or any remote disk, download to a temp file first and clean up afterwards — the merger needs a filesystem path, not a stream:

$tmp = tempnam(sys_get_temp_dir(), 'pdf_').'.pdf';
file_put_contents($tmp, Storage::disk('s3')->get($key));

try {
    $merger->addFile($tmp);
    // ...
} finally {
    @unlink($tmp);
}

Watch for stateful instances. If the class is registered as a singleton, or you resolve the facade twice in one request, the file list accumulates across calls and you get duplicated pages. Always resolve a fresh instance per merge (app()->make() on a non-shared binding, or explicitly reset), and write a test that merges twice in a row and asserts page counts for both.

Page ranges are 1-based and easy to get wrong. If the API exposes page selection (e.g. addFile($path, '2-4')), confirm whether omissions mean "all pages" or "first page only" by testing a 3-page fixture. Off-by-one here silently ships wrong invoices to customers.

Do not echo or leave whitespace before output. The most common "corrupt PDF" report is a stray byte from a ?> closing tag, a BOM in a Blade view, or debug output before the response. The PDF will download, look fine in a browser, and fail in an enterprise PDF reader. Use response()->file() / streamDownload() and no intermediate output.

Read errors as parse errors, not library errors. When merging throws, log the input filenames and sizes alongside the exception. Nine times out of ten the stack trace points into vendor code and tells you nothing; the useful signal is which of the eight files is the broken one.

Pin the version and record why. Add it to composer.json with an exact or tilde-pinned constraint and a comment in a docs/adr/ note explaining that the wrapper is unmaintained. That note is what saves the next developer (or future you) from a two-hour archaeology session when a Laravel major upgrade breaks the install.

Confirm merge() semantics before building on them. Some wrappers return the binary string; some write to a path and return a boolean. Getting this wrong produces an empty file with no exception. dd(bin2hex(substr($result, 0, 8))) should start with 255044462d (%PDF-). Do it once, in a tinker session, before you write the service class.

Extension points are essentially nil. There is no plugin system, no events, no driver abstraction. If you need compression, watermarking, or per-document metadata, do it before the merge (on inputs) or after (with qpdf/pdftk in a post-processing step). Treat this package as exactly what it is — an addFile() accumulator and a merge() call — and keep all orchestration in your own code.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource