How to easily create a PDF in Laravel Livewire

Exporting a customer's cart information as a PDF, quickly and easily, with some PDF specific changes in layout and information, can be nicely done with Laravel and Livewire's build-in features.

Updated on 2021-08-13 by Dimitri König

Recently I came across an interesting challenge in one of my projects: how do I create a PDF version of a one-page-cart-checkout page using Laravel Livewire. In most cases this is a simple task, especially if you know some good tools. Spatie's Browsershot Package takes the pain out of all the little things you usually have to take care of.

In this specific case there were some additional things to take into account:

  • The customer is already logged in and the cart contains customer specific information.

  • The Livewire component makes API requests to an ERP to fetch prices, availability and some other information, on almost every change. That information needs to be used without additional unnecessary requests.

  • There are some page-wide settings the customer can make which are stored in session which also need to be taken into account.

  • Performance: Since the customer is already used to a very nice and quick way of navigating the page, especially that particular full page component, he most probably is expecting the pdf export to be quick as well.

  • Testing and code maintenance afterwards: I really need a simple solution, not only now but also in 6 months.

URL solution with some customer log in challenges

Initially I thought about calling Spatie's Browsershot with the target URL, take the PDF output and stream it as a download to the user, like this:

public function toPdf()
{
	$pdf = $this->exportAsPdf($this->getCurrentUrl());

	$headers = [
		'Content-type' => 'application/pdf',
		'Content-Disposition' => 'attachment',
	];

	return response()->streamDownload(fn() => print($pdf), 'export.pdf', $headers);
}

Browsershot then compiles some options with the URL being one of them, calls a local script, which runs puppeteer, which opens the URL in a headless chrome instance, which puts the html into a pdf as soon as the page is done loading. That may take a while.

But most importantly: I would need to make sure that the headless browser opens the exact page with all the data necessary:

  • Auto log in of the customer needs to be done: Could be done via a temporary encrypted URL containing necessary information to auto log in the customer.

  • Stored session data needs to be either stored in a way that can be retrieved across multiple log ins, every multiple log ins at the same time.

  • Retrieved 3rd party api data needs to be either stored beforehand, temporarily if need be, or requested again.

  • Already set options in the components state needs to be provided as well.

That could be a viable solution but I didn't like it. It would mean writing a lot of code just to get the app to output the same html as it is already now. And it would take a bit longer than I'd like to. And since I really like my customers I don't want them to wait so long.

Event/Hook solution

Another solution would be to hook into the final output of the current page, store the html code, and make another call to the retrieve the stored html code and export it as an PDF, while making sure that I delete the stored html code afterwards. Too much of a hassle. Somehow I have this gut feeling that there is a better and simpler solution. Turns out there is.

Solution with reusing the already loaded component and data

Let's think this through: the customer is on the cart page and presses a button which calls Livewire to call a method for pdf export:

<button type="button" wire:click="toPdf">Export cart as PDF</button>

As soon as the customer presses that button, Livewire loads the current component and calls the "toPdf" method. At exactly this point I already have all the session data at hand, the customer is logged in, I have the current state of the cart, and can reuse 3rd party fetched data. So how do I get the html of the whole page before the actual render() method is called and a view is returned to be processed further into a final response?

The first obivous attempt is to call the render() method manually. But the way Livewire works is it won't provide the public properties used in a view as well. Those properties are resolved in the beginning of calling the component via invoke, but not used directly in the render() method.

Take a look at the invoke method of the base component class. There you find how Livewire resolves public properties as well as routing data, and uses them to render the final components html, especially if you use the component as a whole page with layout options.

So the solution is to take the current component and simply call it again and let the invoke method return the final view which can be rendered as html and used in Browsershot directly as html input. That way Browsershot and therefore puppeteer and the headless chrome browser only need to render the provided html and return it as pdf. No auto log in calls. No additional 3rd party api requests.

public function toPdf()
{
    $clonedComponent = clone $this;
    $clonedComponent->isPdf = true;
    $html = app()->call($clonedComponent)->with(['view' => 'layouts.pdf'])->render();

    $pdf = Browsershot::html($html)
		->noSandbox()
		->waitUntilNetworkIdle()
		->pdf();

    $headers = [
        'Content-type' => 'application/pdf',
        'Content-Disposition' => 'attachment',
    ];

    $this->skipRender();

    return response()->streamDownload(fn() => print($pdf), 'export.pdf', $headers);
}

Let's go through some important points in that method:

  • Line 3: Cloning the current component makes sure that I can modify properties which should be taken into account while rendering. If I omit this step and simply use the current component via $this, those properties are set not only for the pdf export rendering process but also for the current request in general and returned to the customer as changed payload.

  • Line 5: The magic: app()->call() not only takes a string or closure as first parameter, but also takes a created object without instantiating it again, and simple calls the invoke method while injecting all necessary dependencies. The only caveat is: the "mount" method is called again. So make sure to check what is going on there. Also you can provide a different layout than the layout you provide in the render function or the default layout.

  • Line 17: Since we only need to stream the rendered pdf we don't need to call the render method again, so let's skip that.

Final conclusion

In my opinion Laravel Livewire lives up to its promise again and again, not only for replacing most Vue/React/JS in general tools, but also for such simple yet (former) complex tasks as to exporting the current page as an pdf.