Skip to content

attitude/phpx

Repository files navigation

PHPX — JSX-like syntax for PHP

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.

How it works

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.


Installation

Important

This project is not yet published to Packagist. Add the repository manually or include it as a submodule.

Option 1: Git submodule

git submodule add git@github.com:attitude/phpx.git path/to/phpx

Option 2: Composer (VCS repository)

Add to your project's composer.json:

{
    "repositories": [
        {
            "type": "vcs",
            "url": "https://github.com/attitude/phpx"
        }
    ],
    "require": {
        "attitude/phpx": "dev-main"
    }
}
composer install

Option 3: Download ZIP

Download and extract the repository, then require_once 'path/to/phpx/src/index.php' in your project.


Usage

Compiler

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;

Pragma formatter

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), '!'])

Template literals

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).'!'

Extending the compiler with AST visitors

PHPX has two independent extension points:

  • FormatterInterface controls the shape of the emitted PHP — array tuples vs pragma(...) calls (see above).
  • NodeVisitor transforms 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 (from enterNode()) — 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.

Extending the parser with syntax recognizers

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.


Renderer

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>'); // &lt;b&gt;hi&lt;/b&gt;
echo $renderer(null);        // (empty)
echo $renderer(true);        // (empty)

Components

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'].

Prop conventions

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.

Renderer options

$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.


Security

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.


Editor support

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/.


CLI compilation

Compile .phpx files to .php from the command line:

php scripts/compile.php path/to/component.phpx

Running tests

composer 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 snapshots

Created by martin_adamko

About

PHPX is to PHP what JSX is to JS

Topics

Resources

License

Stars

16 stars

Watchers

1 watching

Forks

Packages

 
 
 

Contributors

Languages