Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Styling PDFs with CSS

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

Our PDF attachment looks terrible. I don't know why, but the CSS is definitely not working.

Debugging this can be tricky because, even though this was originally generated from an HTML page, we can't exactly "Inspect Element" on a PDF to see what went wrong.

So... let's... think about what's happening. The encore_entry_link_tags() function creates one or more link tags to CSS files, which live in the public/build/ directory. But the paths it generates are relative - like href="/build/app.css".

We also know that the getOutputFromHtml() method works by taking the HTML, saving it to a temporary file and then effectively loading that file in a browser... and creating a PDF from what it looks like. If you load a random HTML file on your computer into a browser... and that HTML file has a CSS link tag to /build/app.css, what would happen? Well, it would look for that file on the filesystem - like literally a /build/ directory at the root of your drive.

That is what's happening behind the scenes. So, the CSS never loads... and the PDF looks like it was designed... well... by me. We can do better.

Making Absolute CSS Paths

Once you understand what's going on, the fix is pretty simple. Replace {{ encore_entry_link_tags() }} with {% for path in encore_entry_css_files('app') %}.

... lines 1 - 2
... lines 4 - 9
{% for path in encore_entry_css_files('app') %}
... line 11
{% endfor %}
... lines 14 - 26

Instead of printing all the link tags for all the CSS files we need, this allows us to loop over them. Inside, add <link rel="stylesheet" href=""> and then make the path absolute with absolute_url(path).

... lines 1 - 2
... lines 4 - 9
{% for path in encore_entry_css_files('app') %}
<link rel="stylesheet" href="{{ absolute_url(path) }}">
{% endfor %}
... lines 14 - 26

We saw this earlier: we used it to make sure the path to our logo - before we embedded it - contained the hostname. Now when wkhtmltopdf, more or less, opens the temporary HTML file in a browser, it will download the CSS from our public site and all should be happy with the world.

Let's try it! Run the console command:

php bin/console app:author-weekly-report:send

Move back over and... I'll refresh Mailtrap... great! 2 new emails. Check the attachment on the first one. It looks perfect! I mean, hopefully you're better at styling than I am... and can make this look even better, maybe with a hot-pink background and unicorn Emojis? I'm still working on my vision. The point is: the CSS is being loaded.

Let's check the other email to be sure. What? This one looks terrible! The first PDF is good... and the second one... which was generated the exact same way... has no styling!? What madness is this!?

Encore: Missing CSS after First PDF?

This is a little gotcha that's specific to Encore. For reasons that are... not that interesting right now - you can ask me in the comments - when you call an Encore Twig function the first time, it returns all the CSS files that you need for the app entrypoint. But when we go through the loop the second time, render a second template and call encore_entry_css_files() for a second time, Encore returns an empty array. Basically, you can only call an Encore function for an entrypoint once per request... or once per console command execution. Every time after, the method will return nothing.

There's a good reason for this... but it's totally messing us up! No worries, once you know what's going on, the fix is pretty simple. Find the constructor and add one more argument - I know, it's getting a bit crowded. It's EntrypointLookupInterface $entrypointLookup. I'll do my normal Alt + Enter and select "Initialize fields" to create that property and set it.

... lines 1 - 16
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
... lines 18 - 19
class AuthorWeeklyReportSendCommand extends Command
... lines 22 - 28
private $entrypointLookup;
public function __construct(UserRepository $userRepository, ArticleRepository $articleRepository, MailerInterface $mailer, Environment $twig, Pdf $pdf, EntrypointLookupInterface $entrypointLookup)
... lines 33 - 39
$this->entrypointLookup = $entrypointLookup;
... lines 42 - 88

Down below, right before we render... or right after... it won't matter, say $this->entrypointLookup->reset(). This tells Encore to forget that it rendered anything and forces it to return the same array of CSS files on each call.

... lines 1 - 19
class AuthorWeeklyReportSendCommand extends Command
... lines 22 - 49
protected function execute(InputInterface $input, OutputInterface $output)
... lines 52 - 56
foreach ($authors as $author) {
... lines 58 - 62
if (count($articles) === 0) {
... lines 68 - 83
... lines 85 - 87

This should make our PDF wonderful. Run the command one more time:

php bin/console app:author-weekly-report:send

Fly over to Mailtrap and... I'll refresh. Ok, two emails - let's check the second: that's the one what was broken before. The attachment... looks perfect.

Next, I like to keep my email logic close together and organized - it helps me to keep emails consistent and, honestly, remember what emails we're sending. Let's refactor the emails into a service... and eventually, use that to write a unit test.

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
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "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.9", // v1.17.6
        "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