vel/mpdf-bundle
vel/mpdf-bundle integrates the mPDF library into Laravel, providing bundle-style configuration and service provider wiring for generating PDFs from HTML. It aims to simplify PDF output in Laravel apps, but adoption and community activity are very limited.
The first thing to internalize: vel/mpdf-bundle is a Symfony bundle, not a Laravel package. It ships a DI extension + service definition that wraps the mpdf/mpdf library. In a Laravel app you cannot boot it (no Bundle registration, no container extension hook), so the realistic options are: (a) you're working in a Symfony service and this is a legitimate shortcut, or (b) you're in Laravel and should composer require mpdf/mpdf directly, treating this bundle purely as documentation for sane config defaults.
If you are in Symfony:
composer require vel/mpdf-bundle — read the composer.json first; a 2-star repo with 0 dependents is a maintenance risk, so check the mpdf/mpdf constraint it pins and whether it's compatible with the mPDF version you actually want.Vel\MpdfBundle\MpdfBundle in config/bundles.php (or AppKernel.php on legacy).config/packages/mpdf.yaml with the constructor options you care about:mpdf:
config:
mode: utf-8
format: A4
default_font_size: 11
margin_top: 20
margin_bottom: 20
tempDir: '%kernel.cache_dir%/mpdf'
DependencyInjection/Configuration.php in the bundle source is the source of truth for which keys exist — the README is thin. Then Resources/config/services.xml for the service id (conventionally mpdf). Verify with bin/console debug:container mpdf.First use case — an invoice PDF from a Twig template:
public function invoice(Invoice $invoice, Mpdf $mpdf, Environment $twig): Response
{
$html = $twig->render('pdf/invoice.html.twig', ['invoice' => $invoice]);
$mpdf->WriteHTML($html);
$pdf = $mpdf->Output('', \Mpdf\Output\Destination::STRING_RETURN);
return new Response($pdf, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="invoice.pdf"',
]);
}
Returning a Response yourself (rather than letting Output() emit headers) keeps you compatible with Symfony's response pipeline and with tests.
Wrap it in a generator service. Don't inject the raw Mpdf service into controllers. Create App\Pdf\PdfGenerator with a generate(string $template, array $context): string method. This gives you one place to change options, one place to add logging/timing, and one place to swap the engine later.
Two-phase HTML. Separate your CSS from your body and write CSS first, otherwise large documents get slow and style application becomes order-dependent:
$mpdf->WriteHTML($css, \Mpdf\HTMLParserMode::HEADER_CSS);
$mpdf->WriteHTML($body);
Headers and footers as templates, not string concat. SetHTMLHeader() / SetHTMLFooter() accept the same {PAGENO}, {nbpg}, {DATE j-m-Y} placeholders that mPDF resolves per page. Render the header HTML from a Twig partial once and reuse it — it keeps branded stationery out of every content template.
Know the four output destinations. INLINE (browser preview), DOWNLOAD, FILE (write to disk — use this for queued jobs), STRING_RETURN (nothing emitted, return value only). Pattern: synchronous preview = INLINE via Response; async generation = FILE to storage, then store the path.
Never reuse an instance. mPDF is stateful; a second WriteHTML() on the same object appends to the same document. If you inject the service and generate two PDFs in one request (e.g. invoice + packing slip), you get both in one file. Ask the container for a fresh instance per document, or model the service as non-shared / use a factory.
Chunking for mixed layouts. WriteHTML() / AddPage('L') / WriteHTML() lets you mix portrait and landscape within one document — useful for invoices with wide line-item tables plus a portrait cover page. Also the standard trick for appending generated sections.
Async by default in production. HTML→PDF is 200ms–3s+ and memory-heavy. For anything user-facing beyond a single small document, push generation to a queue worker (Messenger in Symfony, a job in Laravel) and hand the user a signed download URL. This also sidesteps PHP max_execution_time and web-server gateway timeouts.
Per-tenant/per-document overrides. If tenants need different paper sizes or fonts, decorate the service or add a compiler pass that adjusts the definition arguments — don't duplicate config files. Alternatively build the Mpdf instance yourself with the options array and let the bundle just supply defaults.
tempDir must exist and be writable. mPDF writes intermediate files and the font cache there. Point it at %kernel.cache_dir%/mpdf (or storage/app/mpdf in Laravel) and mkdir it in the config or a cache:warmup step, otherwise you get cryptic MpdfException errors on the first render.@imported CSS must resolve inside mPDF's base path. Absolute filesystem paths under your project root usually work; paths outside chroot silently fail. Set $mpdf->showImageErrors = true when debugging missing logos.mbstring and gd. Without gd, no images and no transparency. Confirm both extensions in your runtime before blaming the bundle.calc(). Use tables and floats. page-break-before/after works; position: fixed behaves per-page, which is actually useful for watermarks.debug: 1 may arrive as the string "1". The bundle passes config values straight to the mPDF constructor, which does loose type checks — validate your config file against Configuration.php.Output() is called after any output (a stray BOM, a dd() left in a template), the PDF is corrupted and you get binary garbage in the browser. The STRING_RETURN + Response pattern avoids this entirely.$mpdf->debug = true to make it throw instead of swallowing errors, and check $mpdf->showImageErrors. mPDF's own errors often don't surface in var/log/ — wrap generation in a try/catch and log the exception with the template name and record ID attached.%PDF- magic bytes for a smoke test, and use smalot/pdfparser to assert that customer name / total actually appear in the output. Snapshotting raw bytes is brittle across mPDF patch versions.Mpdf\Mpdf namespace in places. Pin the version, be prepared to fork, and seriously consider dropping the bundle: it's ~50 lines of DI glue over mpdf/mpdf. For Laravel, mpdf/mpdf plus a small service (or spatie/laravel-pdf if you want headless Chrome instead) is less risky. Choose mPDF over dompdf specifically when you need CJK/Arabic/RTL or font embedding — that's its differentiator.services.yaml (or decorate it) and pass the full options array — fontDir, fontdata, autoScriptToLang, autoLangToFont are the ones you'll miss most for multilingual content.How can I help you explore Laravel packages today?