January 19, 2016
by Jason Roman





Categories:
Protips  Tutorials

Tags:
HTML5  PHP  Symfony  Symfony2  Symfony3  wkhtmltopdf


How to Export any Symfony Route to PDF

Use a Symfony Listener to export any route to PDF with no additional controller code.

As a web developer you will likely come across a time when you have to do some reporting, and will choose the option of exporting one or more of your existing pages to PDF. Exporting HTML pages to PDF is a pretty straightforward process with wkhtmltopdf via the command-line, but you'll probably need a way to generate the PDF directly from your website. To do this in Symfony you might consider having a secondary route that loads the template from your main route and runs wkhtmltopdf on that content. You might also incorporate the KnpSnappyBundle for that second route. You could even use a single route and pass a parameter to it signifying that it needs to be exported to PDF.

All of these approaches will work just fine but they can be messy and duplicate a lot of code, especially as you start exporting multiple routes. Thankfully, Symfony is extensible enough that you can export your pages to PDF without ever modifying a single line of your controllers or without having to manually call a service. We're going to solve this, and all it is going to require is passing ?print=pdf to any existing URL. First, let's create a listener that will perform our export:

# src/AppBundle/Resources/config/services.yml
services:
listener.export_pdf:
class: AppBundle\EventListener\ExportPdfListener
arguments:
- @templating
- @knp_snappy.pdf
tags:
- { name: kernel.event_listener, event: kernel.controller, method: onKernelController }
- { name: kernel.event_listener, event: kernel.view, method: onKernelView }
We are listening on both the kernel.controller and kernel.view events - you'll see why shortly. Let's take a look at our listener class definition:
namespace AppBundle\EventListener;

use Symfony\Component\HttpKernel\Event\FilterControllerEvent;
use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent;
use Symfony\Component\Templating\EngineInterface;

use Knp\Bundle\SnappyBundle\Snappy\LoggableGenerator;

class ExportPdfListener
{
private $templating;
private $knpSnappyPdf;

public function __construct(
EngineInterface $templating,
LoggableGenerator $knpSnappyPdf
) {
$this->templating = $templating;
$this->knpSnappyPdf = $knpSnappyPdf;
}
//...
}
Easy enough - we'll need the templating and knp_snappy.pdf services. Now let's take a look at the hook into our controller:
// class ExportPdfListener
public function onKernelController(FilterControllerEvent $event)
{
$request = $event->getRequest();
$controllers = $event->getController();

if (!is_array($controllers)) {
return;
}

$controller = $controllers[0];

// check if 'print=pdf' is in the query string
if ($request->query->get('print') === 'pdf')
{
$request->headers->add(array(
'X-Export-Pdf' => true,
'X-Asset-Absolute-Url' => true,
));
}
}
As you can see, the controller listener does not perform any of the actual exporting. All we are doing is hooking into the controller and checking if print=pdf was passed to the query string. If so, we create a few custom HTTP headers that will be passed to the response. The first, X-Export-Pdf, acts as a flag that signifies we are exporting our route to PDF, and you can check for that flag anywhere in your code or templates and perform conditional behavior. The second HTTP header, X-Asset-Absolute-Url, is necessary because wkhtmltopdf cannot handle relative paths for assets (css, js, images) - I'll explain that in a bit. Let's finish off the rest of our listener by hooking into the kernel view:
// class ExportPdfListener
public function onKernelView(GetResponseForControllerResultEvent $event)
{
$request = $event->getRequest();
$result = $event->getControllerResult();

// make sure the proper headers have been set for exporting to pdf
// and that a template was trying to be rendered
if (!($request->headers->get('x-export-pdf') && $request->attributes->has('_template'))) {
return;
}

$template = $request->attributes->get('_template');

// render the template as would normally be done in the controller
$html = $this->templating->render($template->getLogicalName(), $result);

// send the PDF to the user
$event->setResponse(new Response(
$this->knpSnappyPdf->getOutputFromHtml($html),
200,
array(
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="output.pdf"'
)
);
}
First, we check that the custom x-export-pdf header was set from our controller listener (you can check the x-asset-absolute-url header here as well) and that there is a template to be rendered Symfony automatically converts these headers to all lowercase. Then the HTML is generated and passed to the knp_snappy.pdf service which generates the PDF. Finally, we override the controller response to present the PDF to the user as an attachment that they can open or download. It's that simple! All you have to do is add ?print=pdf to the end of any URL or create a button with a link to the URL with that additional query string, and you'll have an exported PDF without having modified code in any of your controllers.

Remember the additional X-Asset-Absolute-Url HTTP header we added before? Again, wkhtmltopdf does not properly handle relative URLs, so we must use absolute paths for any external assets like images or stylesheets or JavaScript. Here is an example of using that header in our Twig templates to properly load a stylesheet:
{% stylesheets output='css/site.css' filter='cssrewrite'
'bundles/app/css/site.css'
%}
{% if app.request.headers.get('x-asset-absolute-url') %}
{# use with Symfony >= 2.7 #}
{% set asset_url = absolute_url(asset_url) %}

{# use with Symfony <= 2.6 #}
{% set asset_url = app.request.schemeAndHttpHost ~ asset_url %}
{% endif %}

{% endstylesheets %}
What we are doing here is checking that the X-Asset-Absolute-Url is set, and if so we load our stylesheet with an absolute URL. I provided two methods - the absolute_url() function was introduced in Symfony 2.7, otherwise you can just use the second method and append the asset url to app.request.schemeAndHttpHost. You could always load all of your assets with absolute paths to avoid these extra checks, but I like to use relative URLs whenever possible and only use absolute paths with exported PDFs.

Using listeners in this way is very extensible. Let's expand our example. Say we have a bunch of reports and only want these reports to be exportal, rather than all of our routes. This can be done very simply by creating a base controller (an interface would work as well) that all of our report controllers extend from, and then checking that our controller extends that base controller before setting our HTTP headers. Here's the updated controller listener that handles this case:
use AppBundle\Controller\BaseReportsController;
//...

public function onKernelController(FilterControllerEvent $event)
{
$request = $event->getRequest();
$controllers = $event->getController();

if (!is_array($controllers)) {
return;
}

// if both conditions are met, set the appropriate http headers
if ($request->query->get('print') === 'pdf' && $controller[0] instanceof BaseReportsController)
{
$request->headers->add(array(
'X-Export-Pdf' => true,
'X-Asset-Absolute-Url' => true,
));
}
}
Now the exporting will only work for controllers that extend BaseReportsController.

Another feature we could add is allowing options to be passed to the PDF export; wkhtmltopdf provides plenty of options for setting margins, headers, footers, etc. We can set up an interface that allows any of these options to be passed to the query string. Here is an example that will change the left and right margins by making the query string of your PDF route look like ?print=pdf&margin-left=25.4&margin-right=25.4
public function onKernelView(GetResponseForControllerResultEvent $event)
{
$request = $event->getRequest();
$result = $event->getControllerResult();
$options = array();

// make sure the proper headers have been set for exporting to pdf
// and that a template was trying to be rendered
if (!($request->headers->get('x-export-pdf') && $request->attributes->has('_template'))) {
return;
}

$template = $request->attributes->get('_template');

// set additional wkhtmltopdf options from the query string
foreach ($request->query->all() as $key => $value) {
$options[$key] = $value;
}

// render the template as would normally be done in the controller
$html = $this->templating->render($template->getLogicalName(), $result);

// send the PDF to the user
$event->setResponse(new Response(
$this->knpSnappyPdf->getOutputFromHtml($html, $options),
200,
array(
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="output.pdf"'
)
);
}
Here we use the foreach to loop through the query string to set additional options, and then pass them to the knp_snappy.pdf service to be applied to the generated PDF. If you wanted to restrict what wkhtmltopdf options are changeable, you could explicitly define those allowed options and then check them against the query string. For example, to only allow the left and right margins to be changed, you would replace your foreach loop above with the following:
$allowedOptions = array('margin-left', 'margin-right');

foreach ($allowedOptions as $option)
{
if ($request->query->get($option)) {
$options[$option] = $request->query->get($option);
}
}
There you have it! You can now export any route to PDF without touching any controller code or explicitly calling a service.