polidog / use-php
React Hooks-like PHP components with server-side state management
Requires
- php: >=8.5
- nikic/php-parser: ^5.7
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.93
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.0
- vimeo/psalm: ^6.14
README
A framework that delivers server-driven UI with minimal JavaScript, using a React Hooks-like API.
Features
- React Hooks-like API - Simple state management with
useState - Function Components (Recommended) - Lightweight components using simple PHP callables
- Built-in Router - Simple, swappable router with snapshot state preservation across pages
- Minimal JS (~40 lines) - Smooth UX with partial updates, graceful fallback without JS
- Pure PHP by default - No transpilation needed for
H::xxx()style components - PSX (optional) - TSX-like template syntax compiled by
usephp compilefor HTML-heavy UIs (see PSX section) - Configurable State Storage - Choose between session (persistent) or memory (per-request) storage
- Progressive Enhancement - Works even with JavaScript disabled
- Framework Integration - Works with Laravel, Symfony, and other frameworks
Installation
composer require polidog/use-php
# Copy JS file to public directory (full progressive enhancement layer)
./vendor/bin/usephp publish
Quick Start
1. Create a Function Component
<?php // components/Counter.php use Polidog\UsePhp\Html\H; use Polidog\UsePhp\Runtime\Element; use function Polidog\UsePhp\Runtime\fc; use function Polidog\UsePhp\Runtime\useState; // Define a counter component with fc() wrapper $Counter = fc(function(array $props): Element { [$count, $setCount] = useState($props['initial'] ?? 0); return H::div( className: 'counter', children: [ H::span(children: "Count: {$count}"), H::button( onClick: fn() => $setCount($count + 1), children: '+' ), H::button( onClick: fn() => $setCount($count - 1), children: '-' ), ] ); }, 'counter'); // 'counter' is the key for state management
2. Create an Entry Point with Router
<?php // public/index.php require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../components/Counter.php'; use Polidog\UsePhp\UsePHP; // Serve usephp.js (for partial updates) if ($_SERVER['REQUEST_URI'] === '/usephp.js') { header('Content-Type: application/javascript'); readfile(__DIR__ . '/usephp.js'); exit; } // Configure snapshot security (recommended) UsePHP::setSnapshotSecret('your-secret-key-here'); // Configure routes $router = UsePHP::getRouter(); $router->get('/', Counter::class)->name('home'); $router->get('/about', AboutPage::class)->name('about'); // Run the application UsePHP::run();
When you render your own layout, prefer UsePHP::renderClientScript() over a
hand-written <script src="/usephp.js">. It loads the published asset with
defer and includes a tiny inline fallback that hydrates deferred components
if the asset cannot be read; partial form updates still require the full
usephp.js file.
3. Start the Server
php -S localhost:8000 public/index.php
Open http://localhost:8000 in your browser.
Router
usePHP includes a built-in router that can be swapped or disabled for framework integration.
Basic Usage
use Polidog\UsePhp\UsePHP; $router = UsePHP::getRouter(); // Register routes $router->get('/', HomeComponent::class)->name('home'); $router->get('/users/{id}', UserComponent::class)->name('user.show'); $router->post('/users', CreateUserHandler::class)->name('user.create'); // Route groups $router->group('/admin', function ($group) { $group->get('/dashboard', DashboardComponent::class)->name('admin.dashboard'); $group->get('/users', AdminUsersComponent::class)->name('admin.users'); }); // Run the application UsePHP::run();
URL Generation
// Generate URLs from route names $url = $router->generate('user.show', ['id' => '42']); // /users/42
useRouter Hook
Access router functionality within components:
use function Polidog\UsePhp\Runtime\useRouter; $NavComponent = fc(function(array $props): Element { $router = useRouter(); return H::nav(children: [ H::a(href: $router['navigate']('home'), children: 'Home'), H::a(href: $router['navigate']('about'), children: 'About'), $router['isActive']('home') ? H::span(children: '(current)') : null, ]); }, 'nav');
The useRouter() hook returns:
navigate(routeName, params)- Generate URL for a named routecurrentUrl- Current request URLparams- Route parameters from current matchisActive(routeName)- Check if a route is currently active
Snapshot Behavior
Control how state is preserved across page navigations:
// Isolated (default) - State is page-specific $router->get('/page', PageComponent::class)->isolatedSnapshot(); // Persistent - State is passed via URL when navigating $router->get('/cart', CartComponent::class)->persistentSnapshot(); // Session - State is stored in session $router->get('/wizard', WizardComponent::class)->sessionSnapshot(); // Shared - State is shared between specific routes $router->get('/step1', Step1Component::class)->sharedSnapshot('checkout'); $router->get('/step2', Step2Component::class)->sharedSnapshot('checkout');
StorageType vs SnapshotBehavior
These two concepts control state at different levels:
| StorageType (Component) | SnapshotBehavior (Router) | |
|---|---|---|
| Scope | Individual component | Route/page transitions |
| Configuration | #[Component(storage: '...')] |
$router->get(...)->sessionSnapshot() |
| Purpose | How a component's state is stored | How snapshots are handled across routes |
Example: A TodoList component with storage: 'session' stores its own state in the session. Meanwhile, SnapshotBehavior::Persistent on a route controls whether the entire page snapshot is passed via URL when navigating to another route.
Framework Integration
When using usePHP within Laravel, Symfony, or other frameworks:
// Laravel example Route::get('/counter', function () { UsePHP::disableRouter(); // Use NullRouter return UsePHP::render(Counter::class); }); // Symfony example #[Route('/counter')] public function counter(): Response { UsePHP::disableRouter(); return new Response(UsePHP::render(Counter::class)); }
Architecture
With JavaScript (Partial Updates)
[Browser] [PHP Server]
| |
| GET / |
| -------------------------------->|
| | Component renders
| <html>Count: 0</html> | useState → saves to session
| <--------------------------------|
| |
| POST + X-UsePHP-Partial header |
| -------------------------------->|
| | State update
| <partial>Count: 1</partial> | Re-render component only
| <--------------------------------|
| (innerHTML partial update) |
Without JavaScript (Fallback)
[Browser] [PHP Server]
| |
| <form> POST (button click) |
| -------------------------------->|
| | State update
| 303 Redirect |
| <--------------------------------|
| |
| GET / |
| -------------------------------->|
| <html>Count: 1</html> | Full page re-render
| <--------------------------------|
API
Component Definition
Function Components (Recommended)
Function components are simple PHP callables that return Elements. They are the recommended way to build components in usePHP.
use Polidog\UsePhp\Html\H; use Polidog\UsePhp\Runtime\Element; use function Polidog\UsePhp\Runtime\useState; use function Polidog\UsePhp\Runtime\fc; // Simple function component (pure, no state) $Greeting = fn(array $props): Element => H::div( children: "Hello, {$props['name']}!" ); // Function component with useState $Counter = fc(function(array $props): Element { [$count, $setCount] = useState($props['initial'] ?? 0); return H::div(children: [ H::span(children: "Count: {$count}"), H::button( onClick: fn() => $setCount($count + 1), children: '+' ), ]); }, 'counter'); // Function component with snapshot storage (stateless server) use Polidog\UsePhp\Storage\StorageType; $SnapshotCounter = fc(function(array $props): Element { [$count, $setCount] = useState($props['initial'] ?? 0); return H::div(children: "Count: {$count}"); }, 'snapshot-counter', StorageType::Snapshot);
Using function components:
// Method A: fc() wrapper (Recommended) // Wrap with fc() for direct invocation with state support $Counter = fc(function(array $props): Element { [$count, $setCount] = useState($props['initial'] ?? 0); return H::div(children: "Count: $count"); }, 'my-counter'); $element = $Counter(['initial' => 5]); // Direct call $html = UsePHP::renderElement($element); // Method B: H::component() // Creates an Element that resolves during render H::div(children: [ H::component($counterFn, ['initial' => 5, 'key' => 'my-counter']), ]); // Method C: Direct call (only for pure components without useState) $Greeting = fn(array $props): Element => H::div(children: "Hello, {$props['name']}!"); $Greeting(['name' => 'World']); // OK - no state needed
fc() Storage Types:
The fc() function accepts an optional third parameter to specify the storage type:
use Polidog\UsePhp\Storage\StorageType; // Session storage (default) - State persists in PHP session $Counter = fc(fn() => ..., 'key'); $Counter = fc(fn() => ..., 'key', StorageType::Session); // Memory storage - State resets on each request $TempForm = fc(fn() => ..., 'key', StorageType::Memory); // Snapshot storage - State is embedded in HTML (stateless server) $SnapshotCounter = fc(fn() => ..., 'key', StorageType::Snapshot);
| Storage Type | Description | Use Case |
|---|---|---|
Session |
State stored in PHP session | Default. Forms, shopping carts |
Memory |
State reset per request | Temporary UI state, modals |
Snapshot |
State embedded in HTML | Stateless server, shareable URLs |
Class-based Components
For more complex components that need lifecycle methods or dependency injection, you can use class-based components:
use Polidog\UsePhp\Component\BaseComponent; use Polidog\UsePhp\Component\Component; #[Component] class MyComponent extends BaseComponent { public function render(): Element { [$count, $setCount] = $this->useState(0); // ... } }
Component Storage Types
The #[Component] attribute accepts a storage parameter to control how component state is persisted:
use Polidog\UsePhp\Component\Component; use Polidog\UsePhp\Storage\StorageType; // Session storage (default) - State persists across page navigations #[Component(storage: 'session')] class TodoList extends BaseComponent { ... } // Memory storage - State is reset on each page load #[Component(storage: 'memory')] class TemporaryForm extends BaseComponent { ... } // Snapshot storage - State is embedded in HTML, stateless on server #[Component(storage: 'snapshot')] class Counter extends BaseComponent { ... }
| Storage Type | Description | Use Case |
|---|---|---|
session |
State stored in PHP session | Default. Forms, shopping carts, user preferences |
memory |
State reset per request | Temporary UI state, modals |
snapshot |
State embedded in HTML | Stateless server, shareable URLs |
useState
use function Polidog\UsePhp\Runtime\useState; // In function components [$state, $setState] = useState($initialValue); // Examples [$count, $setCount] = useState(0); [$todos, $setTodos] = useState([]); [$user, $setUser] = useState(['name' => 'John']); // In class-based components [$state, $setState] = $this->useState($initialValue);
HTML Elements
use Polidog\UsePhp\Html\H; // Basic usage H::div( className: 'container', id: 'main', children: [ H::h1(children: 'Title'), H::button( onClick: fn() => $setCount($count + 1), children: 'Click' ), ] ); // Conditional rendering H::div(children: [ $isLoggedIn ? H::span(children: 'Welcome') : null, $count > 0 ? H::ul(children: $items) : H::p(children: 'No items'), ]); // All HTML elements are supported H::article(className: 'post', children: [...]); H::table(children: [H::tr(children: [H::td(children: 'Cell')])]); H::video(src: 'movie.mp4', controls: true);
Composing Components
// Define reusable components $Button = fc(function(array $props): Element { return H::button( className: 'btn', onClick: $props['onClick'] ?? null, children: $props['children'] ?? '' ); }, 'button'); $Card = fc(function(array $props): Element { return H::div( className: 'card', children: [ H::h2(children: $props['title']), H::p(children: $props['content']), ] ); }, 'card'); // Compose them together $App = fc(function(array $props): Element { [$count, $setCount] = useState(0); global $Button, $Card; return H::div(children: [ $Card(['title' => 'Counter', 'content' => "Count: $count"]), $Button(['onClick' => fn() => $setCount($count + 1), 'children' => 'Increment']), ]); }, 'app');
PSX (optional, TSX-like syntax)
PSX is an opt-in alternative way to author components: you write HTML directly and usephp compile lowers it to the same H::xxx() calls shown above. The runtime is unchanged — PSX is purely a syntax layer.
When to use it
- Components with deeply nested HTML where
H::div(children: [...])becomes hard to read - Teams used to JSX/TSX who want familiar template syntax
If your components are simple, sticking with H::xxx() is fine — PSX adds a build step, while plain PHP doesn't.
Syntax at a glance
<?php // components/Counter.psx namespace App\Components; use Polidog\UsePhp\Html\H; use Polidog\UsePhp\Storage\StorageType; use function Polidog\UsePhp\Runtime\fc; use function Polidog\UsePhp\Runtime\useState; return fc(function (array $props) { [$count, $setCount] = useState($props['initial'] ?? 0); return ( <div className="counter"> <span>Count: {$count}</span> <button onClick={fn() => $setCount($count + 1)}>+</button> <button onClick={fn() => $setCount($count - 1)}>-</button> </div> ); }, 'counter', StorageType::Session);
Lowers to (cached file inside var/cache/psx/):
return fc(function (array $props) { [$count, $setCount] = useState($props['initial'] ?? 0); return ( H::div(className: 'counter', children: [ H::span(children: ['Count: ', $count]), H::button(onClick: fn() => $setCount($count + 1), children: '+'), H::button(onClick: fn() => $setCount($count - 1), children: '-'), ]) ); }, 'counter', StorageType::Session);
Compile workflow
# One-shot compile of all .psx under components/ ./vendor/bin/usephp compile components/ # Watch mode for the dev loop ./vendor/bin/usephp compile components/ --watch # CI: fail if anything is out of date ./vendor/bin/usephp compile components/ --check # Remove the cache directory ./vendor/bin/usephp compile components/ --clean # Use a custom cache directory ./vendor/bin/usephp compile components/ --cache=build/psx
compile writes its output to var/cache/psx/ (configurable with --cache=PATH). Each .psx source file produces a sha1-named .php companion in that directory, plus a single manifest.php (FQCN → compiled-path map). The .psx source files in your project tree are the only PSX-related files you commit — the cache directory is intended to be ignored:
/var/cache/psx/
Loading PSX components at runtime
use Polidog\UsePhp\Psx\CompileCommand; $app = new UsePHP(); $app->loadComponentManifest(__DIR__ . '/../var/cache/psx/' . CompileCommand::MANIFEST_FILENAME); // Use a PSX component as a route handler $router->get('/', function () use ($app) { \Polidog\UsePhp\Runtime\RenderContext::beginRender(); return $app->renderPsxComponent('App\\Components\\Counter', ['initial' => 0]); });
Composing PSX components
<Counter /> (PascalCase) inside another .psx resolves through PHP's use statements to a fully qualified class name, just like a regular class import:
<?php namespace App\Pages; use App\Components\Counter; use App\Components\Forms\Input as FormInput; return fn() => ( <div> <Counter initial={5} /> <FormInput type="email" /> </div> );
If a component is registered at runtime instead of being defined in a .psx file, declare it with a comment so the compiler doesn't fail validation:
// @psx-runtime App\Legacy\WidgetCounter
For the full spec — Fragment syntax, attribute dispatch, manifest format, edge cases — see docs/PSX.md.
Editor support
Syntax highlighting for .psx is bundled in editors/:
- Neovim / Vim —
editors/nvim/(lazy.nvim / packer / vim-plug / manual install) - VS Code —
editors/vscode/(.vsixbuild or local symlink)
Each editor's README walks through install + verify steps. An LSP / tree-sitter grammar / PHPStan extension are intentionally out of scope here and will land in dedicated repositories.
Deferred rendering (CDN-friendly partial hydration)
Some components depend on per-user state (logged-in name, cart count, A/B bucket) and would otherwise force the entire page out of the CDN cache. The fix is to split such a component into two pieces:
- A base component that does the actual rendering.
- A deferred wrapper that carries the defer config —
fc(..., defer: new Defer(...))for closure components, or#[Defer(...)]for class components.
The page references the wrapper, which renders only a fallback in the cacheable
HTML. The real component is fetched after page load via a separate GET to a
dedicated endpoint /_defer/{name}.
{/* UserHeader.psx — the actual content, reused inline elsewhere if needed */}
return fn(array $props) => <header>Hello {$_SESSION['user']['name'] ?? 'guest'}</header>;
{/* UserHeaderDeferred.psx — the wrapper */}
use Polidog\UsePhp\Component\Defer;
use function Polidog\UsePhp\Runtime\fc;
return fc(
fn(array $props) => <UserHeader />,
defer: new Defer(name: 'user-header', cacheControl: 'private, no-store'),
);
{/* Page.psx — fallback travels as a normal prop */}
<UserHeaderDeferred fallback={<HeaderSkeleton />} />
usephp compile discovers Defer configs and writes a sidecar
deferred-manifest.php next to the regular manifest — loadComponentManifest()
picks it up and auto-calls registerDeferred() for each entry, so the manual
wiring goes away. Class components use the same flow:
#[Component(name: 'UserHeaderDeferred')] #[Defer(name: 'user-header', cacheControl: 'private, no-store')] final class UserHeaderDeferred extends BaseComponent { public function render(): Element { /* the real content */ } } $app->register(UserHeaderDeferred::class); // auto-registers the defer endpoint too
What happens at runtime:
- SSR invokes the wrapper, which (on the page-render path) emits
<div data-usephp-defer-url="/_defer/user-header">…fallback…</div>. - The main HTML is independent of the user — safe to cache at the CDN edge.
usephp.jsfinds all[data-usephp-defer-url]elements afterDOMContentLoadedandGETs each URL. The framework flips an internal flag, re-invokes the wrapper in endpoint mode (so it returns the real content instead of the placeholder), and responds with the configuredCache-Control.- The placeholder is replaced in place with the real component.
- Because each deferred endpoint has its own URL, each can carry its own cache policy —
public, s-maxage=60for a shared announcement bar,private, no-storefor a session-coupledUserHeader, etc.
Passing data from the parent
Any prop besides fallback is forwarded as a query parameter on the deferred
fetch:
<PostCommentsDeferred fallback={<Skeleton />} post_id={$postId} sort="new" />
This becomes GET /_defer/post-comments?post_id=123&sort=new. The wrapper's
inner closure receives them as $props['post_id']. Values must be scalar
(int/string/float/bool) — arrays, Elements, and Closures cannot cross
the URL boundary. All values arrive as strings on the server.
Client-side cache & forced reset
usephp.js puts a two-tier cache in front of every deferred fetch:
- L1 — in-memory
Map<URL, DocumentFragment>, per page lifetime. Always on; a full reload clears it. Identical to the previous behaviour. - L2 —
localStorage, surviving reloads and shared across tabs. Strictly opt-in, and the component decides —usephp.jsnever looks at the HTTPCache-Controlheader for this. A fragment is persisted only when the component setsDefer::$localCache = true; without it the fragment stays memory-only, so a session-coupled component (the default) can't leak one user's content to the next on a shared terminal. By default there is no time expiry — a persisted entry lives until aDEFER_CACHE_VERSIONbump orclearDeferCache()removes it. SetDefer::$localCacheTtl(seconds) to additionally bound it by age (see below). The endpoint'scacheControlis a separate concern (server/CDN caching) and is intentionally decoupled from this client decision.
Read order is L1 → L2 → network; L2 is consulted only for components that opted in. An L2 hit is promoted into L1. Both tiers are keyed by URL and bounded at 64 entries (L1 LRU, L2 oldest-first by insertion), so a list page that defers per-row fragments can't grow them without limit.
Make a deferred component client-cacheable by opting in — on the
#[Defer] attribute or the Defer value object:
// Class component #[Defer(name: 'announcement-bar', localCache: true)] // Closure component in a .psx fc($render, defer: new Defer(name: 'announcement-bar', localCache: true));
This renders <div data-usephp-defer-url="…" data-usephp-defer-cache>;
usephp.js keys off that bare attribute's presence (and nothing else) to
decide persistence. localCache and cacheControl are independent: you
can ship cacheControl: 'private, no-store' for the endpoint while still
allowing the client cache, or vice versa. Because there's no expiry by
default, invalidate via DEFER_CACHE_VERSION (deploy) or
clearDeferCache() (runtime).
Optional time bound — Defer::$localCacheTtl
If a fragment should simply go stale after N seconds, set
localCacheTtl (seconds) alongside localCache: true:
#[Defer(name: 'feed', localCache: true, localCacheTtl: 60)] fc($render, defer: new Defer(name: 'feed', localCache: true, localCacheTtl: 60));
This adds a separate data-usephp-defer-cache-ttl="60" attribute next
to the bare opt-in. Once the persisted entry is older than that, the next
read discards it and re-fetches from the network — the fallback shows
briefly, then the fresh fragment. It is a hard discard, not
stale-while-revalidate (no stale paint, no background swap). The bound
applies to the L2 localStorage tier only; L1 is per-page anyway.
Any localCacheTtl <= 0 (the 0 default included) means no time bound —
byte-identical markup and behaviour to a plain localCache: true. A
negative is normalised to 0 (read as "no bound", not an error), so only
a genuinely positive value ever takes effect. A separate attribute
(rather than a value on the bare opt-in) keeps that default, and every
non-opted-in placeholder, byte-identical to the pre-feature markup. A
positive TTL without localCache: true still throws (it would bound an
entry that's never written).
Forced reset is handled in JS:
// Runtime purge (e.g. after login/logout, or a "refresh" button): window.usePHP.clearDeferCache(); // both tiers, everything window.usePHP.clearDeferCache('post-comments'); // every variant of one defer name window.usePHP.clearDeferCache('/_defer/post-comments?post_id=1'); // one exact placeholder URL // Deploy-time invalidation: bump the constant in usephp.js. A mismatch // against the value persisted in localStorage wipes the whole namespace // before the first cache read, so old fragments can't survive a release. const DEFER_CACHE_VERSION = '1'; // → '2' on the next deploy window.usePHP.DEFER_CACHE_VERSION; // read the current build's value
Name matching is prefix-agnostic, so a custom setDeferPrefix('/api/_d')
still resolves. If localStorage is unavailable (Safari private mode, quota
exceeded, disabled) the L2 tier silently no-ops and L1 keeps serving the
page.
Explicit reload
By default a deferred fragment is fetched once: usephp.js replaces the
placeholder with the response and the wrapper is gone, so there is nothing
to re-target. Opt into Defer::$reloadable to keep a re-fetchable wrapper
in the DOM:
// Class component #[Defer(name: 'todo-list', reloadable: true)] // Closure component in a .psx fc($render, defer: new Defer(name: 'todo-list', reloadable: true));
This renders the placeholder with data-usephp-defer-name="todo-list".
usephp.js then swaps content inside that wrapper instead of replacing it
away, so the region can be re-fetched later. Every reload busts both
cache tiers for that URL first, so it always reflects current server
state. Three ways to trigger it, all over one core API:
// 1. Imperative — call from anywhere (no form required): window.usePHP.reloadDefer(); // every reloadable region window.usePHP.reloadDefer('todo-list'); // by defer name window.usePHP.reloadDefer('/_defer/todo-list?p=2'); // by exact URL // returns the number of regions reloaded (0 ⇒ nothing matched)
// 2. After a partial form submit — the canonical "form mutates data,
// reload the list" wiring. Fires only once the mutation response is
// applied, so the re-fetch sees the new state:
<form data-usephp-form data-usephp-reload-defer="todo-list"> … </form>
// 3. On click of any element outside a usephp form — a standalone
// Refresh button/link, a toolbar control, etc.:
<button data-usephp-reload-defer="todo-list">Refresh</button>
The attribute value is a space/comma-separated list of defer names (or
exact URLs); an empty value reloads every reloadable region. Reload is a
distinct concern from clearDeferCache() — the latter only invalidates,
it never re-fetches. Components that don't set reloadable are unchanged:
replaced away on resolve, byte-identical markup.
Requirements & limitations
- One component per mode. Use a base component for inline rendering and a wrapper carrying
Deferfor the deferred endpoint. Don't try to switch modes on the same component at the call site. - Defer names are URL-safe and unique. Pattern:
[A-Za-z0-9_-]+. Two wrappers attempting to register the same name fail at compile time. - Class-based defer targets are supported. Annotate the class with
#[Defer(name: '...')];register()auto-wires the endpoint. The class itself decides whatrender()produces in endpoint mode. - Params must be scalar. They travel through the URL query string, so
int/string/float/boolonly (bools are coerced to'1'/'0'). Arrays, Elements, Closures, and resources are rejected at render time. - Authorization is the component's responsibility. The name and params are visible in the URL — for endpoints that surface sensitive data, check session/permissions inside the component. (HMAC signing is no longer needed; the name is a public entry-point identifier.)
- Nested defer works. A deferred component's output may itself contain
<...Deferred />placeholders;usephp.jsrecursively hydrates them. - Two-tier defer cache (L1 in-memory + opt-in L2
localStorage). Component-decided persistence (Defer::$localCache, not the HTTPCache-Control; no time expiry by default, optionally bounded byDefer::$localCacheTtlseconds) and a JS forced-reset API — see Client-side cache & forced reset above. Components that don't opt in behave exactly like the previous in-memory-only cache. - Opt-in explicit reload (
Defer::$reloadable). Keeps a re-targetable wrapper so a deferred region can be re-fetched viawindow.usePHP.reloadDefer(), a form'sdata-usephp-reload-defer, or a click on any element — see Explicit reload above. Each reload busts both cache tiers for that URL first. Components that don't opt in are replaced away on resolve, byte-identical markup. - No-JS users see the fallback only. If JavaScript runs but the published
usephp.jsasset cannot be read,UsePHP::renderClientScript()includes a tiny inline fallback that still fetches deferred fragments once. The full asset is still required for partial form updates, defer caching, and explicit reload APIs. - Framework integration: call
UsePHP::handleDeferred()from your controller; it returns the rendered HTML forGET /_defer/...requests, ornullotherwise. MirrorshandleAction(). The prefix is configurable viasetDeferPrefix('/api/_d').
Generated HTML
H::button(onClick: fn() => $setCount($count + 1), children: '+')
Transforms to:
<form method="post" data-usephp-form style="display:inline;"> <input type="hidden" name="_usephp_component" value="counter#0" /> <input type="hidden" name="_usephp_action" value='{"type":"setState","payload":{"index":0,"value":1}}' /> <button type="submit">+</button> </form>
data-usephp-form- Form intercepted by JS- Works as a regular form submission without JS
CLI
./vendor/bin/usephp publish # Copy usephp.js to public/ ./vendor/bin/usephp compile components/ # Compile .psx files (see PSX section) ./vendor/bin/usephp help # Show help
Security
usePHP ships with several built-in defenses. The notes below cover the parts an application author still has to wire up correctly.
Snapshot HMAC secret (required for Snapshot storage)
SnapshotBehavior::Persistent|Session|Shared and components declared with #[Component(storage: 'snapshot')] round-trip state through the client, so the framework HMAC-signs every snapshot. You must configure a high-entropy secret before rendering a snapshot-using component or you'll get a LogicException at render time.
$app = new UsePHP(); $app->setSnapshotSecret(getenv('USEPHP_SNAPSHOT_SECRET'));
Generate the key once and load it from configuration:
// One-time generation — store the result, do NOT regenerate each request. echo bin2hex(random_bytes(32));
The key must be stable across requests and across worker processes (PHP-FPM, multi-server). If different workers have different keys, snapshots produced by one worker will fail verification on the next. Rotating the key invalidates every outstanding snapshot.
Never commit the secret to git. The placeholders used in examples/ ('your-secret-key-here', 'phase-1-demo-secret') are deliberately weak — replace them before deploying anything.
CSRF protection
UsePHP::run() and UsePHP::handleAction() enforce two layers on every POST:
- Origin / Referer same-origin check (always). A request with neither header, or with an origin that doesn't match the current
Host, is rejected with403 Forbidden. - Session-bound synchronizer token (when a session is active).
Renderer::renderWithFormembeds a per-session token as<input type="hidden" name="_usephp_csrf" value="...">;doHandleActionvalidates it withhash_equals.
The token is exposed via UsePHP::getCsrfToken() if you render forms yourself.
If the surrounding framework (Laravel VerifyCsrfToken, Symfony's CSRF component, etc.) already enforces CSRF on POST handlers, opt out of usePHP's check so the two layers don't double-validate:
$app = new UsePHP(); $app->disableCsrfProtection();
Behind a TLS-terminating proxy
When usePHP runs behind a reverse proxy that terminates TLS (nginx, an ALB, Cloudflare, etc.), $_SERVER['HTTPS'] is unset on the PHP-FPM side even though the browser sees https://. The expected origin would then be computed as http://... and every legitimate POST would fail the same-origin check with a 403.
You have two options:
- Pass the scheme into
$_SERVERbefore usePHP runs, e.g. in nginx:fastcgi_param HTTPS on; fastcgi_param HTTP_HOST $http_host;
- Opt into proxy-header trust — usePHP will honor
X-Forwarded-Proto,X-Forwarded-Host, andX-Forwarded-Port:$app->trustProxyHeaders();
Only enable this when every request reaches PHP through a proxy that you control and that strips or overwrites these headers from the client side. Otherwise an attacker can spoof them and bypass the origin check.
Session cookie hardening
usePHP uses $_SESSION for the Session and Shared snapshot behaviors and for its CSRF token. The library does not set cookie flags for you — configure them in php.ini or session_start() options:
session.cookie_httponly = 1 session.cookie_secure = 1 ; set when serving over HTTPS session.cookie_samesite = Lax ; or Strict
Call session_regenerate_id(true) after authentication to defeat session fixation.
Same-origin redirects
UsePHP::redirect($url) and SimpleRouter::createRedirectUrl() reject absolute URLs (https://...), protocol-relative URLs (//host/path), and scheme-prefixed paths (javascript:...). Pass only same-origin paths starting with /. If you need to redirect off-site, write the Location header yourself after running the target through your own allow-list.
URL-attribute XSS guard
href, src, action, formaction, srcdoc, data, poster, background, and xlink:href are URL-context attributes. The renderer drops any value whose scheme resolves to javascript:, vbscript:, or data: (after stripping leading whitespace and control characters, the way browsers parse URLs). Regular relative or absolute HTTP(S) URLs pass through unchanged.
PSX compilation cache
usephp compile writes generated PHP files into var/cache/psx/ and UsePHP::loadComponentManifest() requires them. The cache directory must be writable only by the build/deployment role, not by HTTP request handlers. Never call loadComponentManifest() with a request-derived path.
Reporting issues
If you find a security issue, please email the maintainer rather than opening a public issue.
Requirements
- PHP 8.5+
- Sessions enabled
Development
# Run tests ./vendor/bin/phpunit # Start example server php -S localhost:8000 examples/index.php
License
MIT