tomsgu/pdf-merger
A Laravel/PHP package for merging multiple PDF files into one document. It offers a simple API to combine PDFs, control page order, and add PDF merging to your workflows with minimal setup.
Install it as a normal Composer dependency — it is framework-agnostic and ships no Laravel service provider:
composer require tomsgu/pdf-merger
It pulls in setasign/fpdi and setasign/fpdf transitively, so you get PDF parsing (FPDI) and PDF writing (FPDF) without wiring anything else up. Requirements are modest: PHP >= 7.2 plus the usual FPDF/FPDI extensions (zlib, mbstring, and GD if you later want images in the output).
Where to look first:
README.md — the entire public surface is there in ~20 lines.src/PDFMerger.php — the whole class. Expect essentially two meaningful methods: addPDF($filePath, $pages = 'all') and merge($outputMode = 'browser', $outputPath = 'output/default.pdf').src/PDFMergerInterface.php (if present in your tagged version) — use it for type-hinting in your own wrappers.First use case — combine a contract and its appendix into a single file inside a Laravel app:
use Tomsgu\PDFMerger\PDFMerger;
$pdf = new PDFMerger();
$pdf->addPDF(storage_path('app/contracts/base.pdf'), 'all');
$pdf->addPDF(storage_path('app/contracts/appendix.pdf'), '1-3');
$pdf->merge('file', storage_path('app/contracts/merged.pdf'));
Page selection accepts 'all', a single page ('2'), a list ('1, 3, 4'), or ranges ('1-3'). Page numbers are 1-based, matching what readers show, not what the PDF's internal /Pages array shows.
For an HTTP download, prefer the string mode over the browser mode so you stay inside Laravel's response lifecycle:
$binary = $pdf->merge('string');
return response($binary)
->header('Content-Type', 'application/pdf')
->header('Content-Disposition', 'attachment; filename="merged.pdf"');
Bind it, don't new it everywhere. Even though there's no provider, register a small factory or a scoped binding in AppServiceProvider so you can swap/test it:
$this->app->bind(PDFMergerFactory::class, fn () => new class {
public function make(): PDFMerger { return new PDFMerger(); }
});
Because PDFMerger accumulates state (addPDF calls queue up files), never reuse an instance across requests — always construct fresh per merge.
Wrap the merge in a domain service. A ContractMerger or DocumentAssembler class keeps controller code clean and gives you a single place to normalise paths:
class DocumentAssembler
{
public function merge(array $paths, string $disk = 'local'): string
{
$pdf = new PDFMerger();
foreach ($paths as $path) {
$pdf->addPDF(Storage::disk($disk)->path($path), 'all');
}
$pdf->merge('file', $out = Storage::disk($disk)->path('tmp/'.uniqid().'.pdf'));
return $out;
}
}
merge('file', ...) writes to disk; merge('string') returns the raw bytes in memory. Use file for anything over a few MB, then stream the result with response()->file() or Storage::download().
Queue large merges. Merging is CPU- and memory-bound inside the PHP process (FPDI parses each source document fully). Move multi-hundred-page or batch merges into a ShouldQueue job, write the result to storage/app/private/, and notify the user via a broadcast or database notification. This also keeps request timeouts out of the picture.
Compose with a page-level builder. A common pattern is a fluent wrapper that records (path, pages) tuples, then flushes them to PDFMerger in one shot — useful for template-driven documents (cover page → body → terms → signature page).
Pair with barryvdh/laravel-dompdf or spatie/laravel-pdf for the "generate then combine" workflow: render HTML sub-documents to temp PDFs, then feed them to PDFMerger. Keep temp files on the local disk and clean them in a finally block.
addPDF(). User-uploaded PDFs from scanners and office suites are the usual offenders — catch the exception and surface a friendly error rather than a 500.merge('file', ...) will not create parent directories. Call File::ensureDirectoryExists() first; a missing path is a silent-looking failure mode rather than an obvious one.merge('browser') sends headers. It calls header() directly. In Laravel this bypasses the response layer, so it breaks under test (headers already sent) and under middleware that decorates responses. Use 'string' or 'file' in application code; reserve browser/download for throwaway scripts.memory_limit.Storage instance or a URL. Build it with Storage::disk('local')->path($relative) — storage_path('app/'.$relative) works too but hardcodes the app directory, which bites when tests use a different disk root.PDFMerger, override the merge routine, or set the properties on the underlying FPDF instance after construction — but the public API is thin, so an extension point here means subclassing, not configuration.composer.json and add a smoke test that runs an actual merge against a fixture PDF in CI. This catches upstream FPDI/FPDF breakage after composer update.merge('string') returns the binary, so assert with str_starts_with($binary, '%PDF-') rather than snapshotting bytes. Keep one tiny (2-page) fixture in tests/Fixtures/ — FPDI cannot parse some synthetic PDFs produced by naive generators, so generate your fixture with a real tool like LibreOffice or Dompdf.How can I help you explore Laravel packages today?