libxa / vite
Build a single-page app with LibxaFrame controllers and Vite components. No API, no client-side routes.
Requires
- php: ^8.3
- libxa/framework: ^0.11.2 || ^0.12.0 || ^0.13.0
- symfony/console: ^7.0
Requires (Dev)
- phpunit/phpunit: ^11.0 || ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Build a single-page app with LibxaFrame controllers and React components.
No API to design, no client-side route table to keep in step with the server's, no serialization layer. Your controller returns props; a React component renders them. Navigation swaps the page over fetch and updates history — the browser never reloads.
// src/routes/web.php $router->get('/books', [BookController::class, 'index']);
// src/app/Http/Controllers/BookController.php public function index() { return libxavite('Books/Index', [ 'books' => Book::all(), ]); }
// src/resources/js/pages/Books/Index.tsx export default function Index({ books }) { return <ul>{books.map((b) => <li key={b.id}>{b.title}</li>)}</ul>; }
That is the whole idea. Everything below is detail.
Install
composer require libxa/vite php libxa vite:install npm install react react-dom @vitejs/plugin-react vite --save-dev npm run dev
The client half is not on npm yet. Build it from a checkout of this repository and add it by path:
cd /path/to/libxavite/client && npm install && npm run build cd /path/to/your-app && npm install file:/path/to/libxavite/client
vite:install writes the entry point, a layout, two example pages, the root Blade view, vite.config.js, tsconfig.json and a middleware you can edit. It never overwrites an existing file unless you pass --force.
Two things are left to you. Register the middleware globally:
// src/bootstrap/app.php $app->make(HttpKernel::class)->pushMiddleware(App\Http\Middleware\HandleViteRequests::class);
And point a route at a page:
$router->get('/', fn () => libxavite('Home', ['message' => 'It works.']));
The helper is libxavite(), not vite() — the framework already has a vite() for asset tags, and you will use both in the same files.
Hot reload
Run both servers, then browse http://localhost:5173 — not the PHP port. Vite serves the modules and proxies everything else to PHP, so pages, modules, the HMR socket and your session cookie are all one origin.
php libxa serve # :8000 npm run dev # :5173
| You edit | What happens |
|---|---|
| a component | swapped in place, component state kept |
| a stylesheet | new styles applied, no reload |
| a controller, route or Blade view | the page reloads |
The third row is not free — Fast Refresh has no idea those files exist. vite.config.js watches src/app, src/routes and src/resources/views and sends a full reload when a .php file under them changes.
Two things make this work, and both are easy to lose:
@libxaviteReactRefreshin the root view, before@vite(...). The React plugin normally injects its Fast Refresh preamble by rewriting the HTML it serves — but this HTML comes from Blade, which the plugin never sees. Without the preamble every component throws "@vitejs/plugin-react can't detect preamble". The directive emits nothing in production, so leave it in.src/public/hot, written by the dev server while it is listening and deleted when it stops. It records the port Vite actually bound to, which is not always the configured one — Vite moves to the next free port silently. PHP reads it to decide between dev-server URLs and built files.
Browsing the PHP port directly also works — the asset tags are absolute — but you lose nothing by using the Vite port and it avoids the cross-origin question entirely.
Rendering pages
libxavite($component, $props) returns a full HTML document on a first visit and a JSON page object on every navigation after that. Nothing in your controller changes between the two.
return libxavite('Users/Show', [ 'user' => $user, 'canEdit' => $request->user()?->can('update', $user), ]);
Props can be closures. A closure is only called if the prop is actually going to be sent, which is what makes partial reloads worth using.
Shared data
Anything every page needs — the authenticated user, flash messages — goes in the middleware:
class HandleViteRequests extends \LibxaVite\Http\Middleware\HandleViteRequests { public function share(Request $request): array { return [ 'auth' => ['user' => fn () => auth()?->user()], 'flash' => Vite::always(fn () => session()->pull('flash')), ]; } }
Validation errors are merged in for you, whatever share() returns — a subclass that forgets parent::share() cannot silently break every form in the application.
Prop types
Four wrappers change when a prop is sent. All are static methods on LibxaVite\Vite.
| Sent on a full load | Sent on a partial reload | |
|---|---|---|
| plain value | yes | only if asked for |
Vite::optional(fn) |
no | only if asked for |
Vite::always(fn) |
yes | always, unless excluded |
Vite::defer(fn) |
no — announced, then fetched | only if asked for |
Vite::merge(fn) |
yes | appended, not replaced |
optional — expensive data a page usually does not need. The closure never runs unless the client asks by name.
always — flash messages, and anything else that must survive a partial reload. Without it, refreshing one prop would drop the "Saved." notice that arrived with it.
defer — render the page now, fetch this after. The client makes one follow-up request per group, so three slow props declared together cost one round trip:
'stats' => Vite::defer(fn () => $this->expensiveAggregate(), 'dashboard'), 'chart' => Vite::defer(fn () => $this->alsoSlow(), 'dashboard'),
merge — pagination and infinite scroll. Page two returns page two; the client keeps page one.
Client
Navigating
import { Link, router } from '@libxa/vite/react'; <Link href="/books">Books</Link> <Link href="/books/1" method="delete">Delete</Link> router.get('/books', { data: { q: term } }); router.post('/books', { title: 'Piranesi' });
Link stays a real anchor for GET, so middle-click, ctrl-click and "open in new tab" keep working. A non-GET link renders as a button instead — an anchor would be GET-ed by a stray middle-click, which for a delete link means the row disappearing.
Visit options: only, except, replace, preserveScroll, preserveState, headers, and the onStart / onSuccess / onError / onFinish callbacks.
Partial reloads
Ask for the props that changed and nothing else:
router.get('/books', { data: { q: term }, only: ['books'], replace: true, preserveState: true, });
Only the books closure runs on the server. Every other prop keeps the value the client already holds — including deferred ones, which are not recomputed.
The current page
import { usePage } from '@libxa/vite/react'; const { props, url, component } = usePage<{ auth: { user: User } }>();
Available anywhere in the tree, so a layout six levels down does not need shared data passed to it one component at a time.
Forms
const form = useForm({ email: '', password: '' }); <input value={form.data.email} onChange={(e) => form.setData('email', e.target.value)} /> {form.errors.email && <em>{form.errors.email}</em>} <button disabled={form.processing} onClick={() => form.post('/login')}>Sign in</button>
Errors arrive as page props from the controller's own validation. There is no schema on this side to fall out of step with the server's rules.
Also on the returned object: isDirty, recentlySuccessful, reset(...fields), clearErrors(...fields), setError, transform, setDefaults, cancel.
Layouts
Books.layout = Main; // one Books.layout = [Shell, Sidebar]; // nested, outermost first
A layout is a component taking children, and it is mounted once — an open menu, a scroll position, a playing video all survive navigation.
Titles
import { Head, setTitleTemplate } from '@libxa/vite/react'; setTitleTemplate((title) => `${title} — Acme`); <Head title="Books" />
Because navigation never reloads the document, nothing resets the head between pages. Head removes what it added when the page unmounts.
How it works
The client sends X-Libxa-Vite: true. The server answers with JSON instead of HTML:
{
"component": "Books/Index",
"props": { "books": [] },
"url": "/books",
"version": "e488ccf7"
}
Everything else falls out of that:
- First visit — no header, so the root Blade view renders with the page object in
data-page. The client reads it and mounts. - Redirects after PUT, PATCH or DELETE become 303. A 302 tells the browser to repeat the request at the new location with the same method, so a redirect after a DELETE becomes a DELETE of the page you redirected to.
- Version mismatch — the client sends the build it is running. If the server has since been redeployed, it answers 409 with
X-Libxa-Vite-Locationand the client does a real navigation, because the JavaScript it is running may not be able to render the new page. - External redirects use the same 409, since a fetch cannot follow a redirect off-origin and have the browser end up there.
- A response without the header — a login page from an auth layer, an error page — triggers a hard navigation. The client cannot render it, and swapping in nothing leaves a blank screen.
Configuration
php libxa vendor:publish --tag=libxavite-config
root_view, pages directory, version, encrypt_history, and the SSR block.
The version defaults to a hash of the built asset manifest, which is almost always what you want: it changes exactly when the client's JavaScript changes.
Stacks
React is supported and tested. Vue, Svelte, Preact and Solid adapters are next; vite:install --stack=<name> will name what exists rather than scaffold something that cannot start.
The router is framework-agnostic — it knows the protocol, history and the DOM, and nothing about components. An adapter is one file.
Testing
composer test # 61 tests: the protocol, the page object, the scaffolder, the dev server cd client && npm test # 42 tests: the router and the React adapter
License
MIT.