Generate PDFs in Laravel or any PHP application (CodeIgniter) with Dompdf

- Andrés Cruz - ES En español

Video thumbnail

One of the most useful features you can implement in your applications, especially those like mine that sell books, is the ability to generate PDF documents from HTML content. This HTML content is my books, but in your case, it can be anything. 

However, if you want to do it the complete way, as I did, generating this content depends on custom styles, JavaScript interactions to give it the necessary format, among other things.

Generating PDFs in Laravel is one of those features that sooner or later appear in any serious project: invoices, reports, certificates... or, as in my case, complete books generated from HTML content.

At first, everything seems simple: a Blade view, DomPDF, and you're done. The problem comes when the content depends on JavaScript, dynamic styles, or frontend plugins. That's where the classic approach starts to break down.

In this guide, I explain all the options for generating PDFs in Laravel, their real limits, and the approach I used in production to generate PDFs identical to what the user sees on the screen.

In this article, I show you how I solved that problem by combining browser rendering and final PDF generation from the backend in Laravel using DomPDF.

Difficulties of Generating a PDF in PHP

I wanted to generate a PDF from a series of content blocks (let's call them "book sections") that are displayed and edited in an educational application. Each section is composed of HTML that is rendered in the browser with its styles and scripts. The goal was for that same content to be exportable as a PDF with the final appearance, respecting the styles, structures, and order of the sections.

The main difficulty was that if I simply rendered a Blade view and passed it to DomPDF, I would lose many of the advantages we can have when JS formats the HTML content. In my case, the highlights plugin to format the code. Therefore, I had to implement additional logic to render the HTML content and save it already rendered, ready and with the style applied, it looks beautiful.

Options for generating PDFs in Laravel

Laravel does not generate PDFs natively, but the ecosystem offers very powerful libraries. The two most used today are DomPDF and Spatie Laravel PDF.

DomPDF: the most used option

DomPDF, through the barryvdh/laravel-dompdf package, is the most popular option for generating PDFs in Laravel.

What it does well:

  • Easily converts HTML to PDF
  • Works perfectly with Blade views
  • Ideal for simple or medium-sized documents
  • Very quick to implement

Installation and basic use of dompdf

The command is:

$ composer require barryvdh/laravel-dompdf

And its basic use:

use Barryvdh\DomPDF\Facade\Pdf;
public function generatePdf()
{
   return Pdf::loadView('pdf.example')
       ->download('document.pdf');
}

Where it starts to fail

As soon as the HTML:

  • Depends on JavaScript
  • Uses complex CSS
  • Is rendered dynamically on the frontend

When I tried this approach, I lost all the formatting applied by JS, such as code highlighting or dynamically generated structures.

Spatie Laravel PDF and Browsershot

Spatie offers a different approach: it uses Chromium via Browsershot, which means the PDF is generated from a real browser.

Clear advantages:

  • Supports JavaScript
  • Modern CSS (Grid, Flexbox, Tailwind)
  • The PDF is faithful to the browser's render
$ composer require spatie/laravel-pdf
use Spatie\LaravelPdf\Facades\Pdf;
return Pdf::view('pdf.invoice', $data)
   ->download('invoice.pdf');

Disadvantages

  • Higher resource consumption
  • Dependency on Chromium
  • Less fine control when the document is very large or in sections

The real problem when generating PDFs from Blade

Here is the point that classic tutorials don't explain well.

When HTML is generated only from Blade:

  • There is no JavaScript
  • CSS has limitations
  • The result rarely matches what the user sees

Working with an educational application for selling books, each chapter:

  • Is rendered in the browser
  • Applies dynamic styles
  • Uses JavaScript plugins (code highlighting, visual adjustments)

When I tried to generate the PDF directly from a Blade view, the result was nothing like the final version.

Real limitations of CSS and JavaScript in DomPDF

DomPDF:

  • Does not execute JavaScript
  • Supports limited CSS
  • Does not interpret dynamic interactions

That's why using it directly on Blade views only works for static HTML.

My approach to generating PDFs faithful to the HTML content

The solution was to completely separate the display from the export.

That was the turning point of the project.

Instead of “Blade → PDF”, I designed a three-stage flow.

Render the content in the browser

First, each section of the book is rendered in the browser.

Here I take full advantage of the frontend's power:

  • JavaScript
  • Plugins
  • Dynamic styles
  • Final visual adjustments

When the content looks exactly as I want, I capture the final HTML of the container.

At that moment it is no longer “theoretical HTML”, it is real, processed, and definitive HTML.

How I Generated My Book: Dividing the Process into Three Stages

The key to achieving what was mentioned was to divide the process into three very clear stages:

1. Rendering the content in the browser

Each section of the book is displayed in the browser. This rendering allows applying JavaScript logic, dynamic styles, interactive components, and everything that cannot be rendered directly from the backend.

At this stage, I take full advantage of the browser's power to give each section its final visual appearance. Once it looks just as I want it on the screen, I take the HTML content of the main container and send it to the backend in Laravel to save it.

html.blade.php

@foreach ($book->sections()->where('orden', '>=', 1)->get() as $key => $s)
    <section class="page-break book-chapter" id="{{ $s->id }}"
        data-chapter="chapter{{ $key + 1 }}.xhtml">
        <h1><span class="underline">{{ $book->language == 'spanish' ? 'Chapter' : 'Chapter' }}
                {{ $key + 1 }}</span>: {{ $s->title }}</h1>
        {!! preg_replace('/\s*(width|height)="\d*"/i', '', $s->content) !!}
    </section>
@endforeach

2. Saving sections (book sections)

Each block of HTML that represents a section is saved in the database. I assign an order to each section, so I can later reconstruct the complete HTML document in the correct order.

At this stage, I don't try to generate the PDF yet. I only focus on ensuring that the final content—already transformed and ready—is saved as static HTML. This allows me to completely decouple the display process from the export process.

html.blade.php

// saves the content of the book, each section
document.documentElement.querySelectorAll(".book-chapter").forEach((s) => {
    axios.post("{{ route('web-book-generate-section-save', 55) }}".replace(55, s.id), {
        //"document": `${s.outerHTML}`
        "document": `<html>${document.documentElement.querySelector("head").outerHTML}<body>${s.outerHTML}${document.documentElement.querySelector("footer").outerHTML}</body></html>`
    })
    // console.log(`${s.outerHTML}</body></html>`)
})

In addition, having the sections saved separately allows me to make adjustments, debug, or reorder the content without affecting other parts of the flow.

Save the final HTML by sections

Each chapter or section:

  • Is saved as static HTML in the database
  • Has a defined order
  • Can be debugged independently

I can correct one section without touching the rest of the document.

In addition, this decoupling allowed me not to depend on the PDF engine to interpret complex CSS.

3. Assembly and generation of the PDF

Then, with all the content ready, I use the DomPDF package in Laravel (although it is a package for PHP), and the downloadable file is generated.

 $html = "<html>";
// gets the BODY/foreach of ALL the chapters
        foreach ($this->book->sections()->where('orden', '>', 0)->orderBy('orden')->get() as $s) {
            $sectionHtml = $s->content_render;
            // Clean <html>, <head>, <body> tags using DOMDocument
            $doc = new \DOMDocument();
            @$doc->loadHTML(mb_convert_encoding($sectionHtml, 'HTML-ENTITIES', 'UTF-8'));
            $body = $doc->getElementsByTagName('body')->item(0);
            $content = $body ? $doc->saveHTML($body) : $sectionHtml;
            // Removes <body> tags leaving only the inner content
            $content = preg_replace('/<\/?body[^>]*>/', '', $content);
            $html .= $content;
        }
        // closes the HTML doc
        $html .= '</body></html>';
        //**** HTML REPLACEMENTS
        // DOOM PDF needs the url to be absolute
        $html = str_replace("/images/example/libros/",  URL('/') . "/images/example/libros/", $html);
        // I remove the reference to chapterX.xhtml from the epub format
        $html = preg_replace('/chapter\d+\.xhtml#/', '#', $html);
        // dd($html );
        $pdf = Pdf::loadHTML($html)
            ->set_option("isRemoteEnabled", true)
            ->set_option('isHtml5ParserEnabled', true)
            ->set_option('isPhpEnabled', true)
            ->setPaper('letter', 'portrait');

This last step, you can give it the format you want. I use it to replace relative references with absolute ones for images, remove the HEAD and HTML tags from each of the sections, which I register as a complete HTML document, among others, to finally export it as you can see in the previous code.

With all the content already processed, the backend just does its job:

  • Retrieve sections in order
  • Clean up duplicate tags
  • Assemble the final HTML
  • Generate the PDF with DomPDF

Here I use DOMDocument to remove repeated <html>, <head>, and <body> tags, normalize the content, and ensure compatibility.

The result is a PDF identical to what the user sees on the screen, with no surprises.

Benefits of this approach

This flow gave me several clear advantages:

  1. I can use the browser to render content with JavaScript before saving it.
  2. The real styles and structures that the user sees are respected.
  3. COMPLETELY customizable in style.
  4. I don't depend on a PDF engine to interpret complex CSS.
  5. The PDF is faithful to what the user sees, as it is based on already processed HTML.

It's easy to debug, as each section can be reviewed individually.

Generating complex PDFs in Laravel step by step

Summarizing the complete flow:

  1. Rendering the content in the browser
    1. JavaScript is executed
    2. Final styles are applied
    3. The visual result is validated
  2. Persistence of the processed HTML
    1. Already rendered HTML
    2. Saved by sections
    3. Totally decoupled
  3. Final generation of the PDF
    1. Document assembly
    2. Replacement of relative paths
    3. Export with DomPDF

This approach scales very well when the PDF grows or becomes complex.

Advantages of separating display and export

This system gave me very clear advantages:

  • I use JavaScript without limitations
  • The PDF respects the final design exactly
  • Debugging by sections
  • Cleaner architecture
  • Greater control of the result

In real projects, this difference is huge.

When to use DomPDF and when to use Spatie PDF

Use DomPDF if:

  • The HTML is already processed
  • You need fine control
  • You generate large PDFs
  • You want fewer dependencies

Use Spatie PDF if:

  • You want 100% browser rendering
  • The document is not huge
  • You don't mind depending on Chromium

In my case, DomPDF was perfect once the frontend rendering was separated.

Common errors when generating PDFs in Laravel

Some very common errors:

  • Relying on DomPDF to execute JavaScript
  • Not using absolute paths for images
  • Generating giant PDFs in a single view
  • Mixing visual rendering and export

Avoiding these errors makes the difference between an “acceptable” PDF and a professional one.

Tips for large PDFs and real projects

  • Divide the content by sections
  • Save already rendered HTML
  • Avoid complex logic in the PDF
  • Debug each part separately
  • Think in terms of architecture, not just code

Frequently asked questions about generating PDFs in Laravel

  • What is the best library for generating PDFs in Laravel?
    • It depends on the case. DomPDF for control and scalability, Spatie PDF for full browser rendering.
  • Does DomPDF support JavaScript?
    • No. That's why it's key to work with already processed HTML.
  • How to generate PDFs faithful to the final design?
    • By separating the frontend rendering from the backend export.
  • Can large PDFs be generated without problems?
    • Yes, if the content is divided by sections and assembled correctly.

Dynamic Digital Signature and Watermarks in PDF and EPUB Files

Video thumbnail

When distributing digital content such as PDFs or EPUBs, it is essential to implement a traceability mechanism to identify the source of a file in the event of a leak. A highly efficient strategy consists of injecting a watermark or digital signature containing the authenticated user's details (such as their email address and a unique download identifier) directly into the file at the moment the request is processed.

Unlike generating a document entirely from scratch—a resource- and time-intensive process when dealing with extensive books of hundreds of pages—this technique applies the watermark by dynamically and instantaneously modifying an existing base file.

Dynamic Signature in PDF Documents

To manipulate an existing PDF in Laravel without needing to re-render the HTML layout, the setasign/fpdf and setasign/fpdi packages are used. While FPDF provides the API for formatting, typography, and text positioning, FPDI acts as an extension that allows opening a pre-existing PDF file and importing its pages as templates onto which content can be drawn.

Dependency Installation

$ composer require setasign/fpdf setasign/fpdi

Implementation of the PDF Signing Service

The following service opens the original document, iterates through each of its pages while maintaining their original dimensions, and injects the signature into the footer before returning the resulting binary:

<?php

namespace App\Services;

use setasign\Fpdi\Fpdi;

class PdfWatermarkService
{
    /**
     * Adds the user's email and download ID to the footer of each PDF page.
     *
     * @param string $filePath Absolute path of the original PDF file.
     * @param string $userEmail Email address of the user.
     * @param string|null $downloadId Unique identifier of the transaction.
     * @return string Binary content of the signed PDF.
     */
    public function signPdfForUser(string $filePath, string $userEmail, ?string $downloadId = null): string
    {
        $pdf = new Fpdi();
        $pageCount = $pdf->setSourceFile($filePath);
        $pdf->SetAutoPageBreak(false);

        for ($pageNo = 1; $pageNo <= $pageCount; $pageNo++) {
            $templateId = $pdf->importPage($pageNo);
            $size = $pdf->getTemplateSize($templateId);

            $pdf->AddPage($size['orientation'], [$size['width'], $size['height']]);
            $pdf->useTemplate($templateId);

            // Style configuration for the footer
            $pdf->SetFont('Helvetica', 'I', 8);
            $pdf->SetTextColor(128, 128, 128);
            $pdf->SetY(-10);

            $text = "Copy licensed exclusively to: {$userEmail} | Download ID: " . $downloadId;
            $text = iconv('UTF-8', 'windows-1252//TRANSLIT', $text);

            $pdf->Cell(0, 5, $text, 0, 0, 'C');
        }

        return $pdf->Output('S');
    }
}

Dynamic Signature in EPUB E-books

An EPUB file is a compressed structure (ZIP format) containing XHTML files for chapters, CSS stylesheets, and images. Therefore, PDF rendering libraries are not required; instead, PHP's native ZipArchive extension is used.

The process consists of copying the EPUB to a temporary file, iterating through its HTML/XHTML content documents, and injecting the signature block right before the closing </body> tag.

Implementation of the EPUB Signing Service

<?php

namespace App\Services;

use ZipArchive;

class EpubWatermarkService
{
    /**
     * Injects a watermark into the XHTML chapters of an EPUB file.
     *
     * @param string $filePath Path of the original EPUB file.
     * @param string $userEmail Email address of the user.
     * @param string|null $downloadId Unique download identifier.
     * @return string Binary content of the signed EPUB.
     */
    public function signEpubForUser(string $filePath, string $userEmail, ?string $downloadId = null): string
    {
        $tempFile = tempnam(sys_get_temp_dir(), 'epub_');
        copy($filePath, $tempFile);

        $zip = new ZipArchive();

        if ($zip->open($tempFile) === true) {
            $watermarkHtml = sprintf(
                '<div style="margin-top: 30px; padding: 10px; border-top: 1px solid #ccc; font-size: 0.75em; color: #666; text-align: center;">' .
                'Copy licensed exclusively to: <strong>%s</strong> | ID: %s' .
                '</div></body>',
                htmlspecialchars($userEmail, ENT_QUOTES, 'UTF-8'),
                $downloadId
            );

            $filesToUpdate = [];

            for ($i = 0; $i < $zip->numFiles; $i++) {
                $filename = $zip->getNameIndex($i);

                // Skip covers or images
                if (str_contains(strtolower($filename), 'cover')) {
                    continue;
                }

                if (preg_match('/\.(xhtml|html|htm)$/i', $filename)) {
                    $content = $zip->getFromIndex($i);

                    if ($content && str_contains($content, '</body>')) {
                        $filesToUpdate[$filename] = str_replace('</body>', $watermarkHtml, $content);
                    }
                }
            }

            foreach ($filesToUpdate as $filename => $modifiedContent) {
                $zip->addFromString($filename, $modifiedContent);
            }

            $zip->close();
        }

        $signedBinary = file_get_contents($tempFile);
        @unlink($tempFile);

        return $signedBinary;
    }
}

Streaming Download via Controller

To send the processed file to the user without overloading disk storage or modifying the master file, the controller evaluates the document extension, executes the corresponding service in memory, and responds via a streamDownload.

Additionally, a unique identifier (downloadId) is generated based on timestamps or UUIDs to maintain an audit log in the database:

use App\Services\PdfWatermarkService;
use App\Services\EpubWatermarkService;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

// Fetch file path and format filename
$filePath = Storage::disk('files_sell_uploads')->path('book/' . $file->file);
$extension = strtolower(ltrim($file->type, '.'));
$fileName = Str::slug($name) . '.' . $extension;

// Format evaluation and signature application
if ($extension === 'pdf') {
    $signedContent = app(PdfWatermarkService::class)->signPdfForUser($filePath, $user->email, $downloadId);
    $contentType = 'application/pdf';
} elseif ($extension === 'epub') {
    $signedContent = app(EpubWatermarkService::class)->signEpubForUser($filePath, $user->email, $downloadId);
    $contentType = 'application/epub+zip';
} else {
    // Unsupported formats (ZIP, MOBI) are delivered directly
    return Storage::disk('files_sell_uploads')->download('book/' . $file->file, $fileName);
}

// Issuing streaming response
return response()->streamDownload(function () use ($signedContent) {
    echo $signedContent;
}, $fileName, [
    'Content-Type' => $contentType,
    'Cache-Control' => 'private, no-transform, no-store, must-revalidate',
]);

This architecture ensures the protection of digital assets by processing modifications directly on temporary files or memory streams, delivering a fast response without altering the source files stored on the server.

Invoice Generation in Laravel with Laravel Invoices

Video thumbnail

The Laravel Invoices package (developed by the Laravel Daily team) is a flexible tool designed to simplify the creation and issuance of PDF invoices within Laravel applications.

Installation and Basic Configuration

To get started with the package, we execute the installation via Composer:

$ composer require laraveldaily/laravel-invoices

Next, we publish the configuration file, views, and assets to adapt the design and default data to our project:

$ php artisan invoices:install

Global Invoicing Configuration

Publishing the assets generates the config/invoices.php file. This file allows you to define global parameters for the application, avoiding the repetition of common information in every controller:

  • Seller Details: Allows setting the company name, address, phone number, VAT/Tax ID, and custom fields such as SWIFT codes or bank accounts.
  • Currency and Number Formatting: Configures the currency code (EUR), symbol (€), thousands separators, and decimal points.
  • Page Formatting: Adjusts paper size (default is A4) and orientation (portrait or landscape).
  • Numbering and Taxes: Defines the series, default sequence, leading zero padding, and payment terms in days.

config/invoices.php

return [
    'date' => [
        'format' => 'Y-m-d',
        'pay_until_days' => 7,
    ],
    'paper' => [
        'size' => 'a4',
        'orientation' => 'portrait',
    ],
    'seller' => [
        'class' => \LaravelDaily\Invoices\Classes\Seller::class,
        'attributes' => [
            'name' => 'Mi Empresa S.L.',
            'address' => 'Calle Principal 45, Madrid',
            'vat' => 'B98765432',
            'phone' => '910 000 000',
            'custom_fields' => [
                'SWIFT' => 'BANKESMMXXX',
            ],
        ],
    ],
];

Controller Implementation

To build an invoice in the controller, we instantiate the buyer (Buyer), the items or services (InvoiceItem), and the main invoice object (Invoice). Finally, we associate all elements:

namespace App\Http\Controllers;

use LaravelDaily\Invoices\Classes\Buyer;
use LaravelDaily\Invoices\Classes\InvoiceItem;
use LaravelDaily\Invoices\Invoice;

class InvoiceDemoController extends Controller
{
    /**
     * Generates the invoice and streams it directly to the browser.
     */
    public function show()
    {
        // Buyer setup
        $buyer = new Buyer([
            'name' => 'Cliente Demo',
            'address' => 'Calle Ejemplo 123, Alicante',
            'phone' => '600 000 000',
            'custom_fields' => [
                'email' => 'cliente@demo.es',
                'cif' => 'B12345678',
            ],
        ]);

        // Item or service creation
        $item = (new InvoiceItem())
            ->title('Desarrollo web')
            ->description('Landing page responsive + formulario de contacto')
            ->pricePerUnit(450.00)
            ->quantity(1)
            ->tax(21);

        // Invoice construction
        $invoice = Invoice::make()
            ->series('DEM')
            ->sequence(1)
            ->buyer($buyer)
            ->date(now())
            ->currencySymbol('€')
            ->currencyCode('EUR')
            ->currencyFormat('{SYMBOL}{VALUE}')
            ->currencyThousandsSeparator('.')
            ->currencyDecimalPoint(',')
            ->addItem($item)
            ->notes('¡Gracias por confiar en nosotros!');

        // PDF rendering directly in the HTTP response
        return $invoice->stream('factura-demo.pdf');
    }
}

Issuing the PDF Document

There is no need to render additional Blade views. The stream() method compiles the data into a PDF document using Dompdf and transfers it directly to the browser for viewing or downloading via the route assigned in the routes file (routes/web.php).

Conclusion

Generating PDFs in Laravel goes far beyond converting a Blade view into a downloadable file.

When the content is dynamic, complex, or depends on JavaScript, the classic approach falls short. In my experience, decoupling display and export is the key to generating professional, scalable, and faithful-to-the-real-design PDFs.

If you are building something more than a basic PDF, this approach will save you a lot of headaches.

Learn how to generate PDFs in Laravel using DomPDF and rendered HTML. A real solution for PDFs that accurately reflect your design, even with complex JavaScript and CSS.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

I agree to receive announcements of interest about this Blog.