Make the PDF, don’t keep it
Streaming generated PDFs straight to the browser instead of writing them to disk, using a certificate template as the example
For years, the learning platform I work on printed every course certificate to a PDF file the first time a learner opened it, saved that file, and handed out the saved copy from then on. By 2026 there were more than 400,000 of them. Worse, whenever a client corrected a credit amount or touched up a certificate design, learners kept getting the old version, because the old file was already sitting there.
The fix was to stop saving them. Each certificate is now built fresh when someone asks for it and sent straight to their browser, and nothing is written to disk. This guide is about the difference between those two approaches, how to stream a PDF properly from PHP, and how the certificate template itself works. A saved file, it turns out, is just a cache with no expiry date.
Two ways to hand someone a PDF
Write it, then serve it. Generate the PDF, save it somewhere on the server, and send the browser that file, either directly or through a link to it. Next time, if the file exists, skip straight to sending it.
Stream it. Generate the PDF in memory and send the bytes as the response to the request that asked for it, with headers that tell the browser it’s a PDF. When the response ends, the PDF is gone from the server.
Saving looks like the efficient choice, because you only do the work once. But that file is now a second copy of data you already have, and it starts going out of date the moment anything it was built from changes:
- It goes stale. The file knows nothing about the record it came from. Fix a typo in a name, change a credit value or update the design, and every saved copy stays wrong until something deletes it. Every kind of change needs its own rule for which files to throw away, and someone always finds a change nobody wrote a rule for.
- It piles up. One file per learner per course, forever. At about 7.5MB a certificate, that pile reached 306GB.
- It needs guarding. A saved file is something you have to keep out of the web root, protect with access checks or signed links, and back up. A stream is answered by the same request that already checked who’s asking.
- It leaves leftovers. The old renderer wrote an HTML version to a temp folder first and never cleaned it up. One client’s server had 13GB of those.
Streaming does cost a render on every request. Nobody noticed. What people did notice was that a correction made on Monday showed up on Monday.
A word on the examples. The code below is simplified, plain PHP. The real thing ran on the platform’s own stack: PHP 5.6 on CentOS 7 with the Phalcon framework, and Dompdf 0.8.0 as the PDF library, which is why the examples stick to syntax PHP 5.6 understands. Newer Dompdf versions fix some of the quirks in the last section, so check yours.
The template: a picture with words on top
A certificate design is mostly a background image: the border, the logo, the signature, the fancy lettering. Only a handful of things change from learner to learner, like the name, the course title, the date and the credits. So the template is one full-size image with those values laid over it, each one placed where the designer put it.
In HTML that’s a container the size of the page, the image filling it, and one absolutely positioned <div> per value. The important part is how those positions are written: as percentages of the certificate, not pixels. The name sits 39.2% of the way down and 11.8% in from the left, whatever size the page turns out to be:
<div id="cert-container">
<img src="data:image/png;base64,iVBORw0KGgo..." alt="Certificate of completion">
<div class="field" style="top: 39.2%; left: 11.8%; width: 76.4%; font-size: 3.93em; text-align: center; font-weight: bold;">Jordan Example</div>
<div class="field" style="top: 51%; left: 11.8%; width: 76.4%; font-size: 2.36em; text-align: center;">Introduction to Widget Safety</div>
<div class="field" style="top: 73.2%; left: 17.7%; width: 24.6%; font-size: 1.77em; text-align: left;">April 21, 2026</div>
<div class="field" style="top: 73.2%; left: 59%; width: 24.6%; font-size: 1.77em; text-align: right;">2.5 credits</div>
</div>#cert-container {
position: relative;
width: 1056px; /* US Letter, landscape, at 96 px to the inch */
height: 816px;
font-size: 10.56px; /* 1% of the width, so 1em = 1% */
overflow: hidden;
font-family: "Helvetica", sans-serif;
}
#cert-container img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.field {
position: absolute;
line-height: 1.5;
}Positions and widths are plain CSS percentages of the container. Font sizes need one small trick, because CSS has no “percent of the page width” for text that older PDF libraries understand. So the container’s own font size is set to exactly 1% of its width, and every field’s size is written in em. A field at 3.93em is 3.93% of the page width, on any page.
The container is the only thing in the whole template that knows the real size, and the renderer sets it. Everything inside is proportion. That’s what makes the design portable: the same template can be drawn for US Letter, for A4, or at a small size for an on-screen preview, just by changing the container. The editor where a designer drags the fields around can show the certificate at whatever size fits their screen too, and still save exactly the same numbers.
Absolute positioning is the right tool here, for once. A certificate is a fixed composition, not a page that has to reflow, so each value goes where the designer chose and nowhere else. The width on each field gives text-align something to center within, so a long course title stays centered under the heading instead of starting at a fixed spot.
The background is embedded as a data: URI rather than linked. That way the PDF library never has to fetch anything over the network while it renders, and you don’t have to turn on remote loading, which is safer and faster.
Filling it in from data
In the platform, a certificate designer drags fields around on top of the image and saves where each one landed. So each field is stored as data, not markup, and every number in it is a percentage of the certificate’s width or height:
[
{ "source": "learner.name", "top": 39.2, "left": 11.8, "width": 76.4, "fontSize": 3.93, "align": "center", "bold": true },
{ "source": "course.title", "top": 51.0, "left": 11.8, "width": 76.4, "fontSize": 2.36, "align": "center" },
{ "source": "completed.date", "top": 73.2, "left": 17.7, "width": 24.6, "fontSize": 1.77, "align": "left",
"format": "F j, Y" },
{ "source": "credits", "top": 73.2, "left": 59.0, "width": 24.6, "fontSize": 1.77, "align": "right" }
]The renderer picks the paper. Dompdf measures pages in points, 72 to an inch, while CSS lays things out in pixels at 96 to an inch, so one small helper gives you both:
function paper_size($name)
{
// landscape, in inches
$inches = ['letter' => [11, 8.5], 'a4' => [11.69, 8.27]];
list($w, $h) = $inches[$name];
return [
'px' => [(int) round($w * 96), (int) round($h * 96)], // for the HTML: letter 1056 x 816
'pt' => [(int) round($w * 72), (int) round($h * 72)], // for the PDF: letter 792 x 612
];
}Building the HTML is then a loop. Every value that came from a database gets escaped on its way in, because a learner’s name is user input like any other:
// Always a "." for the decimal point, whatever the server's locale.
function css_number($n)
{
return number_format($n, 2, '.', '');
}
function certificate_field_html($field, $value)
{
$css = [
'top' => css_number($field['top']) . '%',
'left' => css_number($field['left']) . '%',
'width' => css_number($field['width']) . '%',
'font-size' => css_number($field['fontSize']) . 'em',
'text-align' => in_array($field['align'], ['left', 'center', 'right'], true) ? $field['align'] : 'left',
];
if (!empty($field['bold'])) {
$css['font-weight'] = 'bold';
}
$style = '';
foreach ($css as $property => $val) {
$style .= $property . ':' . $val . ';';
}
return '<div class="field" style="' . $style . '">' . htmlspecialchars($value, ENT_QUOTES, 'UTF-8') . '</div>';
}
function certificate_html($design, array $values, $paper)
{
list($width, $height) = $paper['px'];
// the one place the real size appears; 1em = 1% of the width
$page = '#cert-container{width:' . $width . 'px;height:' . $height . 'px;font-size:' . css_number($width / 100) . 'px}';
$fields = '';
foreach ($design['fields'] as $field) {
$fields .= certificate_field_html($field, isset($values[$field['source']]) ? $values[$field['source']] : '');
}
$image = base64_encode(file_get_contents($design['image']));
return '<!DOCTYPE html><html><head><meta charset="utf-8"><style>' . $design['css'] . $page . '</style></head><body>'
. '<div id="cert-container"><img src="data:image/png;base64,' . $image . '" alt="">' . $fields . '</div>'
. '</body></html>';
}The little css_number() helper matters more than it looks. Several of PHP’s ways of turning a number into text follow the server’s locale, and on a server set to a language that writes decimals with a comma, 39.2% would come out as 39,2%, which CSS doesn’t understand. number_format() with an explicit '.' always writes a dot.
One honest limit: percentages keep everything in proportion, but Letter and A4 aren’t quite the same shape (A4 is a little longer for its height). Drawn on A4, the same background image gets stretched to fit. The fields still land on the right parts of the design, but if the stretch shows, give each paper size its own background image and keep the one set of field positions. And as with any CSS in a PDF, check percentage positioning in your own PDF library and version before you rely on it.
The values come from the records that already exist: the learner, the enrollment, the completion date, the credits. That’s the whole reason streaming works. Everything a certificate shows is already stored somewhere, so a certificate for a course finished in 2019 can be built today, accurately, as many times as anyone wants. One detail worth copying: the platform took the course title from the learner’s transcript, the title as it was when they finished, so renaming a course later doesn’t rewrite certificates people already earned.
Before: render to a file, then serve the file
The old version, boiled down. It wrote the HTML to a temp file, ran the wkhtmltopdf command-line tool on it to produce a PDF in the learner’s folder, and from then on served that file whenever it existed:
$pdfPath = $tenantHome . '/private/users/' . $userId . '/certificates/course-' . $courseId . '.pdf';
if (!file_exists($pdfPath)) {
$htmlPath = $tenantHome . '/tmp/' . $userId . '-cert.tmp.html';
file_put_contents($htmlPath, certificate_html($design, $values, paper_size('letter')));
exec('wkhtmltopdf --orientation Landscape ' . escapeshellarg($htmlPath) . ' ' . escapeshellarg($pdfPath), $out, $exitCode);
// ...and the temp HTML stayed behind
}
header('Content-Type: application/pdf');
readfile($pdfPath);That file_exists() check is the whole problem in one line. It answers “have we made this before?” when the question that matters is “is what we made still right?”
After: render in memory and stream it
With Dompdf, a PHP library, there’s no external program and no file. It turns HTML into PDF bytes in memory, and those bytes go straight out as the response:
use Dompdf\Dompdf;
$paper = paper_size('letter'); // or 'a4': same template, same fields
$dompdf = new Dompdf();
$dompdf->loadHtml(certificate_html($design, $values, $paper));
$dompdf->setPaper([0, 0, $paper['pt'][0], $paper['pt'][1]]);
$dompdf->render();
$pdf = $dompdf->output();
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="course-' . (int) $courseId . '-certificate.pdf"');
header('Content-Length: ' . strlen($pdf));
header('Cache-Control: private, no-store');
echo $pdf;Each header has a job:
Content-Type: application/pdftells the browser what the bytes are, so it opens its PDF viewer instead of showing gibberish.Content-Disposition: inlineasks it to show the PDF in the tab, and thefilenameis what it gets saved as. Useattachmentinstead ofinlineif you’d rather it download straight away.Content-Lengthlets the browser show real progress and know when it has everything.Cache-Control: private, no-storekeeps shared caches and proxies from holding on to someone’s personal document, and stops the browser from serving yesterday’s version.
Two things to get right around it. Nothing can be printed before the headers, not even a stray space before <?php, or PHP can’t send them. And do the access check first: this request is the only thing between a certificate and whoever asked for it, which is a much shorter list of things to secure than a folder full of files. Dompdf also has a stream() method that sends the headers for you. On the platform I returned output() through the framework’s own response object instead, which is the same idea with the framework in charge of the headers.
Two Dompdf gotchas, and one phantom page
Moving from wkhtmltopdf (which uses a real browser engine) to Dompdf (which doesn’t) surfaced two differences. Together they put a blank second page on every certificate.
No CSS transforms. The certificate editor used transform: scale() to fit the design on screen, and Dompdf 0.8 ignores transforms entirely, so the full-size design overflowed the paper. The fix is to stop scaling: lay the page out at the paper’s own size and make the PDF page exactly that size. With the percentage layout above, that’s already how it works. The container gets the paper’s size in pixels, and Dompdf gets the same paper in points, both from paper_size():
$paper = paper_size('letter');
// HTML: #cert-container is 1056 x 816 px PDF: the page is 792 x 612 pt
// both are 11 x 8.5 inches, so nothing overflows onto a second page
$dompdf->setPaper([0, 0, $paper['pt'][0], $paper['pt'][1]]);Whitespace becomes text. Dompdf 0.8 creates a line of text for the whitespace between block-level tags, like the newline and indentation between <body> and the first <div>. Each one is an invisible line about 12 points tall, and on a page sized exactly to the design, a couple of them are enough to push the bottom edge onto a second page. Collapsing whitespace between tags before rendering removes them:
$html = preg_replace('/>\s+</', '><', $html);That’s safe for a certificate, where every value sits in its own positioned box. In ordinary prose it would glue words together wherever a tag ends one word and starts the next, so don’t apply it to every document blindly.
What about the 400,000 files already there?
Once nothing reads the old PDFs, they’re just weight. A bulk delete across every client’s server is the kind of command that ruins a week if it matches one directory too many. So the platform cleans up as it goes: whenever a learner opens any certificate, the old PDFs in that learner’s folder are deleted. The pile shrinks as people use the system, one learner at a time, and nothing anyone still needs is in it.
With nothing saved, the workarounds for keeping saved files honest could go too. The admin button for forcing a reprint, and the logic that deleted certificates when a learner earned new credits, both came out. There was nothing left for them to fix.
When saving is still the right call
Streaming won here because a certificate is cheap to rebuild and everything in it lives in the database. It’s not always that way. If a document takes a long time to generate, is requested constantly, or has to be an exact, unchangeable record of what was issued on a particular day (an invoice, a signed contract), then storing it can be correct. Just store it on purpose: decide what makes it out of date and write that rule down, or decide that it never goes out of date, and say so.
Need a hand?
If your application is drowning in generated files, or its PDFs keep coming out wrong, I do this kind of work. Tell me what you’re working with.