Warning
Experimental project – give it a try
Write XML/HTML-like markup directly in PHP files. PHPX compiles it to plain PHP arrays that a lightweight Renderer turns into HTML strings — no template engine, no magic.
Source (PHPX):
<>
<h1 className="title">Hello, {$name ?? ucfirst($type)}!</h1>
<p>
Welcome to the world of PHPX, where you can write PHP code in a JSX-like syntax.
<img src="about:blank" alt="Happy coding!" /> forever!
</p>
</>Compiled output (PHP):
[
['$', 'h1', ['className'=>"title"], ['Hello, ', ($name ?? ucfirst($type)), '!']],
['$', 'p', null, [
'Welcome to the world of PHPX, where you can write PHP code in a JSX-like syntax.',
['$', 'img', ['src'=>"about:blank", 'alt'=>"Happy coding!"]], ' forever!',
]],
]Each element is a tuple ['$', tag, props|null, children]. PHP expressions inside { } are preserved verbatim, so the compiled output is valid, executable PHP.
Important
This project is not yet published to Packagist. Add the repository manually or include it as a submodule.
git submodule add git@github.com:attitude/phpx.git path/to/phpxAdd to your project's composer.json:
{
"repositories": [
{
"type": "vcs",
"url": "https://github.com/attitude/phpx"
}
],
"require": {
"attitude/phpx": "dev-main"
}
}composer installDownload and extract the repository, then require_once 'path/to/phpx/src/index.php' in your project.
The Compiler transforms PHPX source strings into PHP code strings.
<?php
require_once 'path/to/phpx/src/index.php';
$compiler = new \Attitude\PHPX\Compiler\Compiler();
$phpCode = $compiler->compile(<<<'PHPX'
<>
<h1 className="title">Hello, {$name ?? ucfirst($type)}!</h1>
<p>
Welcome to the world of PHPX, where you can write PHP code in a JSX-like syntax.
<img src="about:blank" alt="Happy coding!" /> forever!
</p>
</>
PHPX);
echo $phpCode;By default the compiler emits ['$', 'tag', ...] arrays. Use PragmaFormatter to emit function-call style output instead:
$compiler = new \Attitude\PHPX\Compiler\Compiler(
formatter: new \Attitude\PHPX\Compiler\PragmaFormatter(pragma: 'html', fragment: 'fragment'),
);
$compiler->compile('<h1 className="title">Hello!</h1>');
// html('h1', ['className'=>"title"], ['Hello!'])
$compiler->compile('<>Hello, {$name}!</>');
// fragment(['Hello, ', ($name), '!'])Backtick template literals with ${ } interpolation are compiled to PHP string concatenation:
`Hello, my name is ${$name}, and I come from ${$country}!`
'Hello, my name is '.($name).', and I come from '.($country).'!'PHPX has two independent extension points:
FormatterInterfacecontrols the shape of the emitted PHP — array tuples vspragma(...)calls (see above).NodeVisitortransforms the AST between parsing and compilation — inspect, rewrite, or remove nodes before they are compiled.
A visitor implements enterNode() (top-down) and/or leaveNode() (bottom-up); extend AbstractNodeVisitor to override only what you need. Each hook returns:
null— keep the node unchanged;- a node array — replace the node;
NodeTraverser::REMOVE_NODE— drop the node (inside a list, e.g. children or attributes);NodeTraverser::DONT_TRAVERSE_CHILDREN(fromenterNode()) — skip the node's subtree.
Nodes are associative arrays tagged with '$$type' => NodeType. The traverser visits every semantic node; structural tokens (brackets, whitespace) are left alone. Register visitors on the compiler — they run in registration order, each as a full pass:
use Attitude\PHPX\Compiler\{Compiler, AbstractNodeVisitor, NodeTraverser};
use Attitude\PHPX\Parser\NodeType;
$stripComments = new class extends AbstractNodeVisitor {
public function leaveNode(array $node): array|int|null {
return $node['$$type'] === NodeType::PHPX_COMMENT
? NodeTraverser::REMOVE_NODE
: null;
}
};
$compiler = new Compiler(visitors: [$stripComments]);
$compiler->compile('<div>a{/* note */}b</div>');
// ['$', 'div', null, ['a', 'b']]With no visitors, the output is byte-for-byte identical to the default compiler.
The third extension point is the recognition layer. SyntaxRecognizer lets you teach the parser new token patterns — JSX is conceptually just one of the options, like a Babel parser plugin. A recognizer implements:
claims(TokensList $tokens): bool— return true when the tokens at the cursor start your construct. It MUST NOT move the cursor.parse(TokensList $tokens, Parser $parser): array— parse the construct into a node and advance the cursor past it. It may call$parser->parseNext()to recurse into standard parsing for nested constructs.
The node you return carries '$$type' => NodeType|string. A string $$type is a custom node kind: it travels through NodeTraverser like any node, so a NodeVisitor must lower it to a built-in NodeType node before compilation. A custom node that reaches the compiler unlowered throws.
Register recognizers on the parser and the lowering visitor on the compiler:
use Attitude\PHPX\Compiler\{Compiler, AbstractNodeVisitor};
use Attitude\PHPX\Parser\{NodeType, Parser, SyntaxRecognizer, Token, TokensList};
$macro = new class implements SyntaxRecognizer {
public function claims(TokensList $tokens): bool {
return $tokens->tokenAtCursorMatches(['%', '%']) !== null;
}
public function parse(TokensList $tokens, Parser $parser): array {
$tokens->tokenAtCursorAndForward(); // %
$tokens->tokenAtCursorAndForward(); // %
$inner = [];
while ($tokens->exist() && $tokens->tokenAtCursorMatches(['%', '%']) === null) {
$inner[] = $tokens->tokenAtCursorAndForward();
}
$tokens->tokenAtCursorAndForward(); // %
$tokens->tokenAtCursorAndForward(); // %
return ['$$type' => 'Macro', 'tokens' => $inner];
}
};
$lowerMacro = new class extends AbstractNodeVisitor {
public function enterNode(array $node): array|int|null {
return ($node['$$type'] ?? null) === 'Macro'
? ['$$type' => NodeType::EXPRESSION, 'value' => new Token(T_STRING, 'time()', 1, 0)]
: null;
}
};
$compiler = new Compiler(parser: new Parser(recognizers: [$macro]), visitors: [$lowerMacro]);
$compiler->compile('%%now%%'); // time()Two limitations in this release:
- Recognizers are consulted only at node boundaries (top level, and inside
(...)/[...]/{...}and element children). They do not interrupt a JSX text run — place custom syntax right after{...}or an element, not mid-text. - Attributes are not yet pluggable — recognizers do not run inside a tag's attribute list.
With no recognizers, the parser output is byte-for-byte identical to the default parser.
The Renderer converts the compiled array format into an HTML string.
<?php
require_once 'path/to/phpx/src/index.php';
$renderer = new \Attitude\PHPX\Renderer\Renderer();
$node = ['$', 'h1', ['className' => 'title'], 'Hello, World!'];
echo $renderer($node); // <h1 class="title">Hello, World!</h1>Strings and numbers are HTML-escaped; null and bool render as empty strings (matching React behaviour):
echo $renderer('<b>hi</b>'); // <b>hi</b>
echo $renderer(null); // (empty)
echo $renderer(true); // (empty)Uppercase tag names are compiled to variable references.
formatElement() outputs $Component (a PHP variable reference) for uppercase tag names instead of the string 'Component'. This means <Greeting /> compiles to ['$', $Greeting, ...] rather than ['$', 'Greeting', ...], so the renderer's components map is bypassed — a $Greeting closure must be in scope at evaluation time instead.
If you relied on passing components via the named map, this is a breaking change.
Pass an associative array of named components as closures. Each component receives a $props array (including children):
$node = ['$', $Greeting, ['name' => 'World']];
echo $renderer($node, [
'Greeting' => function(array $props): array {
return ['$', 'p', null, "Hello, {$props['name']}!"];
},
]);
// <p>Hello, World!</p>Pass a \Closure directly as the element type to skip the components map:
$greet = fn(array $props): array => ['$', 'span', null, "Hi, {$props['name']}!"];
echo $renderer(['$', $greet, ['name' => 'Alice']]);
// <span>Hi, Alice!</span>When used directly as the element type (index 1 of the node array), only a \Closure is supported. In the components map, however, values may be any PHP callable (accepting 0 or 1 parameter); children are passed via $props['children'].
className and htmlFor map to class and for. style accepts arrays/objects and is serialised to inline CSS, with camelCase keys converted to kebab-case. data and aria accept arrays/objects and are serialised to data-* / aria-* attributes, with keys lowercased. The same namespace-expansion behaviour applies to any other attribute whose value is an associative array or object (for example, 'x' => (object)['foo' => 'bar'] becomes x-foo="bar"). class accepts an associative array or object for clsx-like conditional classes, where keys are class names and values are booleans — only truthy entries are included. Array attribute values (indexed arrays) are flattened and space-joined. null always omits the attribute; for plain (non-hyphenated) attributes false also omits the attribute and true renders a valueless boolean attribute (e.g. checked), while for hyphenated attributes — including those produced via namespace expansion such as data-*, aria-* or x-* — true and false are serialised as the strings "true" and "false" to preserve their semantic meaning.
Use dangerouslySetInnerHTML to inject raw HTML — value must be ['__html' => '...'] and is not escaped; only use with trusted content.
$pretty (bool) enables indented output; $indentation (string, default "\t") sets the indent character. $void switches void elements to HTML5-style >. $react adds <!-- --> markers around leading and trailing whitespace in string children (even when the text also contains non-whitespace) for React-compatible output. Pass encoding: to the constructor to override the default UTF-8.
All text and attribute values are escaped via htmlspecialchars (ENT_QUOTES | ENT_HTML5 | ENT_SUBSTITUTE) using the renderer's configured encoding (default UTF-8). Tag and attribute names are validated against strict patterns. The only exception is dangerouslySetInnerHTML, which intentionally bypasses escaping — treat it like innerHTML and only pass trusted content.
PHPX ships a standard LSP server (scripts/language-server.php, stdio) providing diagnostics, completion, hover, and rename for .phpx files. It is editor-agnostic — see docs/EDITORS.md for Neovim, Helix, Sublime Text, JetBrains (LSP4IJ), and Zed setup. A VS Code extension lives in extension/.
Compile .phpx files to .php from the command line:
php scripts/compile.php path/to/component.phpxcomposer test # run the test suite
composer test:watch # re-run on file changes
composer test:coverage # generate a coverage report
composer test:update-snapshots # update test snapshotsCreated by martin_adamko