Back to Blog
Tutorial

Template Design Patterns for Clean, Maintainable PDF Documents

January 15, 202611 min read

Template Design Patterns for Clean, Maintainable PDF Documents

PDF templates have a tendency to become unmaintainable. They start as a clean HTML file, then requirements accumulate: "add a discount line," "show the PO number if it exists," "handle both single-page and multi-page invoices," "support right-to-left languages." Each change is a small patch. After six months, the template is a tangled mess that nobody wants to touch.

This article covers the design patterns that prevent that decay — patterns for structuring, composing, and maintaining PDF templates that grow gracefully with your requirements.

Pattern 1: The Component Hierarchy

Just like web development moved from monolithic HTML pages to reusable components, PDF templates benefit from the same approach.

The Monolithic Anti-Pattern

<!-- ❌ One massive template -->
<div class="invoice">
    <div style="display: flex; justify-content: space-between; margin-bottom: 20px;">
        <div>
            <img src="/logo.png" style="width: 120px;">
            <h1 style="font-size: 24px; margin: 0;">Acme Corp</h1>
            <p style="font-size: 10px; color: #666;">123 Business St, New York, NY 10001</p>
            <p style="font-size: 10px; color: #666;">Tax ID: US12-3456789</p>
        </div>
        <div style="text-align: right;">
            <h2 style="font-size: 28px; color: #2563eb;">INVOICE</h2>
            <!-- ... 400 more lines of inline styles and mixed concerns ... -->
        </div>
    </div>
    <!-- ... -->
</div>

The Component Pattern

Break the template into logical, reusable pieces:

templates/
  invoice/
    layout.blade.php          ← Main structure
    components/
      header.blade.php        ← Logo, company info, document title
      parties.blade.php       ← Seller and buyer info side by side
      line-items.blade.php    ← Product/service table
      totals.blade.php        ← Subtotal, tax, discounts, total
      payment-info.blade.php  ← Bank details, payment terms
      footer.blade.php        ← Legal text, page numbers
    styles/
      invoice.css             ← All styling in one place
<!-- layout.blade.php — clean and readable -->
<!DOCTYPE html>
<html lang="{{ $locale }}">
<head>
    <link rel="stylesheet" href="styles/invoice.css">
</head>
<body>
    @include('invoice.components.header', ['company' => $seller])
    @include('invoice.components.parties', ['seller' => $seller, 'buyer' => $buyer])
    @include('invoice.components.line-items', ['items' => $items, 'currency' => $currency])
    @include('invoice.components.totals', ['totals' => $totals, 'currency' => $currency])

    @if($paymentInfo)
        @include('invoice.components.payment-info', ['payment' => $paymentInfo])
    @endif

    @include('invoice.components.footer', ['legalText' => $legalText])
</body>
</html>

Each component is:

  • Independently testable: Render the component alone to check its layout
  • Reusable: The header component works for invoices, quotes, and credit notes
  • Readable: Someone can understand the template structure in 10 seconds

Pattern 2: Strict Data Contracts

The second most common source of template bugs (after styling issues) is data inconsistency. Templates break when they receive null values, unexpected types, or missing fields.

Define Explicit Input Types

class InvoiceTemplateData
{
    public function __construct(
        public readonly string $documentNumber,
        public readonly string $documentType, // 'invoice' | 'credit_note' | 'quote'
        public readonly DateTimeImmutable $issueDate,
        public readonly DateTimeImmutable $dueDate,
        public readonly CompanyData $seller,
        public readonly CompanyData $buyer,
        /** @var list<LineItemData> */
        public readonly array $lineItems,
        public readonly TotalsData $totals,
        public readonly ?PaymentData $payment,
        public readonly ?string $notes,
        public readonly string $currency,
        public readonly string $locale,
    ) {}
}

class LineItemData
{
    public function __construct(
        public readonly int $position,
        public readonly string $description,
        public readonly string $quantity,      // Pre-formatted: "10.00"
        public readonly string $unitPrice,     // Pre-formatted: "$150.00"
        public readonly string $totalPrice,    // Pre-formatted: "$1,500.00"
        public readonly ?string $discount,     // Pre-formatted or null
        public readonly string $vatRate,       // Pre-formatted: "19%"
    ) {}
}

Formatting in the Data Layer, Not the Template

// ❌ Formatting in the template
<td>{{ number_format($item->price, 2) }} {{ $currency }}</td>

// ✅ Formatting in the data layer
class LineItemData
{
    public static function fromModel(InvoiceItem $item, string $currency, string $locale): self
    {
        $formatter = new NumberFormatter($locale, NumberFormatter::CURRENCY);

        return new self(
            position: $item->position,
            description: $item->description,
            quantity: number_format($item->quantity, 2),
            unitPrice: $formatter->formatCurrency($item->unit_price, $currency),
            totalPrice: $formatter->formatCurrency($item->total_price, $currency),
            discount: $item->discount ? $formatter->formatCurrency($item->discount, $currency) : null,
            vatRate: ($item->vat_rate * 100) . '%',
        );
    }
}

// Template is just output — no logic
<td>{{ $item->totalPrice }}</td>

Benefits:

  • Templates contain zero business logic
  • Formatting is consistent across all documents
  • The same data object works with any template (invoice, receipt, statement)
  • Testing is simple: construct a data object, render the template, verify the output

Pattern 3: Conditional Sections

Real templates have many optional sections: discounts, notes, PO numbers, tax exemptions. Managing these conditionals cleanly is crucial.

The Boolean Flag Approach

<!-- ❌ Fragile — checking data presence inline -->
@if(isset($purchaseOrder) && $purchaseOrder !== '' && $purchaseOrder !== null)
    <div class="po-number">PO: {{ $purchaseOrder }}</div>
@endif
// ✅ Clear boolean flags in the data contract
class InvoiceTemplateData
{
    public readonly bool $hasPurchaseOrder;
    public readonly bool $hasDiscount;
    public readonly bool $hasNotes;
    public readonly bool $showPaymentDetails;

    public function __construct(/* ... */) {
        $this->hasPurchaseOrder = $this->purchaseOrder !== null;
        $this->hasDiscount = collect($this->lineItems)->some(fn ($item) => $item->discount !== null);
        $this->hasNotes = $this->notes !== null && trim($this->notes) !== '';
        $this->showPaymentDetails = $this->payment !== null;
    }
}
<!-- ✅ Clean conditional in template -->
@if($data->hasPurchaseOrder)
    <div class="po-number">PO: {{ $data->purchaseOrder }}</div>
@endif

The Slot Pattern for Extensible Sections

<!-- Base template with optional slots -->
<div class="document">
    @yield('before-header')
    @include('components.header')
    @yield('after-header')

    @yield('before-items')
    @include('components.line-items')
    @yield('after-items')

    @include('components.totals')

    @yield('before-footer')
    @include('components.footer')
    @yield('after-footer')
</div>
<!-- Invoice-specific extensions -->
@extends('templates.base')

@section('after-header')
    @include('components.parties')
@endsection

@section('after-items')
    @if($data->hasNotes)
        @include('components.notes')
    @endif
@endsection

Pattern 4: Responsive Tables

Tables in PDFs face unique challenges: they can't scroll horizontally, and they need to handle variable-width content without breaking.

Smart Column Widths

/* Define column widths explicitly */
.line-items th:nth-child(1) { width: 8%; }   /* # */
.line-items th:nth-child(2) { width: 42%; }  /* Description */
.line-items th:nth-child(3) { width: 10%; }  /* Qty */
.line-items th:nth-child(4) { width: 15%; }  /* Unit Price */
.line-items th:nth-child(5) { width: 10%; }  /* VAT */
.line-items th:nth-child(6) { width: 15%; }  /* Total */

Text Overflow Handling

.description-cell {
    max-width: 0;           /* Force the cell to respect width */
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Or wrap with a maximum number of lines */
.description-cell-wrapped {
    display: -webkit-box;
    -webkit-line-clamp: 3;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

Adaptive Tables for Long Items

<!-- If any item has a long description, switch to a stacked layout -->
@if($data->hasLongDescriptions)
    @foreach($items as $item)
        <div class="line-item-stacked">
            <div class="description">{{ $item->description }}</div>
            <div class="details">
                <span>{{ $item->quantity }} × {{ $item->unitPrice }}</span>
                <span class="total">{{ $item->totalPrice }}</span>
            </div>
        </div>
    @endforeach
@else
    <table class="line-items">
        <!-- Standard table layout -->
    </table>
@endif

Pattern 5: Multi-Variant Templates

When you need the same document in multiple variants (standard invoice, proforma invoice, credit note), don't duplicate the entire template.

Variant Configuration

class DocumentVariant
{
    public function __construct(
        public readonly string $title,         // "INVOICE", "CREDIT NOTE", "QUOTE"
        public readonly string $accentColor,   // "#2563eb", "#dc2626"
        public readonly string $numberPrefix,  // "INV-", "CN-", "QT-"
        public readonly bool $showPaymentTerms,
        public readonly bool $showBankDetails,
        public readonly bool $showValidityPeriod,
        public readonly ?string $watermark,    // "DRAFT", "PROFORMA", null
    ) {}

    public static function invoice(): self
    {
        return new self('INVOICE', '#2563eb', 'INV-', true, true, false, null);
    }

    public static function creditNote(): self
    {
        return new self('CREDIT NOTE', '#dc2626', 'CN-', false, true, false, null);
    }

    public static function quote(): self
    {
        return new self('QUOTE', '#059669', 'QT-', false, false, true, null);
    }

    public static function proforma(): self
    {
        return new self('PROFORMA INVOICE', '#2563eb', 'PI-', true, true, false, 'PROFORMA');
    }
}
<!-- One template, multiple variants -->
<h2 style="color: {{ $variant->accentColor }}">{{ $variant->title }}</h2>

@if($variant->watermark)
    <div class="watermark">{{ $variant->watermark }}</div>
@endif

@if($variant->showPaymentTerms)
    @include('components.payment-terms')
@endif

Pattern 6: White-Label Templates

When customers need their own branding on documents:

class BrandConfig
{
    public function __construct(
        public readonly ?string $logoUrl,
        public readonly string $primaryColor,
        public readonly string $fontFamily,
        public readonly ?string $headerText,
        public readonly ?string $footerText,
    ) {}

    public static function fromTeam(Team $team): self
    {
        return new self(
            logoUrl: $team->logo_url,
            primaryColor: $team->brand_color ?? '#2563eb',
            fontFamily: $team->font_preference ?? 'Inter',
            headerText: $team->document_header,
            footerText: $team->legal_footer_text,
        );
    }
}
/* CSS custom properties for branding */
:root {
    --brand-color: {{ $brand->primaryColor }};
    --brand-font: '{{ $brand->fontFamily }}', sans-serif;
}

.document-title {
    color: var(--brand-color);
    font-family: var(--brand-font);
}

.header-bar {
    border-bottom: 2pt solid var(--brand-color);
}

This approach lets customers customize appearance without touching the template structure — keeping the document reliable while allowing brand differentiation. Services like PDF-API.io take this further by providing a visual template editor, so customers can design their branded documents without any code.

Pattern 7: Testing Templates

Visual Regression Testing

Generate PDFs with known data and compare them pixel-by-pixel against baselines:

it('renders invoice correctly', function () {
    $data = InvoiceTemplateData::fixture('standard');
    $pdf = PdfRenderer::render('invoice', $data);

    expect($pdf)->toMatchPdfSnapshot('invoice-standard');
});

it('handles 200 line items without layout breaks', function () {
    $data = InvoiceTemplateData::fixture('extreme-200-items');
    $pdf = PdfRenderer::render('invoice', $data);

    // Verify it generates without errors
    expect($pdf)->not->toBeEmpty();

    // Verify page count is reasonable
    expect(PdfInspector::pageCount($pdf))->toBeBetween(4, 8);
});

Data Fixture Strategy

Create named fixtures that cover edge cases:

class InvoiceTemplateData
{
    public static function fixture(string $name): self
    {
        return match ($name) {
            'minimal' => self::minimalFixture(),
            'standard' => self::standardFixture(),
            'extreme-200-items' => self::extremeFixture(200),
            'long-descriptions' => self::longDescriptionFixture(),
            'multi-currency' => self::multiCurrencyFixture(),
            'with-discount' => self::withDiscountFixture(),
            'credit-note' => self::creditNoteFixture(),
            'rtl-arabic' => self::rtlFixture(),
            default => throw new InvalidArgumentException("Unknown fixture: {$name}"),
        };
    }
}

Conclusion

The difference between a template that lasts a month and one that lasts years comes down to these patterns:

  1. Component hierarchy: Break templates into reusable, testable pieces
  2. Strict data contracts: All formatting happens in the data layer
  3. Clean conditionals: Boolean flags, not inline checks
  4. Responsive tables: Handle variable content gracefully
  5. Multi-variant support: One template, many document types
  6. White-label config: Brand customization without template duplication
  7. Automated testing: Visual regression tests with edge-case fixtures

Invest in these patterns early. A template that's easy to maintain is a template that gets improved — and a template that's hard to maintain is one that slowly decays until someone rewrites it from scratch.


Want to skip the template engineering? PDF-API.io's visual editor lets you design templates with drag-and-drop — no HTML, no maintenance, no headaches. Try it free.

Ready to automate your PDFs?

Start generating professional documents in minutes. Free plan includes 100 PDFs/month.

Start for Free