zendframework/zendpdf
ZendPdf is a PHP library for creating and manipulating PDF documents. It supports drawing text, images, and shapes, page management, and importing existing PDFs. Originally part of Zend Framework, it is a legacy component and no longer actively maintained.
zendframework/zendpdf is the standalone extraction of ZF1's Zend_Pdf: a pure-PHP library that can both write PDFs from scratch and load, modify, and re-save existing PDFs. It needs no GD, no ImageMagick, and no headless browser for text-and-vector work. That "load an existing PDF" capability is its main differentiator versus dompdf/mpdf.
Two-minute setup:
composer require zendframework/zendpdf
Composer will print an "abandoned" warning — expected, see Gotchas. Then generate a file:
use ZendPdf\PdfDocument;
use ZendPdf\Page;
use ZendPdf\Font;
use ZendPdf\Color\Html;
$pdf = new PdfDocument();
$page = $pdf->newPage(Page::A4); // also appended to $pdf->pages
$page->setFont(Font::fontWithName(Font::FONT_HELVETICA), 11);
$page->setFillColor(new Html('#222222'));
$y = $page->getHeight() - 50; // origin is bottom-left, y grows up
$page->drawText('Invoice #1042', 40, $y, 'UTF-8');
$pdf->save(storage_path('app/invoices/1042.pdf'));
Where to look first: the five classes you will use 95% of the time are PdfDocument (pages, load/save/render), Page (all drawing methods), Font (fontWithName / fontWithPath), Color\* (Html, Rgb, GrayLevel), and Image::imageWithPath(). Read the Page class methods once end-to-end — that is the API surface. Skip everything else.
First real use case: invoice or report generation where you already have a letterhead/stationery PDF. Load the template, overlay the variable data, save. That workflow is where this package still earns its keep.
Template overlay (the killer feature). Load a static PDF, clone the page into a fresh document, draw on top:
$template = PdfDocument::load(resource_path('pdf/letterhead.pdf'));
$pdf = new PdfDocument();
$page = clone $template->pages[0]; // clone: never mutate the source object
$pdf->pages[] = $page;
$page->setFont(Font::fontWithName(Font::FONT_HELVETICA), 10);
$page->drawText($customer->name, 60, $page->getHeight() - 180, 'UTF-8');
$pdf->save($out);
Multi-page reports with a cursor helper. There is no flow layout, so wrap it once:
final class Cursor
{
public function __construct(
private PdfDocument $pdf,
private Font $font,
private float $size = 10,
private float $left = 40,
private float $bottom = 50,
) {}
public function line(string $text): void
{
$page = end($this->pdf->pages) ?: $this->pdf->newPage(Page::A4);
if ($this->y < $this->bottom) {
$page = $this->pdf->newPage(Page::A4);
}
$page->setFont($this->font, $this->size);
$page->drawText($text, $this->left, $this->y, 'UTF-8');
$this->y -= $this->size * 1.45;
}
}
Initialise $this->y = $page->getHeight() - 60 and reset it on every newPage().
Laravel wiring. There is no Laravel integration and you don't need one — a single injectable service is enough:
class InvoicePdf
{
public function render(Invoice $invoice): string
{
// build $pdf ...
return $pdf->render(); // raw bytes
}
}
return response($invoicePdf->render(), 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="invoice.pdf"',
]);
For big documents prefer save() to a temp path and response()->file($path) so you aren't holding the rendered string plus the document in memory.
Images. Dimensions are in points, not pixels — convert explicitly:
$img = Image::imageWithPath(public_path('logo.png'));
$w = 120; // points
$h = $w * $img->getPixelHeight() / $img->getPixelWidth();
$page->drawImage($img, 40, $page->getHeight() - 40 - $h, $w, $h);
Merging documents. Push page objects from several sources into one PdfDocument's pages array (clone them if the source may outlive the merge). Useful for concatenating pre-rendered sections — e.g. a cover page from one file plus body pages from another.
Custom fonts for anything non-ASCII. Font::fontWithPath(base_path('resources/fonts/DejaVuSans.ttf')) embeds and subsets a TrueType font; then drawText(..., 'UTF-8') produces correct glyphs. Do this the moment you need €, accents, or non-Latin text.
each(), curly-brace string offsets, dynamic properties). Run a smoke test on your target PHP before designing around it, and isolate it behind a PdfGenerator interface so swapping to tecnickcom/tcpdf, mpdf, or Browsershot is a one-file change.getHeight() - offset conversion once in a helper and never inline it again.drawText positions the text baseline, not the top of the glyphs. Reserve ~0.75 × fontSize of leading or your first line will look clipped against a border.unitsPerEm and glyph metrics; in practice most teams precompute approximate character widths or use a monospace font for tabular columns.newPage() registers the page for you ($pdf->pages[] happens inside). Calling it and then also appending manually produces blank pages.$template->pages[0] directly mutates the loaded document; if you cached it or reuse it across a request, the previous overlay bleeds through.load() will throw a ZendPdf\Exception\RuntimeException on anything structural it doesn't understand. Catch broadly around load() and fall back to generating from scratch.imageWithPath().save('/tmp/out.pdf') and inspect with pdfinfo, pdftotext, or open in a viewer. When text "disappears", it is almost always a y-coordinate below zero, a missing setFont() on a new page, or a fill colour matching the background.Page::A4, LETTER, …) are the reliable route; if you need landscape, create the page then check getWidth()/getHeight() and lay out against those rather than assuming a fixed 595×842.render() vs save() — render() returns the full byte string; for anything above a few MB, write to disk and stream the file.How can I help you explore Laravel packages today?