ScreenshotNeo

BlogHTML to image & PDF

How to Add HTML Headers and Footers with KnpSnappyBundle

Add branded HTML headers, footers, and page numbers to Symfony PDFs with KnpSnappyBundle and wkhtmltopdf.

By the ScreenshotNeo team1 October 20267 min read

Use wkhtmltopdf’s header-html and footer-html options through KnpSnappyBundle. Set enough top and bottom margin for the templates, then pass absolute URLs (or accessible file paths) to the header and footer. For simple text, use options such as footer-right with wkhtmltopdf substitution variables.

KnpSnappyBundle integrates Snappy with Symfony, and Snappy invokes wkhtmltopdf. The bundle’s README describes Snappy as a PHP wrapper for wkhtmltopdf and the bundle as its Symfony integration: KnpSnappyBundle documentation.

1. Install and configure KnpSnappyBundle

Install the bundle and make sure the wkhtmltopdf binary exists on the conversion machine.

composer require knplabs/knp-snappy-bundle
which wkhtmltopdf
wkhtmltopdf --version

Configure the binary and global PDF options in config/packages/knp_snappy.yaml:

knp_snappy:
  pdf:
    enabled: true
    binary: /usr/local/bin/wkhtmltopdf
    options:
      margin-top: 25mm
      margin-bottom: 20mm
      header-html: 'https://example.test/pdf/header'
      footer-html: 'https://example.test/pdf/footer'
      header-spacing: 4
      footer-spacing: 4

The exact option names come from wkhtmltopdf. Its official usage manual documents --header-html and --footer-html; the page-settings reference lists the related margins, spacing, text, and JavaScript settings: wkhtmltopdf usage manual and page settings reference.

2. Create an HTML header template

Create a route that returns a small, self-contained HTML document. Keep CSS and JavaScript compatible with the wkhtmltopdf rendering engine.

// src/Controller/PdfChromeController.php
namespace App\\Controller;

use Symfony\\Component\\HttpFoundation\\Response;
use Symfony\\Component\\Routing\\Attribute\\Route;

final class PdfChromeController
{
    #[Route('/pdf/header', name: 'pdf_header', methods: ['GET'])]
    public function header(): Response
    {
        return new Response(\'<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    html, body { margin: 0; padding: 0; }
    body { font: 12px Arial, sans-serif; color: #222; }
    .header { width: 100%; border-bottom: 1px solid #bbb; padding: 0 0 6px; }
    .brand { font-weight: bold; }
  </style>
</head>
<body>
  <div class="header"><span class="brand">Example Company</span></div>
</body>
</html>\');
    }
}

A Twig template is easier to maintain for real applications:

{# templates/pdf/header.html.twig #}
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    html, body { margin: 0; padding: 0; }
    .header { border-bottom: 1px solid #bbb; padding-bottom: 6px; }
  </style>
</head>
<body>
  <div class="header">{{ company_name|e }}</div>
</body>
</html>

HTML templates receive wkhtmltopdf substitution values as query-string parameters. Read the values in JavaScript and put them into elements whose class names match the variables.

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <script>
    function subst() {
      var params = new URLSearchParams(window.location.search);
      document.querySelector('.page').textContent = params.get('page') || '';
      document.querySelector('.topage').textContent = params.get('topage') || '';
      document.querySelector('.date').textContent = params.get('isodate') || '';
    }
  </script>
</head>
<body onload="subst()" style="border:0;margin:0">
  <div style="width:100%;border-top:1px solid #999;text-align:right;padding-top:4px">
    Page <span class="page"></span> of <span class="topage"></span>
    &nbsp; · &nbsp; <span class="date"></span>
  </div>
</body>
</html>

The documented variables are [page], [frompage], [topage], [webpage], [section], [subsection], [date], [isodate], [time], [title], [doctitle], [sitepage], and [sitepages]. In an HTML template, use the corresponding query-string value. For a text-only footer, the equivalent is:

footer-right: 'Page [page] of [topage]'

4. Render a PDF with per-document options

Global configuration is convenient for every document. Per-render options let each invoice, report, or statement choose its own templates and spacing.

// src/Service/PdfRenderer.php
namespace App\\Service;

use Knp\\Snappy\\Pdf;

final class PdfRenderer
{
    public function __construct(private Pdf $pdf) {}

    public function render(string $bodyHtml, string $headerUrl, string $footerUrl): string
    {
        return $this->pdf->getOutputFromHtml($bodyHtml, [
            'margin-top' => '25mm',
            'margin-bottom' => '20mm',
            'header-html' => $headerUrl,
            'footer-html' => $footerUrl,
            'header-spacing' => 4,
            'footer-spacing' => 4,
            'encoding' => 'UTF-8',
            'print-media-type' => true,
        ]);
    }
}

Return the bytes from a controller:

use Symfony\\Component\\HttpFoundation\\Response;
use Symfony\\Component\\HttpFoundation\\ResponseHeaderBag;

$pdfBytes = $renderer->render($bodyHtml, $headerUrl, $footerUrl);
$response = new Response($pdfBytes);
$response->headers->set('Content-Type', 'application/pdf');
$response->headers->set('Content-Disposition', 'inline; filename="report.pdf"');
return $response;

5. Generate absolute template URLs

wkhtmltopdf fetches header and footer resources in its own process. Give it an absolute URL that resolves from the conversion host, or use an accessible filesystem path.

use Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;

$headerUrl = $urlGenerator->generate(
    'pdf_header',
    [],
    UrlGeneratorInterface::ABSOLUTE_URL
);
$footerUrl = $urlGenerator->generate(
    'pdf_footer',
    [],
    UrlGeneratorInterface::ABSOLUTE_URL
);

Do not assume that a URL working in a user’s browser works for the wkhtmltopdf process. Check DNS, TLS certificates, firewall rules, reverse-proxy routing, authentication, and cookies from the machine running the converter. If the route requires a logged-in browser session, expose a controlled rendering route or use a file path instead.

6. Option selection checklist

Need Option or approach
Rich branding, tables, logos, or CSS header-html and footer-html
One line of text header-left, header-center, header-right, or matching footer options
Page numbers HTML template JavaScript or text such as Page [page] of [topage]
Prevent overlap Increase margin-top or margin-bottom
Move the template away from content Adjust header-spacing or footer-spacing
Print stylesheet rules print-media-type
Local assets Use an accessible path and deliberately evaluate enable-local-file-access
Modern JavaScript Transpile or polyfill; wkhtmltopdf may not support current APIs

7. Why headers and footers disappear

Wrong or unreachable URL

Fetch the generated URL from the conversion host with curl -I. A private hostname, invalid certificate, missing DNS record, redirect loop, or firewall can make the template unavailable.

Insufficient margin

The header or footer may technically render outside the printable content area. Increase the corresponding margin first, then tune spacing.

Unsupported wkhtmltopdf build

Several header and footer capabilities depend on patched-Qt features. Confirm the exact binary and version installed on the server, rather than relying on a developer workstation’s version.

Authentication and cookies

The conversion process does not automatically share a browser’s authenticated session. Use a public, short-lived, access-controlled route or pass the required request data through supported wkhtmltopdf options.

JavaScript or CSS incompatibility

Keep templates small and use ES5-compatible JavaScript when possible. Replace unsupported APIs with simple DOM operations or add a polyfill.

Local-file security restrictions

If a template references local CSS or images, the binary may block access. Enabling local file access can solve that, but the Snappy project warns that it is risky with untrusted HTML or JavaScript. Only enable it when the input and referenced files are controlled.

8. Debugging workflow

  1. Log the final wkhtmltopdf binary path and version.
  2. Open the header and footer URL from the conversion machine, not only from your laptop.
  3. Render the template URL by itself and inspect its HTML, CSS, and JavaScript.
  4. Start with a visible border and plain text, then add branding incrementally.
  5. Set generous margins such as 30mm while diagnosing overlap.
  6. Compare a text footer with an HTML footer to isolate template versus layout problems.
  7. Inspect stderr from Snappy/wkhtmltopdf for network, SSL, JavaScript, and file-access errors.

9. Performance, reliability, and cost

Every PDF conversion starts a wkhtmltopdf process and may fetch the body, header, footer, stylesheets, fonts, and images. Keep templates self-contained or serve assets from a low-latency internal endpoint. Reuse a stable binary and avoid unnecessary remote requests.

For reliable page numbering, test documents with one page, many pages, long titles, missing metadata, and content that reaches the bottom margin. Add retries around transient network failures, but do not blindly retry malformed HTML or an unreachable route.

Cache immutable CSS, fonts, and logos. If PDFs contain sensitive data, keep header and footer routes private and protect them with a short-lived token or an internal network policy. Treat user-supplied HTML as untrusted input, especially when considering local-file access or JavaScript execution.

10. Or skip the browser setup

If you need a clean capture or PDF without maintaining a wkhtmltopdf process, ScreenshotNeo provides a website screenshot API and MCP server. One GET request accepts a URL and returns a PNG, JPEG, WebP, or PDF. See the ScreenshotNeo API documentation.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/invoice -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://example.com/invoice"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com/invoice' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers identify the page verdict and billing result. An MCP server lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf. The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots, and every feature is available on every plan. Create a free ScreenshotNeo account.

11. FAQ

Can I use a local file for a header?

Yes, if the wkhtmltopdf process can read it and local-file access is enabled where required. Evaluate the security impact before enabling that option for untrusted input.

Should I use HTML or text headers?

Use text options for a short label or page counter. Use HTML when you need layout, branding, images, tables, or dynamic metadata.

Increase margin-bottom; footer spacing alone does not reserve enough page area.

Yes. Use [topage] in a text footer or read the topage query parameter in an HTML template.

Does KnpSnappyBundle render the HTML itself?

No. It passes options to Snappy, which invokes wkhtmltopdf. The installed wkhtmltopdf build and its network and security environment determine what renders.