PHP RFC: Native Markup Expressions
- Version: 0.1
- Date: 2026-07-15
- Author: Liam Hammett, liam@liamhammett.com
- Status: Under Discussion
- Target version: PHP 8.7/9.0
- Implementation: https://github.com/php/php-src/pull/22661
- Discussion thread: https://news-web.php.net/php.internals/131925
- Voting thread: tbd
Introduction
PHP began as a templating language - code embedded inside HTML. As the language and its ecosystem have matured, the inverse has become just as desirable: embedding HTML inside PHP, as a first-class value you can compose, return, and pass around.
Yet PHP still has no native way to do it. Today authors choose between:
- Inline HTML (
?> <h1><?= $x ?></h1> <?php) - output-only: it cannot be captured as a value without output buffering, offers no composition, does not escape, and the tag-soup syntax is verbose and hard to read. - String concatenation / heredocs - no escaping, no structure, error-prone.
- Userland template engines (Blade, Twig, Latte) - powerful, but each requires a separate template dialect and files, a compilation step, a parser written in PHP, and its own third-party IDE/tooling support.
This RFC proposes a native markup expression syntax, inspired by JSX, that produces an in-memory tree of objects which renders to safely-escaped HTML. Markup becomes a value: it can be returned, passed to functions, stored, and composed.
class Greeting implements Markup\Html { public function __construct(public string $name, public string $type) {} public function toHtml(): Markup\Html { return <> <h1 class="title">Hello, {$this->name ?: ucfirst($this->type)}!</h1> <p>Welcome to PHP, where markup is a first-class expression.</p> </>; } } echo <Greeting name="Rasmus" type="guest" />; // prints the rendered HTML, dynamic values escaped
Despite appearances, this is not a new template language grafted onto PHP - the syntax is pure compile-time sugar. Every markup expression lowers, during compilation, to a plain new expression:
// The new syntax... $html = <button class="btn">Sign in</button>; // ...compiles to exactly this - same AST, same opcodes: $html = new \Markup\Element('button', ['class' => 'btn'], ['Sign in']);
Nothing downstream of the parser changes, and markup obeys the expression semantics every PHP developer already knows.
The two headline benefits over every existing option are composition (components, attributes, slots) and escape-by-default safety - neither of which PHP's existing markup facilities provide. And because markup is ordinary PHP code rather than a string dialect, static analysis reaches inside it: component props are named arguments checked against real signatures, so templates become type-checkable end-to-end.
Why this is worth doing
Generating HTML is not a niche PHP task - it is arguably the PHP task, and essentially every major framework has independently built the same answer to it: an escape-by-default, composable template engine. Their adoption is enormous - on Packagist, laravel/framework (Blade) has over 540 million installs and twig/twig over 460 million, before counting Latte, Smarty, Plates, or hand-rolled templates that never appear in a dependency graph.
That every engine converged on the same two features is strong evidence the need is real and the language does not meet it: developers reach for a third-party compiler, a separate dialect, and a build step to obtain what the runtime could provide natively. And escape-by-default is no mere convenience - cross-site scripting has sat in the OWASP Top 10 for its entire existence, and automatic HTML escaping is one of the disciplines that prevents it.
History
This idea is not new - PHP is where it started. Around 2010, Facebook built XHP, a PHP extension that made XML/HTML fragments valid PHP expressions with automatic escaping - <a href={$url}>Link</a> as a real value - and it survives today as a native feature of Hack. JSX was born directly out of XHP, and has since become the way most frontend developers write user interfaces. The pattern has proven itself at ecosystem scale; this RFC brings it home.
The approach has surfaced in various forms many times over the years without a formal proposal; discussions about “automatic template escaping” resulted in arguments that “XHP really is the right solution for this problem,” though that also raised the central objection - escaping is context-dependent (HTML vs URL vs JS vs CSS) - which this RFC answers by scoping escaping to HTML context only (see Escape-by-default).
Goals and Non-Goals
The goal of this RFC is a native, ergonomic syntax for building HTML - the output format the overwhelming majority of PHP produces - with escaping and composition built in.
This RFC is deliberately narrower than XHP: it targets HTML specifically, not general XML. That choice is visible throughout - escaping uses htmlspecialchars; void elements serialize as HTML5 (<br>, not <br/>); text and attribute handling follow HTML, not XML, semantics. There is no XML namespace support, no CDATA, no DTD, and no requirement that output be well-formed XML. This keeps the surface small and the output aimed at the one thing PHP overwhelmingly produces: web pages. Emitting XML (RSS, SVG, SOAP) remains the job of the existing DOM / XMLWriter APIs and is out of scope.
Semantic validation of attribute values is also not a goal - a javascript: URL in href/src escapes cleanly yet is not blocked: sometimes it is intentional, and the policy is the application's to set. (Latte nullifies such URLs and React warns; a framework can enforce the same here via a component decorator or userland wrapper.) Attribute and tag names, by contrast, are always validated - see Escape-by-default.
The aim, in short: enough syntax to make HTML a first-class concern of PHP, with the extension points for frameworks to customize behaviour, and room to grow without breaking backward compatibility (see Future Scope).
Why core, and not an extension
An obvious question - especially given the XHP precedent - is whether this could be proven out as a PECL extension first. It cannot, for one technical reason and one practical one.
Technically, markup must begin at a bare < in operand position, and telling that apart from the comparison and shift operators requires the scanner change described under Backward Incompatible Changes - state no extension can reach. The only route open to an extension is rewriting source text before compilation (the original XHP extension's approach), which shifts line numbers in errors and stack traces and leaves the raw file something token_get_all() - and therefore every tool built on it - cannot tokenize.
Practically, a syntax lives or dies by its ecosystem. Code using an extension-only syntax cannot be reasonably published to Packagist - it is a parse error on any install without the extension - and no IDE, formatter, or static analyser updates its grammar for syntax that may not exist on a given machine. That chicken-and-egg is part of where XHP for PHP stalled, while the same feature thrived in Hack, where it is part of the language. Shipping in core is what makes markup part of PHP itself: parsers, IDEs, and analysis tools implement it once, and every developer's markup gets the same highlighting, formatting, and static analysis their ordinary code already enjoys.
Proposal
1. A markup expression that evaluates to an object
The core proposal is that a markup expression evaluates to an instance of a built-in type implementing Markup\Html. It is not a string - but casting it to one will return a string value.
This is the JSX model (elements are objects, strings appear only after rendering) and is what makes composition and a single, central escaping step possible.
Desugaring at a glance
Every construct in this proposal lowers the same way - at compile time, to a new expression or a function call, behaviour PHP already supports well; no other part of the engine changes.
// Attributes: literal, ={expr}, bare boolean, {...spread} (see Attributes) <a href={$url} class="btn">{$label}</a> // → new \Markup\Element('a', ['href' => $url, 'class' => 'btn'], [$label]) // Fragments group children with no wrapper element (see Syntax structure) <><h1>{$title}</h1><input {...$attrs} required /></> // → new \Markup\Fragment([ // new \Markup\Element('h1', [], [$title]), // new \Markup\Element('input', [...$attrs, 'required' => true], []), // ]) // Components dispatch through Markup\render_component() (see Tags) <Card title="Hi" /> // → \Markup\render_component(Card::class, ['title' => 'Hi']) // Component body → slot; extra regions are markup-valued props (see Children & slots) <Layout theme="dark" header={<h1>Title</h1>}> <p>Body content.</p> </Layout> // → \Markup\render_component( // Layout::class, // resolved as a class name string // ['theme' => 'dark', // props → named arguments // 'header' => new \Markup\Element('h1', [], ['Title'])], // new \Markup\Fragment([/* <p>...</p> */]), // body slot // ) // Dynamic tags: the value classifies at runtime (see Tags) <$tag class="box">{$content}</$tag> // → \Markup\render_dynamic($tag, ['class' => 'box'], [$content])
Two properties fall directly out of this lowering. Markup is zero-cost sugar - writing the objects by hand produces the same AST and opcodes. And markup obeys ordinary expression semantics - attributes and children are argument expressions, evaluated eagerly, scoped and typed like any other PHP code.
Every lowering in this document can be reproduced on the reference implementation with no extra tooling: php -d zend.assertions=1 -r 'assert(false && <markup />);' prints the desugared form in the assertion message.
2. Escape-by-default
This is a templating feature, and like every major PHP template engine (Blade, Twig, Latte) it escapes by default:
- Interpolations
{$expr}and attribute values are escaped withhtmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401)- likehtmlspecialchars()itself, the charset comes from inidefault_charset(UTF-8 by default), so non-UTF-8 applications keep working. - Any value implementing
Markup\Html(including elements and fragments) passes through un-escaped - it is already assumed to be safe HTML. - The explicit, greppable opt-out for trusted raw strings is
Markup\raw($trustedString), which returns aMarkup\Html.Markup\escape($string)is also provided.
Escaping is HTML-context only. URL/JS/CSS-context escaping is explicitly out of scope and remains the author's responsibility. Full context-aware escaping is noted as Future Scope.
A single escaping context is sound here in a way it is not for string-template engines, because there is no context to detect:
- The grammar admits dynamic data in few positions - child content and attribute values - each escaped by one fixed rule (attribute values are always serialized double-quoted; no unquoted output form exists).
- Dynamically-supplied names (spread keys,
new Element($tag)) are validated and can throw a runtime exception, not escaped - an invalid name is aValueError, so a name can never break out of the markup. <script>/<style>get no raw-text mode: interpolated content there is HTML-escaped like any other child, so</script>breakout is inexpressible.
3. Tags: HTML elements vs components
Dispatch is purely syntactic (the compiler cannot know runtime types):
| Tag form | Meaning |
|---|---|
<div>, <my-widget> (lowercase / hyphenated) | literal HTML element → Markup\Element |
<Card> (bare, capitalized) | a component - resolved as a class name honoring use / namespace |
<App\Card>, <\App\Card> (namespace-qualified) | a component - a namespace-qualified or fully-qualified class name |
<Author::byline> | a component - a public static method on a class |
<$tag>, <{expr}> (a variable / any expression) | a dynamic tag - the runtime string value is classified by the same rule, at runtime |
Any tag whose name is capitalized, contains a namespace separator (\), or names a static method (::) is a component; everything else is a literal HTML element.
A component produces Markup\Html, and every component tag resolves through the class name rules alone. There are two forms:
// Class implementing Markup\Html - the instance IS the renderable class Card implements Markup\Html { public function __construct(public string $title) {} public function toHtml(): Markup\Html { return <div class="card">{$this->title}</div>; } } // <Card title="Hi" /> → new Card(title: 'Hi') // Static method - multiple small components can live together on one class class Author { public static function byline(string $name): Markup\Html { /* ... */ } public static function avatar(string $src): Markup\Html { /* ... */ } } // <Author::byline name="Rasmus" /> → Author::byline(name: 'Rasmus')
toHtml() returns markup, not a string, so it composes under strict_types with no cast, and callers can still reach the produced Markup\Element tree. It may also return another Html - e.g. delegate to another component instance. A static-method component must likewise return Markup\Html, and must be public, static, and user-defined.
Both forms resolve through the class resolution every PHP developer already knows - a <Author::byline/> tag's class part resolves exactly as Author::class would. Plain function components are deferred to Future Scope: a bare <Foo/> gives no syntactic signal whether a class or a function is meant, and PHP resolves the two through separate use / use function import tables, so supporting functions means dual-candidate name resolution in the compiler and a class-vs-function dispatch order at runtime (plus guarding tags like <Date/> from resolving to internal functions such as date()). A static method has no such ambiguity.
Bare-tag and qualified-tag resolution
A component tag resolves exactly as a class name does - honoring use imports and the current namespace, precisely as Foo::class would where the tag appears. The same applies to the class part of a static-method tag:
namespace Views; use App\Card; use App\Author; <Card title="Hi" /> // → new App\Card(title: 'Hi') <Author::byline name="R"/> // → App\Author::byline(name: 'R')
Tags may also be written namespace-qualified or fully-qualified directly; any tag containing \ is a component regardless of the first letter's case:
<App\Card title="Hi"/> // namespace-relative <\App\Card title="Hi"/> // fully-qualified
Dynamic tags - <$tag> and <{expr}>
The tag position also accepts a dynamic value, for the common CMS/builder case where the element or component is data (“render this list of blocks”). It follows the rule PHP developers already know from double-quoted string interpolation: a simple variable may appear bare, and braces admit any expression:
$tag = $isImportant ? 'strong' : 'span'; echo <$tag class="note">{$text}</$tag>; echo <{$block->type} {...$block