soqrate/tcpdf
A Laravel integration for TCPDF that simplifies generating PDFs from HTML, with configuration and service provider support for creating, saving, or streaming PDF documents in your application.
soqrate/tcpdf is a Composer distribution of the classic TCPDF single-class PDF generator. It is not a Laravel-native wrapper — there is no service provider, no facade, no config publish. You get the TCPDF class and a set of K_* path constants, and you drive it imperatively. Treat it as a low-level rendering engine you wrap yourself.
Install and verify autoloading first.
composer require soqrate/tcpdf
Before writing any code, confirm how the package autoloads. TCPDF historically is not PSR-4 — it is a monolithic tcpdf.php plus a config/tcpdf_config.php. Check vendor/soqrate/tcpdf/composer.json for a classmap or files entry targeting the main class file. If it is absent, new TCPDF() will fatal with "class not found" and you must require_once the file yourself (ideally in a small bootstrap or a service provider's register()).
First use case: stream a PDF download from a controller.
public function show(Invoice $invoice)
{
$pdf = new \TCPDF('P', 'mm', 'A4', true, 'UTF-8');
$pdf->SetCreator(config('app.name'));
$pdf->SetTitle("Invoice {$invoice->number}");
$pdf->SetMargins(15, 20, 15);
$pdf->SetAutoPageBreak(true, 25);
$pdf->AddPage();
$pdf->SetFont('helvetica', '', 11);
$pdf->Write(6, 'Invoice ' . $invoice->number);
return response($pdf->Output('invoice.pdf', 'S'), 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="invoice.pdf"',
]);
}
The 'S' output mode returns the PDF as a string — this is what you want in Laravel. Never use Output(..., 'I') or 'D' inside a controller: those emit raw headers and echo directly, bypassing the response stack, which breaks middleware, session handling, and testing.
Where to look first in the source:
tcpdf.php — the class itself; grep for function Header, function Footer, function Error, and public function Output.config/tcpdf_config.php — the K_PATH_* and font defaults. Read this before your first render; it determines where fonts, cache, and images are resolved from.fonts/ — bundled core fonts (helvetica, times, courier, dejavusans). Only these work out of the box.Subclass per document type, do not scatter TCPDF calls through controllers. Headers, footers, and page-numbering logic belong in a dedicated class. Laravel's container will happily resolve it.
namespace App\Pdf;
class InvoicePdf extends \TCPDF
{
public function __construct(protected array $meta)
{
parent::__construct('P', 'mm', 'A4', true, 'UTF-8', false);
$this->SetMargins(15, 35, 15);
$this->SetHeaderMargin(10);
$this->SetFooterMargin(15);
$this->SetAutoPageBreak(true, 25);
$this->setImageScale(PDF_IMAGE_SCALE_RATIO);
}
public function Header(): void
{
$this->SetY(12);
$this->SetFont('helvetica', 'B', 9);
$this->Cell(0, 5, $this->meta['company'], 0, 1, 'L');
$this->SetDrawColor(200);
$this->Line(15, 24, 195, 24);
}
public function Footer(): void
{
$this->SetY(-18);
$this->SetFont('helvetica', '', 8);
$this->Cell(0, 5, 'Page ' . $this->getAliasNumPage()
. ' of ' . $this->getAliasNbPages(), 0, 0, 'C');
}
}
getAliasNumPage() / getAliasNbPages() emit placeholder tokens that TCPDF resolves at output time — this is the only correct way to get "Page 3 of 12" without a second pass. Do not use getPageNum() in the footer; it captures the page number at the moment the footer renders, which is usually right but breaks if you re-render a page.
Render Blade to HTML, then hand it to writeHTML(). This is the workflow most teams settle on: designers work in Blade/CSS, and you keep a strict subset of HTML supported by TCPDF.
public function action(Invoice $invoice)
{
$pdf = new InvoicePdf(['company' => config('app.name')]);
$pdf->AddPage();
$html = view('pdf.invoice', [
'invoice' => $invoice,
'logo' => public_path('img/logo.png'),
])->render();
$pdf->writeHTML($html, true, false, true, false, '');
return response($pdf->Output('invoice.pdf', 'S'), 200, [
'Content-Type' => 'application/pdf',
]);
}
Pass absolute filesystem paths for images into the view and use <img src="{{ $logo }}">. TCPDF will not resolve asset() URLs, and remote URLs require allow_url_fopen and add network latency to every render.
Use writeHTMLCell() with explicit dimensions when you need a boxed region (address blocks, side-by-side columns). TCPDF has no layout engine; the only reliable way to get two columns is two writeHTMLCell() calls with computed X coordinates via getX() / setX().
$y = $pdf->getY();
$pdf->writeHTMLCell(85, 0, 15, $y, $billingHtml, 0, 1, false, true, 'L');
$pdf->writeHTMLCell(85, 0, 110, $y, $shippingHtml, 0, 1, false, true, 'L');
$pdf->setY($pdf->getY() + 5);
Repeat table headers across page breaks. TCPDF honors <thead> inside writeHTML():
<table border="1" cellpadding="4">
<thead>
<tr><th>Item</th><th>Qty</th><th align="right">Total</th></tr>
</thead>
<tbody> ... </tbody>
</table>
Without <thead>, long invoices get headerless continuation pages. This is the single most common visual defect in TCPDF invoices.
Persist with Storage, not the 'F' output mode. If you need a stored file, take the string and let Laravel handle the disk:
Storage::disk('s3')->put("invoices/{$invoice->id}.pdf", $pdf->Output('', 'S'));
Output($path, 'F') writes with raw fopen/fwrite, ignoring Laravel's filesystem abstraction, S3, and file locking. Reserve 'F' for cases where you genuinely need a local temp file (e.g. handing to a mailer library that requires a path).
Generate asynchronously for anything non-trivial. PDF rendering is CPU-bound and TCPDF is slow — a 200-line invoice table can take hundreds of milliseconds, a 5000-row report can take tens of seconds and eat memory. Queue it:
class GenerateInvoicePdf implements ShouldQueue
{
public function handle(): void
{
$string = (new InvoicePdf(...))->render($this->invoice);
Storage::put($this->path, $string);
$this->invoice->update(['pdf_path' => $this->path]);
}
}
Take advantage of TCPDF's differentiators over FPDF/mPDF-lite: native 1D/2D barcodes and digital signatures.
$pdf->write2DBarcode($invoice->number, 'QRCODE,H', 160, 12, 30, 30, [], 'N');
$pdf->write1DBarcode($invoice->number, 'C128', 15, 250, 60, 12, 0.4, [], 'N');
Testing: assert on the output magic bytes rather than the whole string.
$response = $this->actingAs($user)->get("/invoices/{$invoice->id}/pdf");
$response->assertOk();
$this->assertStringStartsWith('%PDF-', $response->getContent());
If you need deeper assertions, parse with smalot/pdfparser in tests only.
Class-name collision with tecnickcom/tcpdf. Both packages define the global TCPDF class. If any transitive dependency pulls in tecnickcom/tcpdf (many PDF-related packages do), you get a fatal "cannot redeclare class" or, worse, silent version mixing. Run composer why tecnickcom/tcpdf and composer show | grep -i tcpdf before committing to this package. The upstream tecnickcom/tcpdf is the actively maintained one; this fork has zero dependents and zero stars, so pin it exactly ("soqrate/tcpdf": "x.y.z") and be prepared to swap it out if it goes stale.
Path constants must be defined before the class loads. TCPDF checks for K_PATH_MAIN, K_PATH_FONTS, K_PATH_CACHE, K_PATH_IMAGES, and K_TCPDF_EXTERNAL_CONFIG at include time. If you need custom paths (e.g. cache into storage/framework/cache/tcpdf), define them in AppServiceProvider::register() before anything autoloads the class — or point K_TCPDF_EXTERNAL_CONFIG at your own config file that defines everything.
// AppServiceProvider::register(), runs early
if (!defined('K_PATH_CACHE')) {
define('K_PATH_CACHE', storage_path('framework/cache/tcpdf'));
}
Error() kills the process. TCPDF's internal error handler writes a message and calls die(). Inside a queue worker or an HTTP request this is an uncatchable failure — no exception, no log, just a dead worker. Subclass and override it to throw:
public function Error($msg)
{
throw new \RuntimeException('TCPDF: ' . $msg);
}
Font headaches. Only core fonts work without setup. Using any TTF requires addTTFfont(), which is slow, writes .php/.z files into the fonts directory, and must be run once — never at request time in production. The packaged fonts/ directory is often not writable in deployed apps (vendor/ is read-only in many setups). Either run font generation at build time and commit the output, or set K_PATH_FONTS to a writable storage/ path. addTTFfont() also has known issues with certain font variants and silently produces broken glyph widths.
Unicode: pass true as the fourth constructor arg and set dejavusans if your content has characters outside Latin-1 (Cyrillic, CJK, currency symbols, em-dashes). helvetica will render □ for anything unsupported — and it fails silently, so this ships to production undetected unless you eyeball a PDF with real customer data.
Cache directory must exist and be writable. TCPDF writes temporary files there. In Docker images with a non-root user this is a classic Permission denied at Output() time. Ensure storage/framework/cache/tcpdf exists with 0775 and is in your deploy script's directory-creation step.
The HTML/CSS support is a strict, ancient subset. No flexbox, no grid, no @media, no pseudo-classes, no CSS variables, no position. Supported roughly: font-family, font-size, font-weight, color, background-color, border, padding, margin, text-align, line-height, width/height on tables. <div> behaves unpredictably compared to <p>/<table>. Debugging trick: render the same Blade view in the browser first — if it looks like a table-based 2003 email template, TCPDF can probably handle it.
Page breaks ignore SetAutoPageBreak inside <table> rows in some versions. Force a break manually with $pdf->AddPage() between logical sections, or split large tables into multiple writeHTML() calls with a page check:
if ($pdf->getY() > 240) {
$pdf->AddPage();
}
Use transactions for multi-part documents. If you build a 40-page report and page 38 throws, a partial PDF may already be in the output buffer:
$pdf->startTransaction();
try {
// ... render ...
$pdf->commitTransaction();
} catch (\Throwable $e) {
$pdf->rollbackTransaction();
throw $e;
}
Memory blows up with images. TCPDF decodes and holds images. A 4MB JPEG at 300 DPI in a loop over 100 line items will exhaust a 128M memory_limit. Downscale images before embedding, deduplicate identical images (TCPDF re-embeds the same file each Image() call unless you reuse it), and set ini_set('memory_limit', '512M') in the job if unavoidable.
Extension point: override Header()/Footer(), not the config file. Everything you'd want to customize per-document — logo, watermarking, footer text — belongs in a subclass. Editing config/tcpdf_config.php in vendor/ is lost on the next composer install and is invisible in code review.
Suppress the default header/footer when you want full control: setPrintHeader(false) and setPrintFooter(false) on the instance — but note SetAutoPageBreak's bottom margin should still account for the space a footer would have used, or content will collide with it on later pages.
Output() name argument and 'S' mode: $pdf->Output('', 'S') returns the raw bytes with no filename side effects. Prefer this over Output('doc.pdf', 'S') in a string-returning context to avoid TCPDF's internal filename munging on characters like / and :.
No Laravel integration means no testing helpers, no facades, no config. Wrap this package behind your own App\Pdf\* classes with a narrow interface (render(): string). That gives you a seam to swap in mpdf, dompdf, or a headless-Chrome renderer later — worth doing given this fork's zero-activity profile.
How can I help you explore Laravel packages today?