stability: Experimental
Retained-mode layout for CSS Grid (grid-template-areas)
At a glance
Section titled “At a glance”Opt-in preview. Retained mode is default-off. The default
Streamingmode is byte-identical to a build that never knew this mode existed. Turn it on only for the documents that need a real grid, and validate the result.
By default, the renderer is single-pass and streaming (see ADR-001).
A CSS Grid declared with grid-template-areas cannot be placed in one forward
pass, so the streaming engine emits an HTML_GRID_REQUIRES_RETAINED warning and
falls back to block flow. Retained mode is the opt-in that replaces that
fallback with a real layout: Config::withCssLayoutMode(CssLayoutMode::Retained)
routes a definite-column grid-template-areas grid through the
GridLayoutEngine, which places children into their named cells.
Install
Section titled “Install”composer require nextpdf/core:^3The layout mode ships in the core package. The Config::withCssLayoutMode
opt-in is @since 6.0.0. The default remains CssLayoutMode::Streaming.
Conceptual overview
Section titled “Conceptual overview”CssLayoutMode is a typed enum on Config. Streaming is the default and the
historical behavior; Retained opts a document into the grid engine. Retained
mode holds a bounded retained node set (the retainedNodeBudget, default
50,000, clamped to [5,000, 100,000]) so the engine can resolve a grid that
streaming cannot — without abandoning the engine’s memory discipline.
When retained mode is on and the engine meets a grid-template-areas grid whose
columns are definite, it lays the grid out for real. Definite columns are fixed
lengths, percentages, or fr units resolved against the content width. Rows
flow automatically. Children are assigned to the cells their area names select.
ADR-001 records the streaming invariant. The 2026-06-28 amendment to ADR-001 adds a retained-opt-in carve-out: the streaming default is untouched and remains the single-pass model; retained mode is an explicitly bounded, opt-in exception for the grid case.
Boundary — what retained mode lays out, and what still falls back
Section titled “Boundary — what retained mode lays out, and what still falls back”Retained mode handles the definite-column grid-template-areas case and that
case only. Everything outside it keeps the HTML_GRID_REQUIRES_RETAINED warning
and the block fallback, even with retained mode on:
grid-auto-flow: columnandgrid-auto-flow: dense.subgrid.@containerqueries.- Auto or intrinsic column tracks (
auto,min-content,max-content).
These are deferred slices, not silent gaps. A grid that depends on one of them degrades to block flow and tells you so.
Fail-closed boundary. A capture-versus-engine width mismatch — the measured
content width disagreeing with the width the grid engine resolves against —
fails closed rather than producing a misplaced grid. Retained mode is also
incompatible with Safe CSS rendering mode: CssRenderingMode::Safe combined with
CssLayoutMode::Retained raises IncompatibleRenderingModeException at config
validation. CssLayoutMode::Auto is reserved and raises NotImplementedException.
API surface
Section titled “API surface”| Symbol | Location | Role |
|---|---|---|
Config::withCssLayoutMode(CssLayoutMode $mode): self | src/Core/Config.php | Opt a document into Streaming (default) or Retained layout. |
Config::withRetainedNodeBudget(int $budget): self | src/Core/Config.php | Bound the retained node set ([5,000, 100,000], default 50,000). |
Config::isRetainedMode(): bool | src/Core/Config.php | Report whether the document is in retained mode. |
CssLayoutMode | src/Core/ | Streaming, Retained; Auto reserved (NotImplementedException). |
GridLayoutEngine | src/Html/ | The retained grid placement engine. |
IncompatibleRenderingModeException | src/Exception/ | Thrown when Safe CSS mode is combined with retained mode. |
Code sample — Quick start
Section titled “Code sample — Quick start”<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Config;use NextPDF\Core\CssLayoutMode;use NextPDF\Core\Document;
$config = (new Config())->withCssLayoutMode(CssLayoutMode::Retained);
$doc = Document::createStandalone($config);$doc->addPage();$doc->writeHtml( '<style>' . '.dashboard { display: grid; grid-template-columns: 1fr 2fr;' . ' grid-template-areas: "sidebar main"; }' . '.sidebar { grid-area: sidebar; } .main { grid-area: main; }' . '</style>' . '<div class="dashboard">' . ' <div class="sidebar">Navigation</div>' . ' <div class="main">Report content…</div>' . '</div>',);$doc->save(__DIR__ . '/grid.pdf');Code sample — Production
Section titled “Code sample — Production”Detect the incompatible-mode case at configuration time, and read back the active mode so the path is explicit.
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Config;use NextPDF\Core\CssLayoutMode;use NextPDF\Core\Document;use NextPDF\Exception\IncompatibleRenderingModeException;
try { $config = (new Config()) ->withCssLayoutMode(CssLayoutMode::Retained) ->withRetainedNodeBudget(75_000); $config->validate();} catch (IncompatibleRenderingModeException $e) { // Safe CSS mode and retained mode cannot combine. Choose one. throw $e;}
$doc = Document::createStandalone($config);assert($config->isRetainedMode());
$doc->addPage();$doc->writeHtml($gridHtml);$doc->save($out);
// A grid that needs a deferred feature (column auto-flow, subgrid, @container,// or intrinsic columns) still emits HTML_GRID_REQUIRES_RETAINED and falls back// to block flow. Inspect the advisory channel.Edge cases & gotchas
Section titled “Edge cases & gotchas”- Streaming stays the default and is byte-identical. Retained mode changes output only for the document you opt in.
- Only definite-column
grid-template-areas. Column auto-flow, dense packing, subgrid,@container, and intrinsic columns keep theHTML_GRID_REQUIRES_RETAINEDwarning and the block fallback. - Safe mode is mutually exclusive.
CssRenderingMode::SafeplusCssLayoutMode::RetainedthrowsIncompatibleRenderingModeException. Autois reserved.CssLayoutMode::AutoraisesNotImplementedException; it is not a usable third option yet.- Width mismatch fails closed. A capture-versus-engine content-width disagreement is refused, not rendered wrong.
Performance
Section titled “Performance”Retained mode holds a bounded node set rather than a full document tree; the
retainedNodeBudget (default 50,000) caps it. Grid placement is linear in the
node and cell count. The per-page performance_budget (wall_ms: 1500,
peak_mb: 64) applies; large grids should keep the budget in mind when raising
the node budget toward its 100,000 ceiling.
Security notes
Section titled “Security notes”Retained mode does not widen the input surface. The HTML security policy, CSS property allowlist, and parser caps apply unchanged. The retained node budget is itself a resource-exhaustion bound: it caps how much structure the engine will hold for a single document.
Conformance
Section titled “Conformance”| Statement | Spec | Clause |
|---|---|---|
grid-template-areas names grid cells; named areas place items. | W3C CSS Grid Layout Module Level 1 | §7.3 |
Explicit fixed, percentage, and fr tracks size against the content width. | W3C CSS Grid Layout Module Level 1 | §7.2 |
This is a preview implementation of a definite-column grid-template-areas
subset. Per-property verified status is tracked in the
CSS support matrix; no end-to-end conformance
is claimed here. No standards text is reproduced.