daltcore/lara-pdf-merger
Laravel package for merging multiple PDF files into a single document. Offers a simple API to combine PDFs, reorder or select pages, and output the merged file, making PDF assembly easy within Laravel applications.
Requirements check first. This package targets PHP 7.4 and Laravel 7 only, and last shipped in May 2020. Before composer require, confirm you are actually on that stack — on Laravel 8+ the service provider and facade registration will usually install but the underlying PDF library constraints (composer.json pins to old illuminate/support and often setasign/fpdi/iio/libmergepdf era versions) will fight your lockfile.
composer require daltcore/lara-pdf-merger
Package discovery should register the service provider automatically. If you're on an older Laravel without discovery, add it to config/app.php manually — the provider and facade class names are visible in vendor/daltcore/lara-pdf-merger/composer.json under extra.laravel. Publish any shipped config:
php artisan vendor:publish --provider="Daltcore\LaraPdfMerger\PdfMergerServiceProvider"
Where to look first. Read, in this order:
vendor/daltcore/lara-pdf-merger/src/ — the facade's getFacadeAccessor() tells you the container binding, and the single service class is usually <300 lines. You can read the whole package in ten minutes, which you should, because there is no meaningful documentation beyond the README.composer.json require) — that's where the real behaviour, page-range syntax, and failure modes live. The Laravel package is a thin shell.config/ for temp paths, binary paths, or driver settings.First use case. Merge two PDFs already sitting on a Laravel disk into one file:
use Daltcore\LaraPdfMerger\Facades\PdfMerger; // verify exact namespace in src/
$merger = PdfMerger::addPDF(Storage::path('invoices/a.pdf'), 'all')
->addPDF(Storage::path('invoices/b.pdf'), 'all');
$merger->merge();
$merger->save(Storage::path('invoices/merged.pdf'));
// or hand it straight back to the browser
return $merger->download();
The pattern is always the same three beats: add inputs → merge → save/stream. Everything else in this assessment is about those three steps behaving in production.
Wrap the facade in your own service. The facade is static and almost certainly holds mutable state on a singleton (the accumulated file list). That makes it dangerous in loops and painful to test. Bind your own thin class:
class PdfMergeService
{
public function merge(array $absolutePaths, string $outputPath): string
{
$merger = PdfMerger::addPDF($absolutePaths[0], 'all');
foreach (array_slice($absolutePaths, 1) as $path) {
$merger->addPDF($path, 'all');
}
$merger->merge();
$merger->save($outputPath);
return $outputPath;
}
}
Resolve a fresh instance per operation (app(PdfMergeService::class) inside the job, not a constructor-injected singleton) to avoid one request's PDFs leaking into the next, especially under Octane or queue workers.
Resolve disk paths, never URLs. Every merger of this vintage wants a real local filesystem path. Storage::url() and remote disks (S3) will not work. Use Storage::path() / Storage::disk('local')->path() for local disks, or download to a tmp file first:
$tmp = tempnam(sys_get_temp_dir(), 'pdf') . '.pdf';
file_put_contents($tmp, Storage::disk('s3')->get($key));
Controller pattern with cleanup. Stream the merge result and let Laravel delete the temp artefact:
public function download(Order $order)
{
$path = app(PdfMergeService::class)->merge(
$order->documents->map(fn ($d) => Storage::path($d->path))->all(),
Storage::path("tmp/order-{$order->id}.pdf")
);
return response()->download($path, "order-{$order->id}.pdf")
->deleteFileAfterSend(true);
}
deleteFileAfterSend(true) is the single most useful line here — merger temp files accumulate fast otherwise.
Queue large merges. Merging is CPU- and memory-bound and blocks the request. Push it to a job, write to a private disk, and notify/poll. Set the job's $timeout and memory_limit explicitly, and give the job a unique temp path so retries don't collide.
Merge generated PDFs with uploaded ones. A common real workflow: render a cover sheet with dompdf/snappy, then append the user's uploaded attachments. Save the generated PDF to a temp path first — most mergers cannot consume a Response or raw string, only a path or a stream resource.
Selective page merging. Most wrappers expose a page-range string as the second argument ('all', '1-3', '2,4,7'). Confirm the exact syntax against the underlying library — this is the number one README gap. Always validate ranges against the source document's page count before merging, or you'll get a partial file with no error.
Testing. Two layers:
PdfMerger::shouldReceive('addPDF')->andReturnSelf() etc., asserting your service composes calls correctly.setasign/fpdi or pdfinfo from poppler-utils). Assert page count and non-emptiness, not byte equality — PDF output is not deterministic.Archived and unmaintained — treat it as vendored code. 28 stars, one release in 2020, archived. Pin the exact version, commit composer.lock, and plan an exit. Realistically:
app/Support/ and drop the dependency. You lose nothing and gain the ability to fix it.illuminate/support constraint so it survives your next Laravel upgrade.setasign/fpdi (or iio/libmergepdf), which is what this package almost certainly wraps anyway.Install failures on modern stacks. Composer will refuse on Laravel 8+ because of the illuminate/support version pin. --ignore-platform-reqs gets you past install but not past runtime — Laravel 9 removed the str_*/array_* helpers era assumptions, and Facade internals shifted. Don't force it onto L9/L10; migrate instead.
Encrypted or malformed PDFs throw mid-merge. A password-protected or corrupt input will surface as a generic exception from the underlying library, often after some files are already written. Validate inputs up front (try opening each with FPDI, or pdfinfo), and wrap the whole merge in a try/catch that deletes the partial output.
Silent partial output. If one addPDF call fails to resolve a path, some versions skip it rather than throwing — you get a PDF missing pages and no error. Assert the result's page count equals the sum of inputs:
$expected = collect($inputs)->sum(fn ($p) => $this->pageCount($p));
abort_if($this->pageCount($output) !== $expected, 500, 'PDF merge incomplete');
Memory on big documents. Mergers that buffer whole documents in memory blow up around 100–200 MB of input with PHP's default 128M memory_limit. Raise it inside the job (ini_set('memory_limit', '512M')) or reduce to merging in batches. Ghostscript-based paths are more memory-friendly but add a binary dependency.
Binary dependencies differ between local and container. If the underlying driver shells out (Ghostscript, pdftk), it works on your Mac and fails in the php:7.4-fpm image. Install the binary in the Dockerfile, pin its path in config, and add a health check that verifies the binary exists at boot — not at first user request.
Config caching bites. If you publish a config with temp paths and later run php artisan config:cache, changes to .env won't take effect. Standard Laravel rule, but especially painful here because the failure mode is "writes PDFs to a path that no longer exists."
File permissions on storage/app and /tmp. The merge writes intermediates. In containers with a read-only root filesystem, /tmp may not be writable — point the package at a known-writable storage/app/tmp directory and make sure it exists (Storage::makeDirectory) before the first merge.
Extension points are thin. The realistic extension surface is: (a) the container binding of the merger service — swap it for your own decorator that adds logging, page-count validation, and cleanup; (b) the published config for paths/driver selection. There is no event system or driver contract worth relying on, so don't build architecture around overriding internals.
Debugging. When a merge fails opaquely, isolate it from Laravel entirely: run the same inputs through the underlying library in php artisan tinker with display_errors on. If it fails there too, it's a PDF/dependency problem, not a Laravel integration problem — and that distinction saves an hour every time.
How can I help you explore Laravel packages today?