PDF: Snappy, wkhtmltopdf & Template Setup

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

How can we make the email we're sending from the console command cooler? By adding an attachment! Wait, hmm. That's probably too easy - Mailer makes attachments simple. Ok, then... how about this: in addition to having the table inside the email that summarizes what the author wrote during the past week, let's generate a PDF with a similar table and attach it to the email.

So that's the first challenge: generating a styled PDF... and hopefully enjoying the process!

Installing Snappy & wkhtmltopdf

My favorite tool for creating PDFs is called Snappy. Fly over to your terminal and install it with:

composer require knplabs/knp-snappy-bundle

Snappy is a wrapper around a command-line utility called wkhtmltopdf. It has some quirks, but is a super powerful tool: you create some HTML that's styled with CSS, give it to wkhtmltopdf, it renders it like a browser would, and gives you back a PDF version. Snappy makes working with wkhtmltopdf pretty easy, but you'll need to make sure it's installed on your system. I installed it on my Mac via brew.

wkhtmltopdf --version

Also, check where it's installed with which or whereis:

which wkhtmltopdf

Mine is installed at /usr/local/bin/wkhtmltopdf. If your binary live somewhere else, you'll need to tweak some config. When we installed the bundle, the bundle's recipe added a new section to the bottom of our .env file with two new environment variables.

46 lines .env
... lines 1 - 41
###> knplabs/knp-snappy-bundle ###
WKHTMLTOPDF_PATH=/usr/local/bin/wkhtmltopdf
WKHTMLTOIMAGE_PATH=/usr/local/bin/wkhtmltoimage
###

These are both used inside a new knp_snappy.yaml file that was also added by the bundle.

knp_snappy:
pdf:
enabled: true
binary: '%env(WKHTMLTOPDF_PATH)%'
options: []
image:
enabled: true
binary: '%env(WKHTMLTOIMAGE_PATH)%'
options: []

The WKHTMLTOPDF_PATH variable already equals what I have on my machine. So if your path is different, copy this, paste it to your .env.local file, and customize it. Oh, and don't worry about wkhtmltoimage: we won't use that utility.

Creating the PDF Templates

Ultimately, to create the PDF, we're going to render a template with Twig and pass the HTML from that to Snappy so it can do its work. Open up templates/email/author-weekly-report.html.twig.

{% extends 'email/emailBase.html.twig' %}
{% block content %}
<hr>
<spacer size="20"></spacer>
<row>
<columns>
<p>
What a week {{ email.toName }}! Here's a quick review of what you've been up to on the Space Bar this week
</p>
</columns>
</row>
<row>
<columns>
<table>
<tr>
<th>#</th>
<th>Title</th>
<th>Comments</th>
</tr>
{% for article in articles %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ article.title }}</td>
<td>{{ article.comments|length }}</td>
</tr>
{% endfor %}
</table>
</columns>
</row>
<row>
<columns>
<center>
<spacer size="20"></spacer>
<button href="{{ url('app_homepage') }}">Check on the Space Bar</button>
<spacer size="20"></spacer>
</center>
</columns>
</row>
{% endblock %}

Hmm. In theory, we could just render this template and use its HTML. But... that won't work because it relies on the special email variable. And more importantly, we probably don't want the PDF to look exactly like the email - we don't want the logo on top, for example.

No problem: let's do some organizing! Copy the table code. Then, in the templates/email directory, I'll create a new file called _report-table.html.twig and paste!

<table>
<tr>
<th>#</th>
<th>Title</th>
<th>Comments</th>
</tr>
{% for article in articles %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ article.title }}</td>
<td>{{ article.comments|length }}</td>
</tr>
{% endfor %}
</table>

Let's make this fancier by adding class="table table-striped". Oo, fancy!

<table class="table table-striped">
... lines 2 - 13
</table>

Those CSS classes come from Bootstrap CSS, which our site uses, but our emails do not. So when we render this table in the email, these won't do anything. But my hope is that when we generate the PDF, we will include Bootstrap CSS and our table will look pretty.

Back in author-weekly-report.html.twig, take out that table and just say {{ include('email/_report-table.html.twig') }}

... lines 1 - 2
{% block content %}
... lines 4 - 12
<row>
<columns>
{{ include('email/_report-table.html.twig') }}
</columns>
</row>
... lines 18 - 26
{% endblock %}

Now we can create a template that we will render to get the HTML for the PDF. Well, we could just render this _report-table.html.twig template... but because it doesn't have an HTML body or CSS, it would look... simply awful.

Instead, in templates/email/, create a new file: author-weekly-report-pdf.html.twig. To add some basic HTML, I'll use a PhpStorm shortcut that I just learned! Add an exclamation point then hit "tab". Boom! Thanks Victor!

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
</body>
</html>

Because we're going to add Bootstrap CSS to this template, let's add a little Bootstrap structure: <div class="container">, <div class="row"> and <div class="col-sm-12">.

... lines 1 - 11
<body>
<div class="container">
<div class="row">
<div class="col-sm-12">
... lines 16 - 18
</div>
</div>
</div>
</body>
... lines 23 - 24

Inside, how about an <h1> with "Weekly Report" and today's date, which we can get with {{ 'now'|date('Y-m-d') }}.

... lines 1 - 11
<body>
<div class="container">
<div class="row">
<div class="col-sm-12">
<h1>Weekly report {{ 'now'|date('Y-m-d') }}</h1>
... lines 17 - 18
</div>
</div>
</div>
</body>
... lines 23 - 24

Bring in the table with {{ include('email/_report-table.html.twig') }}.

... lines 1 - 11
<body>
<div class="container">
<div class="row">
<div class="col-sm-12">
<h1>Weekly report {{ 'now'|date('Y-m-d') }}</h1>
{{ include('email/_report-table.html.twig') }}
</div>
</div>
</div>
</body>
... lines 23 - 24

Adding CSS to the Template

If we just rendered this and passed the HTML to Snappy, it would work, but would contain no CSS styling... so it would look like it was designed in the 90's. If you look in base.html.twig, this project uses Webpack Encore. The encore_entry_link_tags() function basically adds the base CSS, which includes Bootstrap.

Copy this line, close that template, and add this to the PDF template.

... lines 1 - 2
<head>
... lines 4 - 9
{{ encore_entry_link_tags('app') }}
</head>
... lines 12 - 24

Even if you're not using Encore, the point is that an easy way to style your PDF is by bringing in the same CSS that your site uses. Oh, and because our site has a gray background... but I want my PDF to not share that specific styling, I'll hack in a background-color: #fff.

By the way, if our app needed to generate multiple PDF files, I would absolutely create a PDF "base template" - like pdfBase.html.twig - so that every PDF could share the same look and feel. Also, I'm not bringing in any JavaScript tags, but you could if your JavaScript is responsible for helping render how your page looks.

Ok, we're ready! Next, let's use Snappy to create the PDF, attach it to the email and high-five ourselves. Because celebrating victories is important!

Leave a comment!

This tutorial is built on Symfony 4.3, but will work well with Symfony 4.4 or 5.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "aws/aws-sdk-php": "^3.87", // 3.110.11
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.1
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-snappy-bundle": "^1.6", // v1.6.0
        "knplabs/knp-time-bundle": "^1.8", // v1.9.1
        "league/flysystem-aws-s3-v3": "^1.0", // 1.0.23
        "league/flysystem-cached-adapter": "^1.0", // 1.0.9
        "league/html-to-markdown": "^4.8", // 4.8.2
        "liip/imagine-bundle": "^2.1", // 2.1.0
        "nexylan/slack-bundle": "^2.1,<2.2.0", // v2.1.0
        "oneup/flysystem-bundle": "^3.0", // 3.1.0
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.4.1
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.3.4
        "symfony/console": "^4.0", // v4.3.4
        "symfony/flex": "^1.0", // v1.6.2
        "symfony/form": "^4.0", // v4.3.4
        "symfony/framework-bundle": "^4.0", // v4.3.4
        "symfony/mailer": "4.3.*", // v4.3.4
        "symfony/messenger": "4.3.*", // v4.3.4
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.3.4
        "symfony/sendgrid-mailer": "4.3.*", // v4.3.4
        "symfony/serializer-pack": "^1.0", // v1.0.2
        "symfony/twig-bundle": "^4.0", // v4.3.4
        "symfony/twig-pack": "^1.0", // v1.0.0
        "symfony/validator": "^4.0", // v4.3.4
        "symfony/web-server-bundle": "^4.0", // v4.3.4
        "symfony/webpack-encore-bundle": "^1.4", // v1.6.2
        "symfony/yaml": "^4.0", // v4.3.4
        "twig/cssinliner-extra": "^2.12", // v2.12.0
        "twig/extensions": "^1.5", // v1.5.4
        "twig/inky-extra": "^2.12" // v2.12.0
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.2.2
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/browser-kit": "4.3.*", // v4.3.5
        "symfony/debug-bundle": "^3.3|^4.0", // v4.3.4
        "symfony/dotenv": "^4.0", // v4.3.4
        "symfony/maker-bundle": "^1.0", // v1.13.0
        "symfony/monolog-bundle": "^3.0", // v3.4.0
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.3.4
        "symfony/profiler-pack": "^1.0", // v1.0.4
        "symfony/var-dumper": "^3.3|^4.0" // v4.3.4
    }
}