Render Your Email Templates with Output Buffering
You need the order-confirmation email as a string to hand to the mailer. The template is naturally a PHP file - HTML with some echoes. The missing link between "file that prints" and "string I can pass around" is output buffering, and it's four lines:
function renderTemplate(string $path, array $vars = []): string
{
extract($vars, EXTR_SKIP);
ob_start();
include $path;
return ob_get_clean();
}
// templates/order-email.php
<h1>Thanks, <?= htmlspecialchars($name) ?>!</h1>
<p>Order <b><?= htmlspecialchars($number) ?></b> is confirmed.</p>
// usage
$html = renderTemplate(__DIR__ . '/templates/order-email.php', [
'name' => $order->name,
'number' => $order->number,
]);
$mailer->send($order->email, 'Order confirmed', $html);
ob_start() redirects all output into a memory buffer; the include runs normally, printing away; ob_get_clean() hands you everything it printed and discards the buffer. The template file stays plain PHP - full language available, syntax highlighting works, designers can read it.
Why this beats string concatenation and heredocs
Email HTML built with .= across 80 lines is unreadable and unreviewable. A template file diffs cleanly, previews in a browser (hit it directly with test data), and separates "what it looks like" from "what data goes in". This is precisely how Blade, Twig and every other engine work at the bottom - compiled templates are includes captured by output buffering. Knowing the raw mechanism means you can have templating anywhere, dependency-free: legacy apps, tiny tools, WordPress snippets.
Details that keep it clean
- extract with EXTR_SKIP imports your variables into the template's scope but refuses to overwrite existing ones (like $path itself). If extract feels too magical for your taste, pass a single $data array and write
$data['name']in templates - one notch more verbose, zero magic. Never extract user-controlled arrays like $_POST. - Wrap it in try/finally when hardening: if a template throws mid-render, an abandoned buffer will swallow subsequent output.
try { include ... } catch (...) { ob_end_clean(); throw ...; }keeps the buffer stack sane. - Buffers nest. ob_start() inside an active buffer just stacks - so templates can render sub-templates with the same helper, layouts can capture content blocks, all without conflict.
Beyond email
The same helper renders AJAX HTML fragments (return rendered rows to your frontend, not JSON you rebuild client-side), HTML destined for PDF generators, and cached page fragments - render once, file_put_contents, serve statically. Output buffering has other tricks (streaming callbacks, gzip), but capture-an-include is the one you'll use weekly.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.