A user reported that their certificate was printing two pages instead of one. No error. No warning in the logs. Just a blank second page with some content on it that was supposed to be on page one.
That’s the worst kind of bug — silent, visual, and impossible to reproduce until you understand exactly what triggered it.
What Was Happening
The certificate plugin I was working on generates a PDF in one of two modes. If a shared template is applied, it renders the template’s elements at their stored positions. If no template is applied, it falls back to a built-in preset layout with fixed content.
The user had loaded a template, then removed it. From the UI side, the template was gone. But in the database, the private template page still had its element records sitting there — they were never cleaned up when the template was removed.
Here’s what the rendering loop was doing at the time:
foreach ($pages as $page) {
$elements = getElements($page->id);
if ($elements) {
foreach ($elements as $element) {
$element->render($pdf);
}
} else {
renderPresetContent($pdf, $page);
renderTextOptions($pdf, $page);
}
}
The condition inside the loop checks whether elements exist for the current page. If they do, it takes the template branch. If not, it falls back to preset.
The problem: the private template page still had elements in the DB. So getElements() returned results, the loop took the template branch, and one of those stale elements had a posy value of 250mm — on a page that was only 210mm tall.
What TCPDF Does With an Out-of-Bounds Element
TCPDF has auto page break enabled by default. When you try to render anything past the page height, it doesn’t throw an error — it silently creates a new page and renders the content there instead.
// TCPDF creates a new page automatically when Y exceeds page height $pdf->SetAutoPageBreak(true, 0); // An element at Y=250 on a 210mm page: $pdf->SetXY(10, 250); // 40mm past the bottom edge $pdf->Cell(...); // TCPDF adds a new page right here, no warning
So the stale element, sitting 40mm below the page boundary, triggered an automatic page break. The element rendered at the top of the new page. The user got a two-page certificate with a stray piece of content on page two — and had no idea why.
Why the Loop Was Wrong
The real bug is not the stale DB records — those are a symptom. The real bug is that the rendering mode was being decided inside the loop, per page, using data that could be inconsistent.
The condition if ($elements) is checking whether elements exist for that specific page. But that’s not the same as checking whether the user has actually applied a shared template. A page can have elements for reasons that have nothing to do with the current rendering mode — stale records, leftover migrations, manual DB edits.
I realised the loop shouldn’t be the one figuring this out. It was checking $elements per page and using that to decide the mode — but that’s not the same as checking whether the user actually has a template applied. The data inside the loop was unreliable for that decision.
The Fix
Set a flag before the loop. Everything inside the loop reads that flag — it never re-derives the mode from per-row data.
$useElements = false;
if (!empty($customcert->appliedtemplateid)) {
$templatePages = getPagesByTemplate($customcert->appliedtemplateid);
if ($templatePages) {
$pages = $templatePages;
$useElements = true;
}
}
foreach ($pages as $page) {
addPageToPdf($pdf, $page);
renderDesignOptions($pdf, $page, $customcert);
if ($useElements) {
foreach (getElements($page->id) as $element) {
$element->render($pdf);
}
} else {
renderPresetContent($pdf, $page, $customcert);
renderTextOptions($pdf, $page, $customcert);
}
}
$useElements is set based on whether a shared template is actually applied — not based on what’s in the database for a given page. When no template is applied, $useElements is false and the `else` branch runs for every page, every time. The stale private template elements are never queried. The extra page never appears.
Protecting Against TCPDF Overflow Anyway
Even with the loop fixed I added a safeguard anyway — because stale records can creep in again. The simpler option is to just disable auto page break while rendering elements. An out-of-bounds element gets clipped instead of triggering a new page:
$pdf->SetAutoPageBreak(false);
foreach ($elements as $element) {
$element->render($pdf);
}
$pdf->SetAutoPageBreak(true, 0);
If you want more visibility into what’s being skipped, validate positions before rendering instead:
function isWithinPage(\stdClass $element, \stdClass $page): bool {
return $element->posy < $page->height && $element->posx < $page->width;
}
foreach ($elements as $element) {
if (isWithinPage($element, $page)) {
$element->render($pdf);
}
}
That way you can log the skipped elements and trace where the bad values came from.
The Pattern, Beyond PDF Generation
This isn’t a TCPDF-specific problem. Any time you have two fundamentally different rendering modes sharing a loop, the same mistake is possible:
$mode = determineMode($config); // once, outside
foreach ($items as $item) {
if ($mode === 'A') {
renderModeA($item);
} else {
renderModeB($item);
}
}
The same thing happens in report generators that switch between summary and detail views based on per-row data, or in export code that decides between CSV and Excel format inside the row loop. The loop is the wrong place for that call every time.
What I Check Now
If I’m writing a loop with more than one rendering path, I make sure the decision about which path to use is settled before the loop starts — not derived from whatever the loop happens to find in the database row by row.
That one change fixed it. And the loop got easier to read too — no more guessing which branch runs and why.
