PDF Generation & File Exporting

Reviewed & published by Brayan K

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.

Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

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";

2️⃣ 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";

The 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";

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)

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")

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — PHP PDF Generation

TaskCodeNotes
Install Dompdfcomposer require dompdf/dompdfHTML + CSS → PDF
Install TCPDFcomposer require tecnickcom/tcpdfCoordinate-based
Render HTML$d->loadHtml($h); $d->render();Dompdf
Save bytesfile_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 numbersgetAliasNumPage() / getAliasNbPages()in Footer()
TCPDF dest$p->Output($n, "I"|"D"|"F"|"S");view/down/file/string

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 here

🎉 Lesson Complete!

Practice quiz

How does a PHP app gain the ability to generate PDFs?

  • It is built into PHP core
  • You enable the pdf extension in php.ini
  • You add a library with Composer — PHP has no built-in PDF maker
  • You shell out to a system command only

Answer: You add a library with Composer — PHP has no built-in PDF maker. PHP can't make a PDF on its own; you pull in a Composer library like Dompdf, mPDF, or TCPDF.

Which library is best when you already have an HTML + CSS template to convert?

  • Dompdf (or mPDF)
  • TCPDF
  • FPDF
  • ImageMagick

Answer: Dompdf (or mPDF). Dompdf and mPDF render an HTML + CSS template to PDF — the quickest path for most invoices and reports.

Which library is the pick when you need strong Unicode, emoji, or right-to-left scripts like Arabic?

  • Dompdf
  • FPDF
  • TCPDF
  • mPDF

Answer: mPDF. mPDF has the strongest Unicode and RTL text handling of the libraries covered.

When should you reach for TCPDF or FPDF?

  • When you want the fastest possible HTML conversion
  • When you need pixel-exact layout, placing every line, box, and image by coordinate
  • When you only need plain text output
  • When you can't install Composer

Answer: When you need pixel-exact layout, placing every line, box, and image by coordinate. TCPDF and FPDF are programmatic — you position each element by coordinate for pixel-perfect documents like labels or forms.

Where must CSS live for a Dompdf template, and why?

  • Inside the HTML (a <style> block or inline), because Dompdf does NOT fetch external .css files by default
  • In a separate linked .css file, which Dompdf fetches automatically
  • In the php.ini configuration
  • In a JSON file alongside the script

Answer: Inside the HTML (a <style> block or inline), because Dompdf does NOT fetch external .css files by default. Dompdf won't fetch external stylesheets by default, so CSS goes in a <style> block or inline.

In Dompdf, which call hands you the raw PDF bytes so you decide whether to save, stream, or email?

  • render()
  • loadHtml()
  • output()
  • setPaper()

Answer: output(). output() returns the raw bytes; you then file_put_contents() them, stream them, or attach them to email.

In the Dompdf workflow, what is the correct order of method calls?

  • render() then loadHtml() then output()
  • loadHtml() then render() then output()
  • output() then loadHtml() then render()
  • setPaper() then output() then render()

Answer: loadHtml() then render() then output(). Load the HTML, render it to PDF bytes, then output() (or save) the bytes.

In TCPDF, why override the Header() and Footer() methods?

  • To connect to the database
  • To validate user input
  • To set the PDF password
  • Because TCPDF calls them automatically on every new page, giving you repeating logos and page numbers for free

Answer: Because TCPDF calls them automatically on every new page, giving you repeating logos and page numbers for free. TCPDF invokes Header() and Footer() on each page automatically — perfect for a logo and 'Page X of Y' everywhere.

Which TCPDF helpers produce the 'Page X of Y' numbers in a footer?

  • getPage() and countPages()
  • getAliasNumPage() and getAliasNbPages()
  • currentPage() and totalPages()
  • pageNum() and pageTotal()

Answer: getAliasNumPage() and getAliasNbPages(). getAliasNumPage() gives the current page and getAliasNbPages() the total; TCPDF substitutes the real totals after building.

Why might a streamed PDF download but refuse to open ('file is damaged')?

  • The PDF was too large
  • The wrong paper size was chosen
  • Something was output before the PDF bytes — a stray echo, blank line, or space before <?php — when streaming, the bytes must be first
  • The font was too small

Answer: Something was output before the PDF bytes — a stray echo, blank line, or space before <?php — when streaming, the bytes must be first. When streaming, the PDF bytes must be the very first output; any prior output corrupts the file. Use output() to save instead.

Continue this course

Frequently asked questions

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.

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.

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.

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.

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.