PDF Handling in Laravel: A Real Project and the Problems We Solved

Key takeaways

  • Generating PDFs and editing existing PDFs are different jobs, and Laravel needs a different package for each.
  • FPDI with FPDF adds logos, stamps and watermarks to existing PDFs. Both are free and need no external binaries.
  • Import each page at its own size, and store logo positions relative to the page, not in millimetres.
  • Re-save uploaded PNG logos with PHP's GD extension so FPDF can read them.
  • Load pdf.js only where it is needed, and parse each PDF once per request.

PDF handling in Laravel covers two separate jobs. The first is generating new PDFs from HTML, such as invoices, reports and certificates. The second is editing PDFs that already exist, such as adding a logo, stamp or watermark to a document that has already been designed.

This guide focuses on the second job. It is based on a membership portal we built in Laravel for a UK industry organisation. Admins upload resources, members download them, and every copy carries the member's own company logo. The sections below cover the tools we used, the problems we hit along the way and the code that solved them.

It is written for Laravel developers and technical leads who need to brand, stamp or watermark existing PDFs. The code examples are simplified so you can adapt them to your own application.

Generating vs Editing PDFs in Laravel: Choosing the Right Package

Most Laravel PDF packages are built for generating documents. Packages such as DomPDF and spatie/laravel-pdf turn HTML or Blade views into a new PDF. They are not designed to open an existing PDF and add content to it.

For editing, FPDI by Setasign is the standard choice. It imports pages from an existing PDF as templates, and FPDF draws new content on top. Both are free, MIT licensed and installed through Composer:

composer require setasign/fpdi setasign/fpdf

In the membership portal, these two packages make up the whole PDF toolchain. No external binaries or paid services are needed, which keeps hosting simple.

Which Laravel PDF Package Fits Which Task
TaskPackageHow it works
Generate a PDF from a Blade viewbarryvdh/laravel-dompdfRenders HTML and CSS in pure PHP. Easy to host, with limited support for modern CSS.
Generate a PDF with modern CSSspatie/laravel-pdfA Laravel wrapper with a choice of drivers, including headless Chromium, Gotenberg, WeasyPrint and DomPDF.
Add a logo, stamp or watermark to an existing PDFsetasign/fpdi with setasign/fpdfImports each existing page as a template and draws new content on top.
Merge pages from several PDFs into onesetasign/fpdi with setasign/fpdfImports pages from more than one source file into a new document.
Fill in forms, keep digital signatures or change existing textCommercial PDF libraries, such as Setasign's SetaPDF rangeFPDI is not designed for these jobs. See the limits below.
What FPDI Cannot Do

FPDI is the right tool for adding content on top of a page, but it has limits worth knowing before you plan features around it:

  • It cannot change existing text. New content is placed on top of the original page.
  • It copies the visual content of each page into a new document. Form fields, comments and digital signatures are not carried over, and external links are only kept if you ask for them when importing a page.
  • It cannot open encrypted PDFs.
  • The free parser cannot read some PDFs that use compressed cross-reference streams, a feature introduced in PDF 1.5. Setasign sells a separate FPDI PDF-Parser add-on for these files.

How to Handle PDFs With Different Page Sizes

A single PDF can contain pages of different sizes and orientations. A brochure, for example, might mix A4 portrait pages with an A3 landscape spread. If every page is placed on a fixed A4 page, content is cropped or stretched.

The fix is to read each page's size and create the new page to match:

use setasign\Fpdi\Fpdi;

$pdf = new Fpdi();
$pageCount = $pdf->setSourceFile($sourcePath);

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

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

getTemplateSize() returns the width, height and orientation of each imported page, in millimetres by default. Every page keeps its original size, text and layout, and the logo is then drawn on top.

By default, FPDI imports the crop box, which is the area a PDF viewer shows. If your files are print-ready with bleed and crop marks, you may want to pass PageBoundaries::TRIM_BOX to importPage() instead.

Cleaning Up Member Logo Files Before They Reach FPDF

Members upload their own logos, and PNG files are not all saved the same way. FPDF cannot read interlaced PNGs or 16-bit PNGs, and design software commonly exports both. If one of these reaches FPDF, the PDF build fails with one of these errors:

FPDF error: Interlacing not supported: logo.png
FPDF error: 16-bit depth not supported: logo.png

Rather than asking members to save their logo again, the portal re-saves each logo as a standard 8-bit PNG using PHP's GD extension before placing it on the page. Transparency is kept.

function normaliseLogo(string $logoPath): string
{
    $image = imagecreatefrompng($logoPath);

    imagepalettetotruecolor($image);
    imagealphablending($image, false);
    imagesavealpha($image, true);
    imageinterlace($image, false);

    $tempPath = tempnam(sys_get_temp_dir(), 'logo');
    imagepng($image, $tempPath);

    return $tempPath;
}

Each step has a job:

  • imagepalettetotruecolor() converts palette-based PNGs to full colour, so transparency survives the re-save.
  • imagealphablending(false) and imagesavealpha(true) keep the transparent background instead of flattening it.
  • imageinterlace(false) writes a non-interlaced file, which FPDF can read.
  • GD stores 8 bits per colour channel, so a 16-bit PNG comes out as a standard 8-bit file.

The temporary file is deleted once the PDF has been built.

Validate the Upload as Well

Re-saving fixes the file format, but it is still worth checking uploads when they arrive. FPDF's Image() method reads PNG, JPEG and GIF files only, so SVG and WebP logos need converting or rejecting. A Laravel validation rule like this one keeps uploads to PNG files of a sensible size:

$request->validate([
    'logo' => ['required', 'image', 'mimes:png', 'max:2048', 'dimensions:min_width=300'],
]);

Our Laravel security guide covers file upload checks in more detail.

Keeping Logo Positions Consistent Across Different Files

The portal holds many resources, and they are not all the same size. A logo position saved in millimetres suits one document but sits in the wrong place on a larger or smaller one.

Positions were first stored in fixed units. We changed them to be stored relative to each file's own dimensions, so one setting places the logo consistently across files of different sizes.

In practice, each value is stored as a fraction of the page, from 0 to 1. A logo 21 mm from the left edge of an A4 page (210 mm wide) is stored as 0.1. On an A3 page (297 mm wide), the same setting places it 29.7 mm from the edge. When the PDF is built, the fractions are multiplied by the size of the page being stamped:

$size = $pdf->getTemplateSize($template);

$pdf->Image(
    $logoPath,
    $position->x * $size['width'],      // distance from the left edge
    $position->y * $size['height'],     // distance from the top edge
    $position->width * $size['width'],  // logo width
    0,                                  // height 0 keeps the logo's proportions
    'PNG'                               // the temporary file has no extension
);

FPDF measures from the top-left corner of the page, the same as a browser canvas. That keeps the admin preview simple, because a position picked on screen maps straight onto the PDF.

Converting Saved Positions Without Moving Any Logos

Existing saved positions had to be converted as part of this change. A database migration handled the conversion, and a test confirmed the values before and after, so no logo moved unexpectedly when the change went live.

A simplified version of that kind of migration looks like this. The table and column names are examples:

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
    public function up(): void
    {
        DB::table('documents')->lazyById()->each(function (object $document) {
            DB::table('documents')->where('id', $document->id)->update([
                'logo_x' => $document->logo_x_mm / $document->page_width_mm,
                'logo_y' => $document->logo_y_mm / $document->page_height_mm,
                'logo_width' => $document->logo_width_mm / $document->page_width_mm,
            ]);
        });
    }
};

And a Pest test that checks a known position lands in the same place after the conversion:

it('converts saved logo positions without moving them', function () {
    $id = DB::table('documents')->insertGetId([
        'logo_x_mm' => 21, 'logo_y_mm' => 29.7, 'logo_width_mm' => 42,
        'page_width_mm' => 210, 'page_height_mm' => 297,
    ]);

    (require database_path('migrations/2026_01_01_000000_convert_logo_positions.php'))->up();

    $document = DB::table('documents')->find($id);

    expect($document->logo_x * 210)->toEqualWithDelta(21, 0.01)
        ->and($document->logo_y * 297)->toEqualWithDelta(29.7, 0.01)
        ->and($document->logo_width * 210)->toEqualWithDelta(42, 0.01);
});

Building a Live Admin Preview With pdf.js

Setting a logo position by typing in numbers alone is slow and easy to get wrong. The portal shows admins a live preview of the actual PDF with the logo in place, so they can see the result before saving.

The preview uses pdf.js, the PDF viewer library from Mozilla, which draws the page in the browser. The server does not need to render images of each page, and nothing extra has to be installed on the hosting.

pdf.js is a large library, so it is loaded only on the pages that need it. Vite splits it into its own file, and the rest of the admin area stays fast. A cut-down version of the preview script looks like this:

// resources/js/pdf-preview.js
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url';

export async function renderPreview(url, canvas) {
    // A dynamic import, so Vite puts pdf.js in its own file
    const pdfjs = await import('pdfjs-dist');
    pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;

    const pdf = await pdfjs.getDocument(url).promise;
    const page = await pdf.getPage(1);
    const viewport = page.getViewport({ scale: 1.5 });

    canvas.width = viewport.width;
    canvas.height = viewport.height;

    await page.render({ canvas, viewport }).promise;
}
// Turn a click on the preview into the fractions the server stores
export function toRelativePosition(event, canvas) {
    const box = canvas.getBoundingClientRect();

    return {
        x: (event.clientX - box.left) / box.width,
        y: (event.clientY - box.top) / box.height,
    };
}

The script is added with @vite('resources/js/pdf-preview.js') on the document edit screen only, and listed in the input array in vite.config.js. One simple way to show the logo is an image laid over the canvas with CSS, using the same fractions as percentages for its position and width. The same code also works inside a Vue.js or React component if your admin area uses one.

Faster Processing: Reading Each PDF Once

Opening a PDF with FPDI means reading and parsing the whole file. The first version did this twice for each file: once to confirm it could be read, and again to add the logo.

We combined both steps. Each file is now opened once, and a file that cannot be read is handled at that same point. This halves the parsing work for every file, which matters most for larger documents and downloads that contain several files.

use setasign\Fpdi\PdfParser\PdfParserException;

try {
    $pageCount = $pdf->setSourceFile($sourcePath);
} catch (PdfParserException $e) {
    // Encrypted, damaged or unsupported file: log it and skip it
    report($e);

    return null;
}

PdfParserException covers the parsing errors FPDI can throw, including encrypted files and the compressed files mentioned earlier. FPDI reports those with a message that starts "This PDF document probably uses a compression technique which is not supported by the free parser shipped with FPDI".

When to Move PDF Processing to a Queue

Adding a logo to a typical document is usually quick enough to do while the member waits. If members can download large bundles, or you need to brand hundreds of files at once, move the work to a Laravel queue and send a link when the file is ready. Our guide to scaling a Laravel application covers queues in more depth.

Putting It Together: A Laravel Service That Adds a Logo to Every PDF Page

Here is how the pieces fit together in one service class. It opens the file once, keeps every page at its own size, places the logo using relative positions and removes the temporary logo file when it has finished. A small LogoPosition class holds the three stored fractions:

// app/Services/LogoPosition.php
namespace App\Services;

final readonly class LogoPosition
{
    public function __construct(
        public float $x,
        public float $y,
        public float $width,
    ) {}
}
// app/Services/PdfLogoStamper.php
namespace App\Services;

use setasign\Fpdi\Fpdi;

class PdfLogoStamper
{
    public function stamp(string $sourcePath, string $logoPath, LogoPosition $position): string
    {
        $pdf = new Fpdi();
        $pageCount = $pdf->setSourceFile($sourcePath);
        $logo = $this->normaliseLogo($logoPath);

        try {
            for ($page = 1; $page <= $pageCount; $page++) {
                $template = $pdf->importPage($page);
                $size = $pdf->getTemplateSize($template);

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

                $pdf->Image(
                    $logo,
                    $position->x * $size['width'],
                    $position->y * $size['height'],
                    $position->width * $size['width'],
                    0,
                    'PNG'
                );
            }

            return $pdf->Output('S');
        } finally {
            unlink($logo);
        }
    }

    private function normaliseLogo(string $logoPath): string
    {
        // Re-save as a standard 8-bit PNG with GD, as shown earlier
    }
}

FPDF embeds the logo once and reuses it on every page, so adding it to a long document barely changes the file size. A controller then returns the branded copy to the member:

use App\Models\Document;
use App\Services\PdfLogoStamper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;

public function download(Request $request, Document $document, PdfLogoStamper $stamper)
{
    Gate::authorize('download', $document);

    $pdf = $stamper->stamp(
        Storage::path($document->file_path),
        Storage::path($request->user()->logo_path),
        $document->logoPosition(),
    );

    return response()->streamDownload(function () use ($pdf) {
        echo $pdf;
    }, $document->download_name, ['Content-Type' => 'application/pdf']);
}

Gate::authorize() checks that the member is allowed to download the file. The Laravel authorisation docs explain how to write the policy behind it. If your files are stored on Amazon S3 rather than the local disk, read the contents with Storage::get() and pass them to FPDI with StreamReader::createByString() instead of a file path.

Tips for PDF Handling in Laravel

  1. Choose the package for the task. Generating and editing PDFs need different tools.
  2. Check FPDI's limits early. Encrypted files, form fields and digital signatures need a different approach.
  3. Import each page at its own size. Mixed page sizes are common.
  4. Clean up uploaded images. Re-save logos in a standard format before placing them.
  5. Store positions relative to the file. Fixed units only suit one page size.
  6. Give admins a visual preview. It removes guesswork and saves time.
  7. Parse each file once per request. PDF parsing is the slowest step.
  8. Keep the original files untouched. Build branded copies when they are requested, so a new logo never means re-uploading documents.

FAQs About PDF Handling in Laravel

Can Laravel edit an existing PDF?

Not on its own. FPDI imports existing pages so you can add new content such as images, text or watermarks on top. Existing text in the document cannot be changed.

What is the best package for PDF handling in Laravel?

It depends on the task. DomPDF or spatie/laravel-pdf suit generating PDFs from HTML. FPDI with FPDF suits editing PDFs that already exist.

How do I add a logo or watermark to every page of a PDF in Laravel?

Install setasign/fpdi and setasign/fpdf with Composer. For each page, import it with importPage(), add a new page of the same size, place the original with useTemplate() and draw the logo with Image(). Then output the new file.

Why does FPDF say "Interlacing not supported"?

FPDF cannot read interlaced PNG files, and it also rejects 16-bit PNGs. Re-save the image as a standard 8-bit, non-interlaced PNG, for example with PHP's GD functions imagesavealpha() and imageinterlace(), before passing it to FPDF.

Why can FPDI not open some PDF files?

The free FPDI parser cannot open encrypted PDFs or some files that use compressed cross-reference streams. Catch the exception, tell the admin which file failed, and either re-save the file without these features or use Setasign's commercial FPDI PDF-Parser.

Does FPDI keep links, form fields and digital signatures?

No. FPDI copies the visual content of each page into a new document, so form fields, comments and digital signatures are lost. Recent versions can keep external links if you pass true as the fourth argument to importPage().

Is FPDI free for commercial projects?

Yes. FPDI and the setasign/fpdf package are both released under the MIT licence, so they can be used in commercial projects at no cost.

Can admins preview a PDF in the browser?

Yes. pdf.js renders PDF pages in the browser without any server-side tools.

Related Reading
Sources and Further Reading

Looking for Laravel Development Support?

Every application has its own requirements, and getting the details right is what makes a system dependable. Our Laravel development team builds file handling, document automation, integrations, member portals, admin tools and custom platforms. For existing systems, our Laravel maintenance and support plans cover updates, fixes and performance work.

If you need help with PDF handling in Laravel, or with building or improving any other part of a Laravel application, get in touch today. We build clean, reliable systems that fit how your business works.

Written By

Rehan Ahmed

CEO Smart Web Agency | Transforming Digital Experiences through Software Development | Passionate about AI, Blockchain, Innovation & Growth | Contributor at AI Journal

Next Article
Home
Account
Cart
Search