xph /

Your next component system

Write JSX.
ship PHP.

Type-safe components with scoped styles, compiled to native PHP. Auto-escaped by default.

Deploy anywhere PHP runs.

Welcome.xph
---
export interface Props {
	name: string;
}

export interface Server {
	greeting: string;
}
---

<script is:server>
	return [
		'greeting' => 'Hello, ' . $props['name'],
	];
</script>

<h1>{server.greeting}</h1>

<style>
	h1 {
		color: olive;
	}
</style>

01 / Getting started

Getting started with xph.

Use Node.js for development and PHP to check and render your components.

01

Install xph

Create a project and install xph from npm as a development dependency.

Terminal
mkdir my-xph-app
cd my-xph-app
npm init -y
npm install -D xph
mkdir src
02

Write your first component

Create src/Welcome.xph in your new project.

Declare the props your component accepts and the data PHP returns. Use that data in your component; xph escapes dynamic text and scopes your styles.

src/Welcome.xph
---
export interface Props {
	name: string;
}

export interface Server {
	greeting: string;
}
---

<script is:server>
	return [
		'greeting' => 'Hello, ' . $props['name'],
	];
</script>

<h1>{server.greeting}</h1>

<style>
	h1 {
		color: olive;
	}
</style>
03

Define your build

Create xph.config.ts in your project root to set your source and output directories.

From your project root, run npx xph check to check contracts and PHP syntax, then npx xph build to generate PHP and assets. Use npx xph dev to rebuild as you edit.

xph.config.ts
export default {
	srcDir: 'src',
	outDir: 'dist',
	assetBase: '/assets/',
	compressHTML: true,
	scopedStyleStrategy: 'attribute',
	php: 'php',
	debug: false,
};
04

Render from PHP

Create dist/index.php to render your component.

Run php -S localhost:8080 -t dist from your project root and open http://localhost:8080 to see “Hello, world”. Copy dist/ to your PHP server to deploy.

dist/index.php
<?php
require __DIR__ . '/xph.php';

// Prepare all page components before emitting styles.
$renderer = \Xph\renderer(['Welcome']);

// Emit CSS once; place in your document head.
echo $renderer->styles();

// src/Welcome.xph is registered as Welcome.
echo $renderer->render('Welcome', ['name' => 'world']);

// Emit bundled modules once, after the content.
echo $renderer->scripts();

02 / Syntax

Syntax reference.

The supported component syntax, from typed inputs to browser scripts. Snippets use your declared props; imports and declarations are shown where needed.

Component data

Build-time code

Put imports, constants, and helper functions between --- fences. They run during compilation. Helper calls in templates also run at build time when all arguments are build-time values; their results must be serializable. Build-time code also supports expression statements, if, for, for…of, and while.

---
const name = 'xph';
function upper(value: string) {
	return value.toUpperCase();
}
---

<h1>{upper(name)}</h1>

Typed props

Declare inputs with Props and read them through props. Contracts support primitives, arrays, records, aliases, and unions, including imported and generic types that resolve to supported shapes. Types are checked at build time; PHP values are not validated at runtime.

---
export interface Props {
	title: string;
	size?: 'small' | 'large';
	items: { name: string; price: number }[];
}
---

<h1>{props.title}</h1>

PHP server data

One server script runs before each render. Read $props and return an array matching Server. Use PHP for request-time work; omit <?php tags.

Set debug: true to preserve original exceptions and include source diagnostics. It defaults to false.

---
export interface Server {
	year: string;
}
---

<script is:server>
	return [
		'year' => date('Y'),
	];
</script>

<p>{server.year}</p>

PHP server methods

Declare top-level methods or function properties on Server, then return PHP closures. Parameters and return values need explicit types; optional/rest parameters and nested methods are unsupported. Calls run when the expression renders. PHP numeric parameters should accept float or int|float.

---
export interface Server {
	upper(value: string): string;
}
---

<script is:server>
	return [
		'upper' => static fn(string $value): string => strtoupper($value),
	];
</script>

<h1>{server.upper('hello')}</h1>

Expressions & control flow

Text & expressions

Put values or expressions in braces to insert text, join strings, or calculate numbers with + - * / **. Dynamic text is HTML-escaped. Strings and numbers render as text; booleans, null, and undefined render nothing. Arrays and objects must be used in a list, spread, or other supported expression.

<h1>{props.title}</h1>
<p>{'Hello, ' + props.name}</p>
<p>Total: {props.price * props.quantity}</p>

Property access & fallbacks

Use dot or bracket access, including array indexes. Optional access ?. handles missing values; ?? supplies a fallback for null or undefined. Array length is supported; string indexing and string length are not.

<p>{props.user?.name ?? 'Guest'}</p>
<p>{props.items[0] ?? 'Empty'}</p>
<p>{props.items.length}</p>

Operators

Use + - * / ** and < <= > >= with numbers; + also joins two strings. Use === !== for scalar equality, ! && || for logical operations, and unary + - for numbers. Parentheses group expressions.

<p>{(props.price * props.quantity) + 5}</p>
<p>{props.status === 'ready' ? 'Ready' : 'Waiting'}</p>
<button disabled={!props.ready || props.busy}>Save</button>

Conditional content

Use && to show content, || to show a fallback, or a ternary to choose a branch. Conditions use JavaScript truthiness: false, null, undefined, 0, and "" are falsy; objects and arrays are truthy.

{props.visible && <p>Hello!</p>}
{props.visible || <p>Nothing to show.</p>}
{props.signedIn ? <a href="/account">Account</a> : <p>Sign in</p>}

Lists

Use .map() with an expression-bodied arrow callback. Its optional second parameter is the zero-based index. Prepare filtered or sorted request-time lists in PHP.

<ul>
	{props.items.map((item, index) => <li>{index + 1}. {item}</li>)}
</ul>
{props.items.length === 0 && <p>No items yet.</p>}

HTML & attributes

HTML, SVG & comments

Write ordinary HTML, SVG, custom elements, and HTML comments. Comments remain in the output. URL attributes are checked for unsafe schemes; invalid attributes, event handlers, and srcdoc are rejected.

<!-- This comment appears in the rendered HTML. -->
<svg viewBox="0 0 24 24">
	<circle cx="12" cy="12" r="10" />
</svg>
<my-element data-label="Hello"></my-element>

HTML whitespace

Static whitespace is compressed by default; dynamic values and whitespace-sensitive content such as <pre> are preserved. Set compressHTML: false to keep template spacing as written.

<!-- Expressions preserve the supplied whitespace. -->
<pre>{'First line\n  Indented line'}</pre>

HTML attributes

Use native names such as class and for. Boolean HTML attributes use presence; ordinary false/nullish values omit the attribute. ARIA and data booleans become "true" or "false". Empty strings and zero are preserved.

style accepts a CSS string or an object.

<label for="name">Name</label>
<input id="name" disabled={props.disabled} />
<p aria-hidden={props.disabled}>Status</p>
<div
	style={{
		backgroundColor: 'navy',
		'font-size': '14px',
		'--accent': 'gold',
		opacity: 0.8,
	}}
/>

Attribute & prop spreads

Spread an object onto HTML or a component. Later values override earlier ones. HTML attribute values must be scalar; component props can contain arrays and records matching their contracts.

<button {...props.attrs} id="save">Save</button>
<Card {...{ title: props.title }} />

Dynamic HTML tags

Use an uppercase binding or a property containing an HTML tag string. Dynamic tags support attributes, content directives, and scoped styles. They select HTML elements, not components; executable or document-changing tags such as script, style, and iframe are restricted.

---
export interface Props { tag: 'section' | 'article' }
const Tag = 'section';
---

<Tag>Fixed tag</Tag>
<props.tag>Tag from props</props.tag>

Components & slots

Components

Import a .xph component with a default binding and pass its declared props. Imports must stay inside your project; circular component imports are rejected.

---
import Card from './Card.xph';
---

<Card title="Hello" />

Recursive components

Use <xph.self> to render the current component without an import. Props are checked against its Props interface. Add a condition that stops recursion.

---
export interface Props { depth: number }
---

<p>{props.depth}</p>
{props.depth > 0 && <xph.self depth={props.depth - 1} />}

Default slots

In the child component, use <slot> for content passed between its opening and closing tags. Optional fallback content appears when the slot is not supplied.

<!-- Card.xph -->
<article>
	<slot>Nothing here yet.</slot>
</article>

<!-- Parent (imports Card) -->
<Card>
	<p>Inside the card</p>
</Card>

Named slots

Match a child’s literal slot name with <slot name="…">. Assigned elements must be direct children of an imported component. Multiple children with the same name render in order.

<!-- Card.xph -->
<slot name="header">Default heading</slot>

<!-- Parent (imports Card) -->
<Card>
	<h2 slot="header">News</h2>
</Card>

Rendering slots

xph.slots.render(name, args?) renders a slot to an HTML string at PHP request time. Missing slots return an empty string. Interpolation escapes the result; use set:html to insert its markup. Each call renders the slot again.

<div set:html={xph.slots.render('default')} />
<header set:html={xph.slots.render('header')} />

Slot callbacks

Pass positional arguments as an array to xph.slots.render(). The parent supplies an expression-bodied callback with explicitly typed parameters. For a named callback slot, wrap it in <Fragment slot="…">. Ordinary slot content ignores arguments; callback argument types are not checked across components.

<!-- List.xph (items: string[] in Props) -->
{props.items.map(item => (
	<p set:html={xph.slots.render('default', [item])} />
))}

<!-- Parent (imports List) -->
<List items={['Hello', 'World']}>
	{(item: string) => <strong>{item}</strong>}
</List>

Fragments

Group elements without adding an HTML wrapper using <> or <Fragment>. Built-in Fragment accepts an optional slot attribute for grouping named content and set:html for inserting HTML without a wrapper.

<>
	<h2>Title</h2>
	<p>Details</p>
</>

<Card>
	<Fragment slot="header">
		<h2>News</h2>
		<p>Today’s updates</p>
	</Fragment>
</Card>

Template directives

Conditional classes

class:list combines strings, numbers, nested arrays, and truthy object keys with an ordinary class. False, nullish, and zero entries are skipped. On components it passes the resulting string as a class prop.

<div class="panel" class:list={[
	'rounded', ['bordered'], { active: props.active },
]} />

Text & HTML directives

set:text inserts escaped text; set:html inserts trusted or sanitized HTML without escaping. Use one directive on an HTML element without children. set:html also works on <Fragment>. They do not work on components, slots, scripts, or styles.

<h2 set:text={props.title} />
<div set:html={props.html} />

Styles & browser scripts

SVG components

Import an SVG file with a default binding and render it as an inline component:

Attributes on the component override attributes on the SVG root. The original viewBox and SVG contents are preserved, and scoped styles can target the root. SVG files are inlined at build time; their contents must be trusted static markup.

---
import Logo from './logo.svg';
---
<Logo width={48} height={48} class="logo" aria-label="Site logo" />

Scoped & global CSS

<style> scopes selectors and keyframe names to the component. is:global disables scoping for the entire style block. Use :global(...) to leave only part of a selector unscoped: article :global(h1) scopes article while allowing it to target any descendant h1. Nested selectors such as :global(&.is-in) are unwrapped to &.is-in; other nested rules remain scoped. CSS is bundled into external assets; use PHP’s \Xph\assets() to include them, as shown in Getting Started.

Parent style scopes are passed to child components through props, as in Astro. With scopedStyleStrategy: 'class' or 'where', forwarding class={props.class} onto the child's HTML element also forwards the parent's scope class. With the default 'attribute' strategy, forwarding only class does not allow the parent's scoped selector to match; spread {...props} onto the element to also forward the parent's data-xph-* scope attribute. Neither strategy applies props to the child's HTML automatically.

---
import './styles.css';
---

<style>
	h1 { color: olive; }
</style>

<style is:global>
	body { margin: 0; }
</style>

PostCSS

XPH automatically discovers project PostCSS configuration, including postcss.config.cjs, postcss.config.mjs, and the postcss field in package.json. Configuration is searched from the component directory upwards. Install the plugins you use in your project, for example pnpm add -D autoprefixer:

Plugins run on <style> and <style is:global> before component scoping and minification, including in WordPress builds. Async plugins are supported. <style is:inline> bypasses PostCSS. Plugin warnings and configuration errors appear in compiler diagnostics. TypeScript and YAML configurations require the optional loaders supported by postcss-load-config.

// postcss.config.cjs
module.exports = {
	plugins: [require('autoprefixer')],
};

CSS scoping strategies

Set scopedStyleStrategy to 'attribute' (default), 'class', or 'where'. Use 'where' to scope styles without adding selector specificity, making them easier to override.

// xph.config.ts
export default {
	srcDir: 'src',
	scopedStyleStrategy: 'where',
};

Browser scripts & events

Plain scripts run in the browser and support TypeScript and imports. Use event listeners instead of HTML event-handler attributes. Scripts are bundled into external assets; include their URLs through \Xph\assets().

<button id="save">Save</button>
<script>
	document.querySelector('#save')?.addEventListener('click', () => {
		console.log('Saved');
	});
</script>

Inline scripts & styles

Add is:inline to render a script or style in place. Content and static attributes stay as written; imports and URLs are left unchanged. Inline assets skip bundling, type checking, CSS scoping, and deduplication, and are excluded from \Xph\assets(). Use plain JavaScript; dynamic attributes are unsupported.

<script is:inline>
	console.log('Hello');
</script>

<script is:inline src="/browser.js" defer></script>

<style is:inline>
	body { margin: 0; }
</style>

Passing data to the browser

Browser scripts cannot access props, server, or frontmatter bindings. Pass scalar data through HTML data attributes and read it from the DOM. Runtime interpolation inside scripts and styles is unsupported.

<p id="greeting" data-name={props.name}></p>
<script>
	const el = document.querySelector<HTMLElement>('#greeting');
	if (el) el.textContent = 'Hello, ' + el.dataset.name;
</script>

Generated PHP

Import php from xph to compile trusted build-time PHP statements:

Pass a PHP body without <?php or ?> tags. Statements execute at the insertion point on each render, after the server script. The helper does not implicitly echo return values. PHP values are supported as template children, not attributes. Ordinary strings remain data and never execute as PHP.

---
import { php } from 'xph';
const registration = php('add_action("init", "register_content");');
---
{registration}

03 / In your workflow

Bring your tools.

Use xph components in your PHP application, WordPress theme, or Laravel project, with editor support for your workflow.

01

WordPress

Build WordPress themes and dynamic blocks with xph-wordpress. Use Vite for theme development and live PHP previews in Gutenberg, with component styles and scripts included automatically.

Set up WordPress ↗
02

Laravel

Render components from Blade with @xph. Middleware adds dependency CSS and JavaScript automatically, deduplicated per request.

Set up Laravel ↗
03

VS Code

Syntax highlighting, diagnostics, completion, hover, and navigation for typed props and HTML attributes. Add an optional PHP language server for IntelliSense, code actions, and formatting inside server blocks.

Set up VS Code ↗
04

Neovim

A plugin for Neovim 0.11+ with .xph detection, highlighting, and language server setup. The shared language server includes contract and HTML attribute completion, hover, diagnostics, and component navigation.

Set up Neovim ↗