DEV Community

Cover image for Generate, read, and merge PDFs in PHP — no GPL, no FPDI
Denis Skripchenko
Denis Skripchenko

Posted on

Generate, read, and merge PDFs in PHP — no GPL, no FPDI

Every PHP project eventually needs a PDF — an invoice, a ticket, a report, a
signed contract. And every time, you hit the same two walls:

  • Generating PDFs usually means mpdf, which is GPL-2.0. Fine for internal tools, a licensing headache the moment you ship it inside a commercial, OEM, or on-premise product.
  • Merging or importing existing PDFs (stamp a watermark, prepend a cover, overlay a letterhead) means FPDI — and FPDI's PDF-parsing add-on is proprietary.

dskripchenko/php-pdf is a
pure-PHP, MIT-licensed toolkit that does both — generate and read/merge —
with no native binaries and no add-ons. It runs anywhere your Laravel app runs
(only ext-mbstring, ext-dom, ext-zlib; ext-openssl for AES/signing).

composer require dskripchenko/php-pdf
Enter fullscreen mode Exit fullscreen mode

Generating a PDF

Three entry points, from highest to lowest level. The quickest is HTML/CSS:

use Dskripchenko\PhpPdf\Document;

$pdf = Document::fromHtml(<<<HTML
<h1>Invoice #1042</h1>
<p>Customer: <strong>Acme Corp</strong></p>
<table>
  <thead><tr><th>Item</th><th>Price</th></tr></thead>
  <tbody>
    <tr><td>Consulting</td><td>$1,200.00</td></tr>
    <tr><td>Hosting</td><td>$99.00</td></tr>
  </tbody>
</table>
HTML)->toBytes();
Enter fullscreen mode Exit fullscreen mode

There's also a fluent DocumentBuilder and a low-level page API, plus 16
barcode formats, 8 chart types, TTF embedding with kerning, AcroForm fields,
PKCS#7 signing, and PDF/A & PDF/X conformance. But generation isn't the news
here — reading and merging is.

New in 1.1: reading & merging

Merge and reorder pages

PdfMerger concatenates whole or selected pages from any mix of files and
in-memory bytes:

use Dskripchenko\PhpPdf\Pdf\Merge\{PdfMerger, PdfSource};

PdfMerger::create()
    ->append(PdfSource::fromFile('cover.pdf'))
    ->append(PdfSource::fromBytes($invoice))          // the bytes we just generated
    ->append(PdfSource::fromFile('terms.pdf'), pages: [1, 2])
    ->toFile('packet.pdf');
Enter fullscreen mode Exit fullscreen mode

Page annotations and bookmarks are carried over by default, with internal links
and named destinations remapped to the new pages.

Stamp a watermark or letterhead

stamp() draws a source page over the output pages as a reusable Form XObject —
perfect for a "DRAFT" watermark or a corporate letterhead:

use Dskripchenko\PhpPdf\Pdf\Merge\{PdfMerger, PdfSource, Placement};

PdfMerger::create()
    ->append(PdfSource::fromFile('contract.pdf'))
    ->stamp(PdfSource::fromFile('draft-watermark.pdf'), placement: Placement::fit())
    ->toFile('contract-draft.pdf');
Enter fullscreen mode Exit fullscreen mode

Placement gives you fit() (scale to page, centered), stretch(), or
at($x, $y, $scale) for pixel-precise positioning.

FPDI-style: drop an imported page into a generated document

Sometimes you want to combine an existing PDF with freshly generated
content — e.g. print "PAID" and a QR code onto a scanned letterhead. Import the
page as a Form XObject and draw over or under it with the normal API:

use Dskripchenko\PhpPdf\Pdf\Document;
use Dskripchenko\PhpPdf\Pdf\StandardFont;
use Dskripchenko\PhpPdf\Pdf\Merge\PageImporter;
use Dskripchenko\PhpPdf\Pdf\Reader\ReaderDocument;

$src = ReaderDocument::fromBytes(file_get_contents('letterhead.pdf'));
[$w, $h] = PageImporter::pageSize($src, 0);

$doc  = new Document();
$page = $doc->addPage(customDimensionsPt: [$w, $h]);

$form = PageImporter::intoDocument($doc, $src, 0);
$page->useFormXObject($form, 0, 0, $w, $h);                 // imported page as background
$page->showText('PAID', 400, 750, StandardFont::Helvetica, 28); // your content on top

$doc->toFile('paid.pdf');
Enter fullscreen mode Exit fullscreen mode

Rotation and crop boxes are handled automatically; the imported page's fonts and
images are copied verbatim.

A Laravel example: batch invoices into one file

A common real-world job: generate a PDF per invoice, then merge the batch into a
single file for download or email. In a queued job:

use Illuminate\Contracts\Queue\ShouldQueue;
use Dskripchenko\PhpPdf\Document;
use Dskripchenko\PhpPdf\Pdf\Merge\{PdfMerger, PdfSource};

class BuildInvoiceBatch implements ShouldQueue
{
    /** @param \App\Models\Invoice[] $invoices */
    public function __construct(
        private array $invoices,
        private string $outputPath,
    ) {}

    public function handle(): void
    {
        $merger = PdfMerger::create();

        foreach ($this->invoices as $invoice) {
            $pdf = Document::fromHtml(view('pdf.invoice', ['invoice' => $invoice])->render())
                ->toBytes();

            $merger->append(PdfSource::fromBytes($pdf));
        }

        $merger->toFile($this->outputPath);
    }
}
Enter fullscreen mode Exit fullscreen mode

One Blade view for the invoice, one PdfMerger for the batch — no shell-outs,
no wkhtmltopdf binary, no GPL.

Reading, too

Need to inspect an incoming PDF before merging? ReaderDocument parses it —
classic and stream cross-references, object streams, corrupt-xref recovery, and
decryption (RC4 / AES-128 / AES-256, user and owner password):

use Dskripchenko\PhpPdf\Pdf\Reader\ReaderDocument;

$doc = ReaderDocument::fromBytes(file_get_contents('protected.pdf'), password: 'secret');

echo $doc->pageCount();
foreach ($doc->pages() as $page) {
    printf("%.0f × %.0f pt\n", $page->width(), $page->height());
}
Enter fullscreen mode Exit fullscreen mode

Encrypted input is decrypted transparently and re-emitted unencrypted in the
merged output.

What it handles (and doesn't, yet)

The reader is validated against a third-party corpus — LibreOffice, Google Docs,
Ghostscript, pdfTeX, ImageMagick, Qt/pdfkit, FPDF2, pypdf — with CI on PHP
8.2 / 8.3 / 8.4 and a PHPStan level-6 gate. v1 merging carries content, images,
fonts, annotations, and bookmarks; it does not yet carry AcroForm field
trees or structure tags. It's all documented in the
reading & merging guide.

Try it

If your PHP app generates PDFs and you've been paying the mpdf/FPDI licensing tax
— or shelling out to a native binary — this might be worth a look. Feedback and
issues welcome.

Top comments (0)