Generate PDFs in Symfony

Add a PDF download to an existing Symfony page, or generate a PDF from a template. The examples use PDFCrowd's HTML to PDF API and work for invoices, reports, receipts, and other documents built from application data.

Set up the client

Install the PHP client in your Symfony application:

composer require pdfcrowd/pdfcrowd

Symfony already loads Composer's autoloader. You can run the examples below with the included demo credentials, or replace them with your PDFCrowd username and API key.

Add a PDF download to an existing page

An existing page can display its usual HTML and return a PDF when the user clicks a download button. Reuse the page's template, data preparation, and access checks.

Choose what to send to PDFCrowd:

Convert rendered HTML

In an existing Symfony application using Twig, extend the document controller at src/Controller/DocumentController.php. The getDocumentForCurrentUser($documentId) method is a placeholder for your application's document lookup and permission checks. Replace it with your own logic and keep the controller's existing access restrictions. The template at templates/documents/detail.html.twig receives the result as document.

The form below uses Symfony's CSRF component. Ensure symfony/security-csrf is installed, CSRF protection is enabled, and sessions are configured.

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Attribute\Route;

class DocumentController extends AbstractController
{
    #[Route(
        '/documents/{documentId}', name: 'document_detail',
        requirements: ['documentId' => '\d+'], methods: ['GET', 'POST']
    )]
    public function detail(Request $request, int $documentId): Response
    {
        $document = $this->getDocumentForCurrentUser($documentId);

        if ($request->isMethod('POST')
            && $request->request->has('download_pdf')) {
            if (!$this->isCsrfTokenValid(
                'download-document-' . $documentId,
                $request->request->getString('_token')
            )) {
                throw new AccessDeniedHttpException('Invalid CSRF token.');
            }

            $html = $this->renderView('documents/detail.html.twig', [
                'document' => $document,
            ]);

            try {
                $client = new \Pdfcrowd\HtmlToPdfClient('demo', 'demo');
                $client->setContentViewportWidth('balanced');
                $pdf = $client->convertString($html); // Binary string
            } catch (\Pdfcrowd\Error $error) {
                // Add your application's error handling here.
                throw $error;
            }

            return new Response($pdf, 200, [
                'Content-Type' => 'application/pdf',
                'Content-Disposition' => HeaderUtils::makeDisposition(
                    'attachment', "document-{$documentId}.pdf"
                ),
            ]);
        }

        return $this->render('documents/detail.html.twig', [
            'document' => $document,
        ]);
    }
}

Add this form to the Twig template, outside any existing form:

<form method="post" class="pdfcrowd-remove">
    <input type="hidden" name="_token"
           value="{{ csrf_token('download-document-' ~ document.id) }}">
    <button type="submit" name="download_pdf" value="1">Download PDF</button>
</form>

The form's document.id must match the controller's $documentId so both use the same CSRF token ID.

GET requests display the HTML page. Submitting the download button sends a POST, validates the token, renders the same template, converts it, and returns a PDF download. The pdfcrowd-remove class hides the form in the PDF while leaving it visible on the webpage.

The response is created only after conversion succeeds, with the PDF held in memory as a binary string. The catch block rethrows SDK errors to Symfony's error handler. Keep debug mode disabled in production so error responses do not expose private details. See SDK error handling for details.

To display the PDF in the browser, change attachment to inline. Set page size, margins, or other PDF options before convertString().

Make CSS and images available

PDFCrowd needs reachable URLs for stylesheets, images, and fonts referenced by the rendered HTML. Absolute asset URLs, such as https://your-site.example/css/document.css, need no additional base URL.

For relative paths, add the public site or directory URL to your Twig configuration:

# config/packages/twig.yaml
twig:
    globals:
        pdf_base_url: 'https://your-site.example/'

Merge this setting with your existing configuration and replace https://your-site.example/ with your website's public URL. Then add this to the template's <head>, before stylesheet links:

{% if pdf_base_url is defined and pdf_base_url %}
<base href="{{ pdf_base_url }}">
{% endif %}

The Twig global is available when rendering from either a controller or a service. The <base> element tells PDFCrowd where to resolve relative URLs. For example, /css/document.css becomes https://your-site.example/css/document.css. This also affects assets and hyperlinks when the template is viewed as a webpage; avoid adding a second <base> if the template already supplies one.

PDFCrowd cannot reach assets on your development machine through localhost or 127.0.0.1. For local assets, you can instead package the HTML and assets in a ZIP archive. Keep paths relative to the archive's folders and omit the website <base> for that approach.

Convert the page URL instead

Instead of sending rendered HTML, you can ask PDFCrowd to open the page through its URL. Keep the lookup, POST and token checks, client setup, error handling, and response code above. In the PDF branch, remove the statement that sets $html. Then replace the convertString() line with:

$pdf = $client->convertUrl($request->getUri()); // Binary string

This preserves the current path and query parameters. PDFCrowd makes a separate GET request, so the controller returns HTML; only the original POST triggers conversion. The server must be able to handle that GET while the POST waits for the PDF. PHP's built-in development server handles one request at a time by default, so this approach can time out there.

Use a URL that PDFCrowd can reach. Ensure $request->getUri() uses your public address, with the appropriate trusted-host and proxy settings.

The separate request does not inherit the user's Symfony session. For protected pages, configure cookies or HTTP authentication as appropriate, or use rendered HTML from the original request. If forwarding the session cookie makes that GET wait for a session lock held by the POST, use the rendered-HTML approach.

Generate a PDF from a template

You can also generate a PDF directly from a template and application data. Use an existing Twig template or a separate template designed for the document.

Place this service in src/Service/PdfRenderer.php. It renders the supplied template and returns the PDF as a binary string, using the same asset configuration as the controller example:

namespace App\Service;

use Twig\Environment;

class PdfRenderer
{
    public function __construct(private Environment $twig)
    {
    }

    public function renderPdf(string $template, array $context): string
    {
        $html = $this->twig->render($template, $context);

        $client = new \Pdfcrowd\HtmlToPdfClient('demo', 'demo');
        $client->setContentViewportWidth('balanced');
        return $client->convertString($html);
    }
}

Symfony's default service discovery and autowiring supply the configured Twig environment. For example, add a \App\Service\PdfRenderer $pdfRenderer argument to your controller action; Symfony supplies the service. Given a $document and a template at templates/documents/pdf.html.twig, call:

$pdf = $pdfRenderer->renderPdf('documents/pdf.html.twig', [
    'document' => $document,
]); // PDF bytes

Pass all data the template needs in $context. Use the resulting bytes for a download, storage, or email. If rendering or conversion fails, the caller receives the exception.

Outside a web request, inject the service into a Symfony console command. Use a template without request or session dependencies, and omit the download form because its CSRF token needs a session.

For more conversion inputs and PDF settings, see the PHP examples and API reference.