Lesson 33 • Advanced
PDF Generation & File Exporting 📄
By the end of this lesson you'll turn HTML and CSS into a polished invoice PDF, stamp every page with a header, footer and page number, drop in a logo, and choose between streaming a download or saving the file — the exact skills behind every "Download as PDF" button you've ever clicked.
What You'll Learn in This Lesson
- Pick the right PDF library — Dompdf, mPDF, or TCPDF/FPDF — for the job
- Generate a styled invoice from an HTML + CSS template with Dompdf
- Add a header, footer, page numbers and a logo image with TCPDF
- Embed images and Unicode characters without getting empty boxes
- Choose between streaming a download and saving the PDF to disk
- Avoid the memory, font and "output before stream" gotchas that break PDFs
composer require shown above each example. The Output panel shows what each script prints to the terminal; the PDF itself lands next to your script.1️⃣ Choosing a PDF Library
PHP has no built-in PDF maker, so you add one with Composer (PHP's package manager). The three families you'll meet: Dompdf and mPDF turn an HTML + CSS template into a PDF — ideal when you can describe the document as a web page. TCPDF and the classic FPDF are programmatic: you place each piece of text, box and image at exact coordinates. mPDF is the pick when you need strong Unicode or right-to-left scripts like Arabic. Start from how you want to describe the page, and the choice falls out.
<?php
// PHP can't make a PDF on its own — you pull in a library with Composer.
// The right one depends on HOW you want to describe the document.
// 1) Dompdf — you already have an HTML + CSS template? Convert it.
// composer require dompdf/dompdf
//
// 2) mPDF — like Dompdf but better Unicode + RTL (Arabic/Hebrew) support.
// composer require mpdf/mpdf
//
// 3) TCPDF / FPDF — you place every line, box and image by coordinate.
// composer require tecnickcom/tcpdf // TCPDF: feature-rich
// composer require setasign/fpdf // FPDF: small + classic
// Rule of thumb, printed so you remember it:
echo "Have an HTML template? -> Dompdf or mPDF\n";
echo "Need Arabic / emoji / RTL? -> mPDF\n";
echo "Need pixel-exact layout? -> TCPDF or FPDF\n";Have an HTML template? -> Dompdf or mPDF
Need Arabic / emoji / RTL? -> mPDF
Need pixel-exact layout? -> TCPDF or FPDF2️⃣ HTML → PDF: an Invoice with Dompdf
This is the most common real-world path. You build the invoice as a normal HTML string with a <style> block, then Dompdf renders it to PDF bytes. Two things to notice: the CSS lives inside the HTML (Dompdf won't fetch a separate .css file), and output() hands you the raw bytes so you decide whether to save, stream, or email them.
<?php
// Dompdf renders an HTML + CSS string into a PDF.
// composer require dompdf/dompdf
require_once "vendor/autoload.php";
use Dompdf\\Dompdf;
use Dompdf\\Options;
// Build the invoice as a normal HTML string. CSS lives in a <style> block
// because Dompdf does NOT fetch external .css files by default.
function invoiceHtml(array $d): string
{
return <<<HTML
<!DOCTYPE html>
<html><head><style>
body { font-family: DejaVu Sans; color: #333; }
.logo { font-size: 26px; font-weight: bold; color: #04AA6D; }
table { width: 100%; border-collapse: collapse; margin-top: 16px; }
th { background: #04AA6D; color: #fff; padding: 8px; text-align: left; }
td { padding: 8px; border-bottom: 1px solid #ddd; }
</style></head>
<body>
<div class="logo">{$d['company']}</div>
<h1>Invoice #{$d['num']}</h1>
<p>Date: {$d['date']}</p>
<table>
<tr><th>Item</th><th>Total</th></tr>
<tr><td>Web Development</td><td>\$3,000</td></tr>
<tr><td>Hosting (annual)</td><td>\$120</td></tr>
</table>
</body></html>
HTML;
}
$options = new Options();
$options->set("defaultFont", "DejaVu Sans"); // a font with full Unicode coverage
$dompdf = new Dompdf($options);
$dompdf->loadHtml(invoiceHtml([
"company" => "ACME CORP",
"num" => "1001",
"date" => "2025-03-15",
]));
$dompdf->setPaper("A4", "portrait"); // page size + orientation
$dompdf->render(); // turn the HTML into PDF bytes
// Save the PDF to disk (the bytes are returned by output()):
file_put_contents(__DIR__ . "/invoice-1001.pdf", $dompdf->output());
echo "Saved invoice-1001.pdf\n";Saved invoice-1001.pdfThe PDF will look almost exactly like the HTML would in a browser — same green header, same table — which is why Dompdf is so productive. The catch is that it understands only a subset of CSS (no flexbox, no grid); you'll see how to dodge that in Common Errors.
3️⃣ Headers, Footers, Page Numbers & Images
Multi-page documents need the same logo and page numbers on every page. With TCPDF you get this almost for free: override the Header() and Footer() methods and TCPDF calls them automatically on each new page. Image() drops a logo at exact coordinates, and getAliasNumPage() / getAliasNbPages() fill in "Page X of Y" — TCPDF substitutes the real totals after the whole document is built.
<?php
// TCPDF places everything by coordinate — perfect for headers, footers,
// page numbers and logos that must appear on EVERY page.
// composer require tecnickcom/tcpdf
require_once "vendor/autoload.php";
class InvoicePdf extends TCPDF
{
// Header() runs automatically at the top of every page.
public function Header(): void
{
// Image(file, x, y, width) — a logo in the top-left corner.
$this->Image(__DIR__ . "/logo.png", 15, 10, 30);
$this->SetFont("helvetica", "B", 14);
$this->Cell(0, 10, "ACME CORP — INVOICE", 0, 1, "R"); // R = right-aligned
}
// Footer() runs automatically at the bottom of every page.
public function Footer(): void
{
$this->SetY(-15); // 15mm up from the bottom
$this->SetFont("helvetica", "", 9);
// getAliasNumPage()/getAliasNbPages() = current page / total pages.
$this->Cell(0, 10, "Page " . $this->getAliasNumPage()
. " of " . $this->getAliasNbPages(), 0, 0, "C"); // C = centred
}
}
$pdf = new InvoicePdf("P", "mm", "A4"); // Portrait, millimetres, A4
$pdf->SetMargins(15, 30, 15); // left, top, right
$pdf->AddPage();
$pdf->SetFont("helvetica", "", 12);
$pdf->Cell(0, 8, "Bill To: Alice Smith", 0, 1); // 0 width = full line, 1 = new line
$pdf->AddPage(); // forces a 2nd page so "Page 1 of 2" appears in the footer
$pdf->Cell(0, 8, "Terms: payment due within 30 days.", 0, 1);
$pdf->Output(__DIR__ . "/invoice.pdf", "F"); // "F" = save to a File
echo "Saved invoice.pdf (2 pages, numbered footer)\n";Saved invoice.pdf (2 pages, numbered footer)4️⃣ Streaming vs Saving
Once the PDF exists you must say where it goes. Stream it and the bytes flow to the browser — either viewed inline in the tab or popped as a download dialog. Save it and the bytes land in a file you can store, attach to an email, or serve later. The destination is just a flag — Dompdf's stream() vs output(), or TCPDF's Output(name, "I"|"D"|"F"|"S") — but there's one golden rule beside it.
<?php
// Where does the PDF GO? Dompdf and TCPDF both let you choose. Picking the
// wrong destination is the #1 reason a PDF "downloads as corrupt".
// --- Dompdf ---
// stream() sends the PDF to the browser. Attachment=false shows it inline;
// Attachment=true pops a download dialog.
$dompdf->stream("invoice.pdf", ["Attachment" => true]); // download
$dompdf->stream("invoice.pdf", ["Attachment" => false]); // view in the tab
// $dompdf->output() returns the raw bytes instead, so YOU decide:
$bytes = $dompdf->output();
file_put_contents("invoice.pdf", $bytes); // save to disk
// mail()->attach($bytes); // ...or email it
// --- TCPDF Output(name, dest) ---
// "I" = Inline (view) "D" = Download "F" = save to File "S" = return String
$pdf->Output("invoice.pdf", "D"); // force a download
$saved = $pdf->Output("", "S"); // get the bytes back to store or queue
// GOLDEN RULE: when you stream, the PDF bytes must be the FIRST thing sent.
// Nothing — no echo, no stray space before <?php — may go out before it.The golden rule: when you stream, the PDF bytes must be the first thing the server sends. A stray echo, a blank line, or even a space before <?php sneaks ahead of the PDF and corrupts it — the browser downloads a file that won't open. If you don't need to stream, use output() / Output("", "S") to grab the bytes safely.
5️⃣ Your Turn: Finish the Dompdf Script
Now you try. The script below is wired up except for the three method calls that actually do the work. Fill in each ___ using the 👉 hint, then run it in a project where you've installed Dompdf and check it against the Output panel.
<?php
// 🎯 YOUR TURN — finish this Dompdf script, then run it in your project.
// Everything is wired up except the three lines that DO the work.
require_once "vendor/autoload.php";
use Dompdf\\Dompdf;
$html = "<h1>Hello PDF</h1><p>My first generated document.</p>";
$dompdf = new Dompdf();
// 1) Load the HTML string into Dompdf
$dompdf->___($html); // 👉 the method that loads HTML is loadHtml
// 2) Render it (HTML -> PDF bytes)
$dompdf->___(); // 👉 the method is render
// 3) Save the bytes to a file
file_put_contents("hello.pdf", $dompdf->___()); // 👉 output() returns the bytes
echo "Done\n";
// ✅ Expected output:
// Done
// (and a hello.pdf file appears next to your script)Done___ blanks with loadHtml, render and output. It should print Done and write a hello.pdf.One more — this time a TCPDF footer that needs to show "Page X of Y". Fill in the two helpers that return the current page and the total.
<?php
// 🎯 YOUR TURN — make the footer show "Page X of Y" on every page.
// Fill in the two TCPDF helpers that return the page numbers.
require_once "vendor/autoload.php";
class Report extends TCPDF
{
public function Footer(): void
{
$this->SetY(-15);
$this->SetFont("helvetica", "", 9);
$current = $this->___(); // 👉 current page number -> getAliasNumPage
$total = $this->___(); // 👉 total page count -> getAliasNbPages
$this->Cell(0, 10, "Page {$current} of {$total}", 0, 0, "C");
}
}
$pdf = new Report("P", "mm", "A4");
$pdf->AddPage();
$pdf->AddPage(); // two pages so totals are visible
$pdf->Output("report.pdf", "F");
echo "report.pdf written\n";
// ✅ Expected output:
// report.pdf written
// (every page footer reads "Page 1 of 2", "Page 2 of 2")report.pdf written___ with getAliasNumPage() and getAliasNbPages(). Every footer should read "Page 1 of 2", "Page 2 of 2".Common Errors (and the fix)
- Your layout collapses — flexbox / grid is ignored — Dompdf is not a browser and supports only a subset of CSS.
display: flexandgridsimply do nothing. Lay PDFs out with tables and fixed widths, and put CSS in a<style>block or inline — external.cssfiles aren't fetched by default. - "Allowed memory size of … bytes exhausted" — a big table or a high-resolution image blew past PHP's
memory_limitwhile rendering. Raise it for the script withini_set('memory_limit', '256M'), shrink images before embedding them, and split huge reports across pages instead of one giant page. - £, €, accents or emoji show as empty boxes — the active font has no glyph for those characters. Use a font with wide coverage (
DejaVu Sansships with both Dompdf and TCPDF) and make sure your HTML is UTF-8. For Arabic or Hebrew, switch to mPDF. - The downloaded PDF won't open / "file is damaged" — something was printed before the PDF bytes. When streaming, the PDF must be the first output, so remove any stray
echo, blank line, or space before<?php(and drop the closing?>tag). Or save withoutput()instead of streaming.
Pro Tips
- 💡 Generate slow PDFs in the background. A 10-page report can take several seconds — queue it as a background job and email the user a download link instead of blocking the request.
- 💡 Design the template in a browser first. Build and tweak the HTML in Chrome, then hand it to Dompdf — but keep to tables and simple CSS so it survives the conversion.
- 💡 Set a Unicode default font once.
DejaVu Sanscovers Latin, currency symbols and accents, so you avoid the empty-box surprise on a customer's name.
📋 Quick Reference — PHP PDF Generation
| Task | Code | Notes |
|---|---|---|
| Install Dompdf | composer require dompdf/dompdf | HTML + CSS → PDF |
| Install TCPDF | composer require tecnickcom/tcpdf | Coordinate-based |
| Render HTML | $d->loadHtml($h); $d->render(); | Dompdf |
| Save bytes | file_put_contents($f, $d->output()); | to a file |
| Download | $d->stream("x.pdf"); | browser download |
| TCPDF page | $p->AddPage(); $p->Cell(...); | add page + text |
| Page numbers | getAliasNumPage() / getAliasNbPages() | in Footer() |
| TCPDF dest | $p->Output($n, "I"|"D"|"F"|"S"); | view/down/file/string |
Frequently Asked Questions
Q: Which PHP PDF library should I use?
If you already have an HTML and CSS template, use Dompdf — it converts HTML straight to PDF and is the quickest path for most invoices and reports. Use mPDF when you need solid Unicode, emoji, or right-to-left languages like Arabic and Hebrew, since its text handling is stronger than Dompdf's. Reach for TCPDF or the older FPDF when you need pixel-exact placement — barcodes, shipping labels, forms — where you position every element by coordinate. All three install with Composer and are free.
Q: Why does my CSS look wrong in the generated PDF?
Dompdf is not a browser — it supports a useful but limited subset of CSS. Flexbox and CSS grid are not supported, external stylesheets are not fetched by default, and many modern properties are ignored. Build PDF templates with old-school layout: tables for columns, inline styles or a <style> block instead of a linked file, and fixed widths in pt or mm. mPDF supports a bit more, but the same advice applies — keep the CSS simple and test early.
Q: My PDF downloads but won't open — what went wrong?
Almost always there was output before the PDF bytes. When you stream a PDF, those bytes must be the very first thing the server sends, so a stray echo, a blank line, or a space before the opening <?php tag corrupts the file. Remove any output before the stream() or Output() call, and don't leave a trailing ?> with whitespace after it. If you only need the bytes to save or email, use output() (Dompdf) or Output("", "S") (TCPDF) instead of streaming.
Q: Why is my PHP script running out of memory generating PDFs?
PDF rendering holds the whole document in memory, and large tables or high-resolution images push past PHP's default memory_limit. Raise it for the script with ini_set('memory_limit', '256M'), resize images before embedding them, and paginate huge reports instead of building one giant page. For documents that take several seconds, generate them in a background queue job and email the user a link rather than making them wait on the request.
Q: Why do special characters like £, €, or accents show as boxes?
Those boxes mean the active font has no glyph for the character. PDF fonts only draw the characters they actually contain, so a basic Latin font can't render £, €, é, or emoji. Switch to a font with wide coverage — DejaVu Sans ships with both Dompdf and TCPDF — and make sure your HTML source is UTF-8 encoded. For Arabic, Hebrew, or complex scripts, mPDF handles shaping and direction better than the others.
Mini-Challenge: A Receipt PDF
No code is filled in this time — just a brief and an outline. Write it yourself in a project with Dompdf installed, run it, then check your result against the expected output in the comments. This is exactly the build-render-save loop you'll use on every real document.
<?php
// 🎯 MINI-CHALLENGE: a one-page receipt PDF with Dompdf.
// No code is filled in — work from the steps, then run it in your project.
//
// 1. require "vendor/autoload.php" and use Dompdf\Dompdf;
// 2. Build an HTML string with:
// - an <h1> "Receipt #500"
// - a <table> of 2 items and their prices (put CSS in a <style> block)
// 3. $dompdf = new Dompdf();
// 4. loadHtml() your HTML, then setPaper("A4", "portrait"), then render()
// 5. Save it: file_put_contents("receipt.pdf", $dompdf->output());
// 6. echo "receipt.pdf saved\n";
//
// Remember: keep CSS inline / in a <style> block, and use a Unicode font
// (DejaVu Sans) if you include a £ or € sign.
//
// ✅ Expected output:
// receipt.pdf saved
// (a receipt.pdf file appears next to your script)
// your code hereoutput(). It should print receipt.pdf saved and write the file.🎉 Lesson Complete!
- ✅ PHP makes PDFs through Composer libraries — there's no built-in maker
- ✅ Dompdf / mPDF convert HTML + CSS; TCPDF / FPDF place elements by coordinate
- ✅ Build invoices from an HTML template, keeping CSS inline or in a
<style>block - ✅ Override
Header()/Footer()for logos and "Page X of Y" on every page - ✅ Stream bytes to the browser or save them — but stream nothing before the PDF
- ✅ Watch the gotchas: limited CSS, memory on big PDFs, and Unicode fonts
- ✅ Next lesson: Localization — build multilingual PHP apps with translations and locale-aware formatting
Sign up for free to track which lessons you've completed and get learning reminders.