Server-Side Rendering with PHP Templates: Layouts, Partials, and Progressive Enhancement

Build server-rendered pages with plain PHP templates, structure layouts with content sections, reuse markup via partials/includes, wrap small reusable UI fragments as PHP 'components' (functions or classes that render a piece of markup), apply progressive enhancement so server-rendered HTML works before any JavaScript runs, and understand how PHP and a JS/TS frontend can coexist through server-rendered shells with client-side islands.

Long before client-side frameworks, PHP's original design already solved the core problem of server-side rendering (SSR): mixing PHP logic with HTML markup to produce a complete, ready-to-display page on every request, which the browser can render immediately with no JavaScript execution required at all. Modern PHP applications still lean heavily on this model -- not as a legacy holdover, but because a server-rendered HTML response is fast to first paint, works with JavaScript disabled or slow to load, and is trivially indexable by search engines. The craft is in organizing plain PHP templates well: separating a reusable layout from per-page content, factoring repeated markup into partials, building small composable 'components', and then layering client-side JavaScript/TypeScript on top through progressive enhancement rather than making the page depend on it to function at all.

Plain PHP templates: mixing logic and markup deliberately

A PHP template is just a .php file where PHP's short-tag control structures (<?php if (...): ?> ... <?php endif; ?>, <?php foreach (...): ?> ... <?php endforeach; ?>) are interleaved directly with HTML, and any dynamic value is emitted with <?= $value ?> (shorthand for <?php echo $value; ?>). This alternate syntax exists specifically for templates -- it reads far more like HTML-with-holes than a PHP script with embedded strings, which is exactly the point: a template's job is producing markup, and the alternate control-structure syntax keeps that markup visually dominant instead of buried inside echo '<li>' . $item . '</li>'; calls.

Critically, every dynamic value interpolated into HTML output must be escaped with htmlspecialchars() (commonly aliased as a short helper like e() or h()) to prevent cross-site scripting (XSS) -- <?= htmlspecialchars($comment->body, ENT_QUOTES, 'UTF-8') ?> rather than <?= $comment->body ?> unescaped. This is a habit that must be applied consistently on every single interpolation, since a single forgotten escape on user-controlled data is a real XSS vulnerability; some teams enforce this by routing ALL dynamic output through a single escaping helper function by convention, so it becomes a visible, greppable pattern across every template rather than something to remember ad hoc.

Layouts, content sections, and partials

A layout is the shared page shell -- the <html>, <head>, navigation, and footer that every page has in common -- with a designated slot where each page's unique content is injected. A simple, dependency-free way to build this in plain PHP is output buffering: a page template calls ob_start(), includes/echoes its own unique content, captures the buffered output with ob_get_clean(), and then includes the shared layout file, which echoes that captured content variable at the appropriate point inside the shared <html> structure. This gives every page a consistent shell without duplicating the <head>/navigation/footer markup in every single page file.

A partial is a smaller, reusable fragment of markup factored out purely to avoid repetition -- a product card, a pagination control, a flash-message banner -- included with PHP's include or require (or include_once/require_once when the fragment must only ever run once per request, such as a script tag that should not be duplicated). Partials typically take their data through ordinary PHP variables that are in scope at the point of the include, or, more explicitly and safely, by extracting an array of named values into local scope just before including the fragment, which keeps each partial's expected inputs documented and avoids accidentally leaking unrelated variables from the including template's scope.

The distinction between a 'layout' and a 'partial' is really just intent, not a language feature -- a layout wraps around a page's content (outside-in), while a partial is dropped into a specific spot within a page's content (inside-out) -- both are implemented with the exact same include/require mechanism.

• Layout: the shared page shell (head, nav, footer) with a slot for per-page content -- wraps AROUND a page • Partial: a small reusable fragment (a card, a pagination widget) -- dropped INTO a specific spot in a page • ob_start() / ob_get_clean(): capture a page's unique content as a string, to hand off to the layout • include/require vs include_once/require_once: use the _once variants for fragments (like a script tag) that must not be duplicated even if included from multiple places in one request

Reusable components as functions or classes

Beyond simple file-based partials, a small reusable UI fragment with real logic behind it -- formatting, conditional markup, validation of what it's given -- is better expressed as a PHP function or a small class than as a bare include file, precisely because a function has an explicit signature (parameters, return type) instead of an implicit list of 'whatever variables happen to be in scope'. A simple function-based component takes its data as typed parameters and returns a string of rendered HTML (often building that string with its own small internal template file via output buffering), which callers then echo directly: <?= renderBadge($status) ?>.

For a component with more internal state or several related rendering methods, a small class works well too -- a Pagination class, say, constructed with the current page, total pages, and a base URL, exposing a render(): string method. The important shared property, whichever form is used, is that the component's inputs are explicit and typed, its output is a self-contained string of HTML (or it echoes directly, but consistently one or the other, not a mix), and it has no hidden dependency on variables that happen to be set somewhere else in the calling template -- exactly the same discipline that makes any well-designed function reusable, just applied to markup instead of computation.

Progressive enhancement: server HTML first, JS/TS layered on top

Progressive enhancement is the practice of making the server-rendered HTML fully functional on its own -- a form that really submits via a normal HTTP POST, a link that really navigates -- and then using client-side JavaScript/TypeScript purely to improve that already-working experience: intercepting the form submission to send it via fetch() instead and update the page without a full reload, adding a loading spinner, or enabling drag-and-drop reordering. If the JavaScript fails to load, is blocked, or hasn't finished loading yet, the page still works, just without the enhancement -- degrading gracefully rather than breaking outright, which is the opposite of a client-side-only approach where a JS error or slow network leaves the user looking at a blank page or a non-functional button.

Concretely, this usually means: render real <form method="post" action="..."> elements and real <a href="..."> links from PHP so they work with zero JavaScript, then, in a separate <script type="module"> (increasingly written in TypeScript and compiled/bundled), attach event listeners that call event.preventDefault() and take over the interaction only once the script has actually run and found the element -- never make the only way to submit a form or follow a link depend on JavaScript having executed successfully first.

• Server-rendered baseline: real <form action> POSTs, real <a href> links -- work with JS fully disabled • JS/TS layer: intercepts those same elements (preventDefault + fetch) to improve the experience, never replaces the baseline • Graceful degradation: a JS error, ad blocker, or slow network leaves a working (if less fancy) page, not a broken one • Contrast with client-rendered-only apps: a JS failure there typically means a blank page or a completely non-functional UI

PHP plus a JS/TS frontend: server shell, client islands

It's common in modern PHP applications for most of the page to be plain server-rendered PHP -- fast, simple, no build step needed for that content -- while a specific, genuinely interactive widget (a live search box, a rich date picker, a drag-and-drop kanban board) is implemented as a small, separately-bundled JavaScript/TypeScript component that 'hydrates' into a placeholder element the PHP template rendered. This 'islands' pattern keeps the vast majority of the page simple, fast, server-rendered HTML, while concentrating the complexity (and the JS bundle size) only where genuine client-side interactivity is actually needed, rather than shipping and hydrating a full client-side framework for an entire page that's mostly static content.

The PHP template's job in this pattern is to render the island's initial server-known state directly into the placeholder's markup or a data attribute (<div id="price-chart" data-symbol="AAPL" data-initial='<?= htmlspecialchars(json_encode($initialPrices), ENT_QUOTES) ?>'></div>), so the page is meaningful even before the JS bundle loads, and then a TypeScript module selects that element by id, reads its data attributes, and takes over rendering just that region -- the PHP-rendered page and the JS-driven island coexist, each responsible for exactly the part it's good at, rather than the whole page being owned by one or the other.

Layout + partial + progressive-enhancement patterns

$content in layout.php is echoed WITHOUT htmlspecialchars() because it is already a fully-built HTML string from ob_get_clean(), not raw user input -- escaping it again would double-encode entities. Every individual dynamic value inside a page/partial (like $user->name or $post->title) must still be escaped at the point it's interpolated, even though the assembled $content string as a whole is not re-escaped.

<?php // layout.php -- the shared shell, expects $pageTitle and $content ?>
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title><?= htmlspecialchars($pageTitle) ?></title>
</head>
<body>
  <?php include __DIR__ . '/partials/nav.php'; ?>
  <main><?= $content /* already-escaped HTML string, NOT raw user input */ ?></main>
  <?php include __DIR__ . '/partials/footer.php'; ?>
</body>
</html>

<?php // page.php -- a specific page, built ON TOP of the layout ?>
<?php
ob_start();
?>
  <h1><?= htmlspecialchars($user->name) ?></h1>
  <?php foreach ($posts as $post): ?>
    <?php include __DIR__ . '/partials/post-card.php'; // uses $post from scope ?>
  <?php endforeach; ?>
<?php
$content = ob_get_clean();
$pageTitle = 'User Profile';
include __DIR__ . '/layout.php';

// A small function-based component
function renderBadge(string $status): string {
    $class = match ($status) {
        'active' => 'badge badge-green',
        'pending' => 'badge badge-yellow',
        default => 'badge badge-gray',
    };
    return '<span class="' . $class . '">' . htmlspecialchars($status) . '</span>';
}

Code Examples

post-card.php is a partial that expects a $post variable to already be in scope -- the foreach loop in index.php sets $post on each iteration before including the partial, so the same markup fragment renders once per post without being duplicated in index.php itself. Every value is escaped at the point of interpolation, since post titles/excerpts could contain user-supplied content.

include __DIR__ . '/partials/post-card.php';

Pagination is a self-contained component: its inputs (currentPage, totalPages, baseUrl) are explicit constructor parameters rather than ambient variables, and render() returns one complete HTML string with no hidden dependency on the calling template's scope -- this is exactly what makes it safe to reuse across many different pages/templates without any risk of variable-name collisions or missing-context bugs. Expected result: <nav class="pagination"><a href="/posts?page=1">1</a> <span class="page-current">2</span> <a href="/posts?page=3">3</a> <a href="/posts?page=4">4</a> <a href="/posts?page=5">5</a></nav> public function render(): string

The <form> has a real method/action, so submitting it works perfectly with JavaScript disabled -- it performs a normal GET navigation to /search with the query string, and the PHP template renders that page's results server-side either way. The TypeScript module only attaches its preventDefault()-based enhancement if it manages to find both elements and actually runs, upgrading the same form to an AJAX-style search without a full reload -- if the script fails to load, the form still works exactly as before, just with a full page reload per search.

Common mistake: Do not make the <form> lack a real action/method, or rely on onclick="return false" baked into the server-rendered HTML -- that would make the page's core functionality depend on JavaScript having already run, defeating progressive enhancement entirely.

<?php // partials/post-card.php -- expects $post in scope ?>
<article class="post-card">
  <h2><?= htmlspecialchars($post['title']) ?></h2>
  <p><?= htmlspecialchars($post['excerpt']) ?></p>
  <a href="/posts/<?= urlencode($post['slug']) ?>">Read more</a>
</article>

<?php
// index.php
$posts = [
    ['title' => 'Hello World', 'excerpt' => 'My first post.', 'slug' => 'hello-world'],
    ['title' => 'PHP & JS', 'excerpt' => 'Building progressively.', 'slug' => 'php-and-js'],
];
foreach ($posts as $post) {
    include __DIR__ . '/partials/post-card.php';
}
<?php
// components/Pagination.php
final class Pagination
{
    public function __construct(
        private readonly int $currentPage,
        private readonly int $totalPages,
        private readonly string $baseUrl,
    ) {}

    public function render(): string
    {
        $links = [];
        for ($page = 1; $page <= $this->totalPages; $page++) {
            $isCurrent = $page === $this->currentPage;
            $href = htmlspecialchars($this->baseUrl . '?page=' . $page);
            $links[] = $isCurrent
                ? '<span class="page-current">' . $page . '</span>'
                : '<a href="' . $href . '">' . $page . '</a>';
        }
        return '<nav class="pagination">' . implode(' ', $links) . '</nav>';
    }
}

$pagination = new Pagination(currentPage: 2, totalPages: 5, baseUrl: '/posts');
echo $pagination->render();
// <nav class="pagination"><a href="/posts?page=1">1</a> <span class="page-current">2</span> <a href="/posts?page=3">3</a> ...</nav>
<?php
// PHP template: renders a real, working form PLUS a JS-hydration placeholder
$initialResults = searchProducts($query ?? '');
?>
<form id="search-form" method="get" action="/search">
  <input type="text" name="q" value="<?= htmlspecialchars($query ?? '') ?>">
  <button type="submit">Search</button>
</form>

<!-- Server-rendered baseline results -- meaningful with ZERO JavaScript -->
<ul id="search-results" data-initial='<?= htmlspecialchars(json_encode($initialResults), ENT_QUOTES) ?>'>
  <?php foreach ($initialResults as $product): ?>
    <li><?= htmlspecialchars($product['name']) ?> -- $<?= number_format($product['price'], 2) ?></li>
  <?php endforeach; ?>
</ul>

<script type="module" src="/assets/search.js"></script>

<?php /* search.ts, compiled to /assets/search.js -- progressive enhancement layer:
const form = document.getElementById('search-form') as HTMLFormElement | null;
const results = document.getElementById('search-results');

if (form && results) {
  form.addEventListener('submit', async (event: SubmitEvent) => {
    event.preventDefault(); // takes over ONLY once this listener actually ran
    const query = new FormData(form).get('q');
    const response = awai
…

Layout wraps pages; pages include partials

A page builds its own content into a string via output buffering, then hands that string to the shared layout -- partials are simply includes used at either level.

layout.php
  +---------------------------------------------+
  |  <html><head>...</head><body>                |
  |    include partials/nav.php                   |
  |    <main> $content </main>  <---------------+ |
  |    include partials/footer.php               | |
  |  </body></html>                               | |
  +---------------------------------------------+ |
                                                    |
  page.php                                          |
  +----------------------------+                    |
  |  ob_start();                |                    |
  |    ...page markup...        |                    |
  |    include partials/card.php x N                  |
  |  $content = ob_get_clean(); ---------------------+
  |  include layout.php;
  +----------------------------+

Important Rules

Escape every dynamic value at the point it's interpolated into HTML with htmlspecialchars() (or an equivalent helper) -- never echo raw user-controlled data directly into a template.

Keep a layout's job limited to the shared page shell, and a partial's job limited to one reusable fragment -- both use the same include/require mechanism, so the distinction is organizational discipline, not a language feature.

Give function- or class-based components explicit, typed inputs and one clear output form (a returned string, consistently) rather than depending on whatever variables happen to be in scope.

Always render a real, fully-functional <form>/<a> baseline from PHP first -- JavaScript should intercept and improve that baseline via preventDefault(), never be the only way the interaction works at all.

In a PHP-plus-JS/TS app, let PHP render the server-known initial state directly into the page (including into data attributes for an island to read), so the page is meaningful before any client bundle has loaded or executed.

Where this shows up

This layout/partial/component structure is exactly what templating layers like Twig (Symfony) and Blade (Laravel) formalize with dedicated syntax (extends/block/include, or @extends/@section/@include) -- but the underlying model is identical to plain PHP includes and output buffering, which is why understanding the plain-PHP version makes those templating engines' features immediately recognizable rather than mysterious. Progressive enhancement and the server-shell-plus-JS-island pattern show up constantly in real PHP+JS stacks -- a mostly server-rendered e-commerce site with a JS-driven cart widget, or a content site using htmx- or Turbo-style libraries that intercept ordinary links/forms to make them feel like a single-page app while every one of those links and forms remains a fully working, server-rendered fallback underneath.

<!-- Button has no real functionality without JS -- nothing happens if the script fails to load --> <button id="load-more">Load more</button> <script type="module"> document.getElementById('load-more').addEventListener('click', async () => { const res = await fetch('/api/posts?page=2'); // ... }); </script>

<!-- Real link works with zero JS; script intercepts it to enhance --> <a id="load-more" href="/posts?page=2">Load more</a> <script type="module"> const link = document.getElementById('load-more'); link?.addEventListener('click', async (event) => { event.preventDefault(); // only takes over once this code has actually run const res = await fetch('/api/posts?page=2'); // ...append results, then update link.href for the NEXT page... }); </script>

A bare <button> with no href and no form has no default browser behavior at all -- if the JavaScript fails to load, is blocked, or errors out before attaching the listener, the button does nothing, ever. Using a real <a href> as the baseline means the exact same feature works as a normal page navigation with zero JavaScript, and the script only upgrades that already-working link when it actually manages to run.

[multiple-choice] In a typical PHP layout.php / page.php split using output buffering, why is $content echoed into the layout WITHOUT htmlspecialchars(), while individual values like $post['title'] inside partials ARE escaped? A. $content never contains any dynamic data, so escaping would be pointless B. $content is already a fully-assembled HTML string captured via ob_get_clean() -- escaping it again would double-encode entities that were already correctly escaped when each individual value was interpolated C. htmlspecialchars() only works on arrays, not strings, so it cannot be applied to $content D. Layouts are exempt from XSS risk because they never include partials

B. $content is already a fully-assembled HTML string captured via ob_get_clean() -- escaping it again would double-encode entities that were already correctly escaped when each individual value was interpolated — By the time $content reaches the layout, every individual dynamic value that went into it (post titles, user names, etc.) should already have been escaped at its own point of interpolation inside the page/partial templates. Re-running htmlspecialchars() on the whole assembled string would incorrectly re-encode those already-escaped entities (turning & into &amp;, for example), corrupting the output.

[multiple-choice] What makes an interactive feature an example of 'progressive enhancement' rather than a JavaScript-required feature? A. It uses TypeScript instead of plain JavaScript B. The server-rendered HTML already provides a real, working version of the feature (a genuine form submission or link navigation), and JavaScript only improves that already-functional baseline rather than being required for it to work at all C. It never uses fetch() or any network requests from the client D. It is implemented entirely without any PHP involvement

B. The server-rendered HTML already provides a real, working version of the feature (a genuine form submission or link navigation), and JavaScript only improves that already-functional baseline rather than being required for it to work at all — Progressive enhancement specifically means the base functionality works with JavaScript absent, disabled, or not-yet-loaded -- a real <form action> or <a href> that performs a full page navigation -- and JavaScript's role is layered on top to intercept and improve that interaction (e.g. via preventDefault() and fetch()), never to be the sole mechanism the feature depends on.

[multiple-choice] In the 'server shell, client islands' pattern, what is the PHP template's responsibility regarding a JS/TS-driven interactive widget? A. PHP should render nothing at all where the widget will go, leaving an empty element for JavaScript to fill in entirely from scratch B. PHP should render the widget's known initial state directly into the placeholder (markup or data attributes), so the page is meaningful even before the JS bundle has loaded, and the client script then hydrates/takes over that region C. PHP and JavaScript should never appear on the same page together D. The JS bundle must be inlined directly into the PHP template's HTML output to work at all

B. PHP should render the widget's known initial state directly into the placeholder (markup or data attributes), so the page is meaningful even before the JS bundle has loaded, and the client script then hydrates/takes over that region — The islands pattern keeps PHP responsible for rendering real, meaningful initial content (often via data attributes carrying server-known state) into the widget's placeholder, so the page isn't blank or broken before the JS loads -- the client-side script then reads that initial state and takes over rendering just that specific region, rather than the whole page depending on JavaScript to produce anything at all.

Write a PHP partial file 'partials/alert.php' that expects $type ('success'|'error') and $message variables in scope, and renders <div class="alert alert-{type}">{escaped message}</div>. Then write a function-based component renderAlert(string $type, string $message): string that returns the equivalent markup as a string using output buffering internally (ob_start/ob_get_clean) instead of relying on included-file scope, so it can be called as echo renderAlert('success', 'Saved!'); with explicit, typed parameters.

// partials/alert.php <div class="alert alert-<?= htmlspecialchars($type) ?>"><?= htmlspecialchars($message) ?></div> // function version function renderAlert(string $type, string $message): string { ob_start(); ?> <div class="alert alert-<?= htmlspecialchars($type) ?>"><?= htmlspecialchars($message) ?></div> <?php return ob_get_clean(); } echo renderAlert('success', 'Saved!');

The partial reads $type/$message directly from whatever scope included it; the function version should accept them as real parameters, then internally call ob_start(), echo the same markup using the parameters, and return ob_get_clean() -- both must htmlspecialchars() the message.