The model
A BAP — Backend-Answered Pages: the page is
rendered in front of Laravel rather than by it. The renderer — what vite dev
serves, and what Nitro builds into .output/server — is the front door; it
routes, renders and serves the frozen pages and the assets. Laravel answers
what only Laravel can — the data, the session, whether a route may render —
on one private endpoint, and keeps every route of its own: /login, a Blade
page, a webhook, a file under /storage are forwarded to it as they are.
A page reads from PHP the way a SPA would have fetched, except it is a server component, so there is no loading state to write and nothing ships to the browser:
export default async function Orders() {
const orders = await rpc<Order[]>('Orders.recent', 5) // → App\Rsc\Orders::recent(5)
return <ul>{orders.map((o) => <li key={o.id}>{o.number}</li>)}</ul>
}The call runs as the visitor — their cookie travels with it, so auth()->user()
inside Orders::recent is them. Laravel stops being the app that serves pages
and becomes the app that answers them; what that means for what stays in
Laravel is on the BAP page.
Install
composer require rsc-kit/laravel
php artisan rsc:installrsc:install does the PHP half — publishes config/rsc.php, generates
RSC_HOST_CALL_SECRET into .env — and runs rsc-kit init for the
JavaScript half. Nothing you already have is overwritten: where a file exists,
the exact edit is printed for you to make instead.
What lands:
config/rsc.php |
the settings, published |
.env |
RSC_HOST_CALL_SECRET, generated once |
vite.config.ts |
the renderer’s config, with rscKit() in it |
resources/js/app/ |
a root layout and a page |
package.json |
dev, build and start, and laravel-vite-plugin removed |
Requirements: PHP 8.3 and Laravel 13; Bun or Node 24 for the renderer; Vite 8, which the plugin needs and a Laravel application does not ship — the installer reports the version it found rather than upgrading it for you.
One Vite config
A Laravel application arrives with a vite.config.js that
laravel-vite-plugin owns: it sets the base, the public directory, the
output directory, the input list and the dev server’s origin. So does the
renderer’s build, and whichever plugin runs second wins.
There used to be a second file for that. There is not now, because once the
renderer owns the frontend there is nothing left for laravel-vite-plugin to
do — no @vite directive, no public/hot, no Blade asset pipeline. init
moves the stock config aside as vite.config.blade.js, writes its own
vite.config.ts, and drops the plugin from package.json. If something of
yours still needs the Blade pipeline, run it from the moved file:
vite --config vite.config.blade.js.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { rscKit } from '@rsc-kit/core/vite'
import { nitro } from 'nitro/vite'
export default defineConfig({
plugins: [
nitro({ preset: 'bun', serveStatic: 'inline' }),
rscKit({
sourceDir: 'resources/js',
outDir: 'bootstrap/rsc/vite',
hotFile: 'public/rsc-hot',
hostManifest: { command: ['php', 'artisan', 'rsc:host-manifest'] },
}),
react(),
],
})outDir is under bootstrap/ because that is already where a Laravel
application keeps generated code. hotFile is how Laravel finds a running
dev server — read below.
The scripts
{
"scripts": {
"dev": "vite",
"build": "vite build",
"start": "bun .output/server/index.mjs"
}
}vite.config.ts runs php artisan rsc:host-manifest as dev and every build
start (hostManifest). Your server actions are found by reflection through
Composer’s autoloader — the only thing that sees what a class inherits from
its parents and traits — so PHP writes them to rsc-host.json and the build
reads it. It lists every callable too, which types rpc()’s first argument.
Part of starting Vite rather than a step to remember: a stale manifest names a
method that has since been renamed, and nothing fails until the browser calls
it.
If you had customised dev or build yourself, init leaves them alone and
puts the renderer’s at rsc:dev and rsc:build, and says so.
Development
npm run devThen open the application at its own address — my-app.test, whatever you
already use. Vite is the renderer in development. It writes
public/rsc-hot while it runs, Laravel reads that file, and any request
Laravel does not route is handed through to the address inside it. Stop the
dev server and the file goes with it, and so does the proxy.
Per url the rule is: if the React tree has it, React renders it;
otherwise Laravel does. That holds for a url Laravel also routes — a fresh
application ships Route::get('/', …) to its welcome page, and after
install / is resources/js/app/page.tsx, not the welcome page. The build
writes its route table to bootstrap/rsc/vite/routes.json on every vite
and vite build, and the package registers those urls after routes/web.php
loads, so a page added to the tree is routed on the next request. The
welcome route can stay or go; it answers nothing while the page exists.
Until the first vite there is no table, and Laravel answers everything.
The other direction works too. The renderer reads APP_URL and
RSC_HOST_CALL_SECRET from the app’s own .env, and a url the route tree does
not own — /login, a Blade page, a webhook, a file under /storage — is
forwarded to Laravel with X-Forwarded-Host set. So the renderer’s own origin
is the whole application as well, not the RSC half of it, and a browser can
sit on either. The two proxies cannot loop: each marks what it forwards, and a
url neither side owns is a 404 from whichever saw it second.
Calling PHP
A class under app/Rsc/ is discovered by convention. Its public methods are
what rpc() can reach, named Class.method; a class with __invoke is
reached by its class name alone.
namespace App\Rsc;
class Orders
{
public function __construct(private OrderRepository $orders) {}
public function recent(int $limit = 5): array
{
return $this->orders->forUser(auth()->user())->latest()->take($limit)->get()->all();
}
}export default async function Orders() {
const orders = await rpc<Order[]>('Orders.recent', 5)
return <ul>{orders.map((o) => <li key={o.id}>{o.number}</li>)}</ul>
}The class is resolved through the container, so constructor injection works.
auth()->user() is the visitor: the renderer forwards their Cookie header on
every call, EncryptCookies and StartSession run on the endpoint, and the
session bound is theirs.
rpc() is a global the renderer installs, declared for the typechecker in
.rsc-kit/rsc-env.d.ts. It exists in server components during a render and
nowhere else — a client component reaches PHP through a server action.
Typed from the PHP signature
rsc:host-manifest writes what each method takes and returns, so the call
is typed without a type argument:
public function find(int $id, ?FindOptions $options = null): OrderDataconst order = await rpc('Orders.find', 7) // OrderData; rpc('Orders.find', 'seven') fails the typecheck- Parameters are positional, as the call is made:
int,float,stringandboolare typed, a nullable one adds| null, one with a default may be left out, and a variadic one takes the rest. - A form request in front is the call’s one argument, typed from its
rules():integer,numeric,booleanandarrayas such, the rest as strings,requiredones required. - A result is typed when PHP can say what
json_encodewill write: a scalar, a backed enum (its values), or a plain class with public typed properties — a data object — which becomes an interface named for it. - Left open as
unknown:array, a model, a collection, anythingJsonSerializable, and an untyped parameter. Their shape is decided at runtime, so say it at the call:rpc<Order[]>('Orders.recent').
Action stubs are typed the same way, and accept the posting FormData too.
Refusing
Attributes on the class or the method, and the refusal travels as itself rather than as a broken page:
use RscKit\Attributes\Authenticated;
use RscKit\Attributes\Can;
use Illuminate\Routing\Attributes\Controllers\Middleware;
#[Authenticated]
#[Middleware('throttle:60,1')]
class Orders
{
#[Can('update', Order::class)]
public function cancel(int $id): void { … }
}| thrown in PHP | reaches the render as |
|---|---|
AuthenticationException |
the engine’s ServerAuthenticationError: the request answers 401 |
AuthorizationException |
its ServerAuthorizationError: 403 |
ValidationException |
a validationErrors map, field to messages, on the form that submitted |
a middleware abort() |
its own status: throttle’s 429 stays a 429 |
RscRedirectException |
a redirect the browser performs |
A form request type-hinted on a method is resolved and validated before the
method runs, which is where ValidationException usually comes from.
Anything else thrown is a failure: reported to Laravel’s log, and answered
Server Error. With app.debug on, the answer also carries the exception’s
class, message and trace, and the renderer shows them under its own stack as
the error’s cause - so a failed rpc() in development points at the line of
PHP that threw.
Server actions
A class under app/Rsc/Actions/ is a server action. rsc:host-manifest
writes it to the manifest, and the build writes a "use server" module beside your pages
exporting one function per method, named classMethod.
Make one with its guards already on it:
php artisan make:rsc-action Orders --method=cancel --auth --can=update,Order --revalidate=orders--method per call (none makes the class invokable, reached as orders),
--auth for #[Authenticated], --can=ability or --can=ability,Model for
#[Can], --middleware=throttle:60,1 for #[Middleware], --revalidate
for the Rsc::revalidate() line, and --rpc to make a class for rpc()
under app/Rsc instead. A slash nests: Billing/Invoices, discovered like
any other. The attributes it writes are the ones the registry reads, so what
you asked for at the prompt is what runs.
It writes the manifest as well, and a dev server that is running starts again
when the manifest changes — so the export is there to import the moment the
command returns, with nothing to restart by hand. A class you write yourself
is picked up the next time rsc:host-manifest runs, which starting Vite
does.
namespace App\Rsc\Actions;
use RscKit\Rsc;
class Orders
{
#[Authenticated]
public function cancel(CancelOrder $request): void
{
$request->order()->cancel();
Rsc::revalidate('orders');
}
}'use client'
import { ordersCancel } from '../../server-actions.generated'
export function CancelButton({ id }: { id: number }) {
return <button onClick={() => ordersCancel(id)}>Cancel</button>
}Rsc::revalidate('orders') says what the action made stale. The names ride
back with the result, and the answer to the action carries the re-rendered
region with it rather than the browser being told to ask again — the same
thing revalidate() does from a JavaScript action, described in
Sections.
Saying data changed
Rsc::revalidate() is for the action that made the change. A webhook, a
job, a listener, another user - none of those is in the tab showing the
data. For those a section names what it depends on, and PHP says when that
changed, from anywhere:
export default section('repos', Repos, { refreshOn: ({ params }) => [`team:${params.team}:repos`] })// a webhook controller, a job's handle(), a listener
Rsc::changed("team:$teamId:repos");Every open tab showing the section refreshes it, with nothing polling. The
versions live in the cache - the store every server already shares, or the
one rsc.tags_store names; a store that is per server, like file, is one
server’s. PHP answers the renderer’s ask at once rather than holding it, so
a change reaches the tab on the renderer’s next ask, a couple of seconds
later. Sections has the
whole picture.
Route middleware
The renderer owns the route table, but Laravel still decides whether a route
may render. A middleware.ts beside or above a page names middleware in Laravel’s
own vocabulary:
export const middleware = ['auth', 'verified', 'can:update,post']Before anything at or below that directory renders, the names are sent to
PHP and run through the real pipeline against the real request. It fails
closed: anything that is not a literal true is a refusal, so a middleware
that aborts, redirects or simply errors keeps the page from rendering rather
than being read as silence. A page frozen at build time is asked too, before
the file is served — a guard that held until the build froze the page would
otherwise stop holding, silently.
Production
composer install --no-dev --optimize-autoloader
npm ci && npm run buildThe build runs rsc:host-manifest, so PHP has to boot on the build
machine. It writes .output/ — the server, the engine, the frozen pages and
the assets — which travels with the deployment; nothing in it is read from the
source tree at runtime.
Both processes read RSC_HOST_CALL_SECRET, and the renderer also needs
APP_URL (or RSC_BACKEND) to know where Laravel is. Run it with the app’s
.env and it has both:
[Service]
User=www-data
WorkingDirectory=/var/www/app
EnvironmentFile=/var/www/app/.env
ExecStart=/usr/local/bin/bun /var/www/app/.output/server/index.mjs
Restart=alwaysWhich process faces the internet
The renderer, unless you have a reason. It serves assets and frozen pages straight off disk, renders the rest, forwards what it does not own to Laravel, and holds a PHP worker for the length of a host call — a query, a policy check — never a whole render.
Where it forwards to is decided at build time: vite build reads APP_URL
(or RSC_BACKEND) from .env and bakes it in, while host calls read the same
names from the process at runtime. Building on a machine whose .env names a
different backend than production’s means setting RSC_BACKEND for the
build.
server {
server_name example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_buffering off; # Suspense boundaries stream; buffering holds them to the end
}
# The host-call endpoint is PHP's, and must not be reachable from outside.
location /__rsc/host-call {
allow 127.0.0.1;
deny all;
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
}Laravel needs the renderer in trustProxies for url(), route() and its
redirects to come out against the public origin rather than its own.
Trusting it means trusting what it forwards, so the renderer drops any
X-Forwarded-For, Forwarded or X-Real-IP the browser sent: otherwise a
visitor would pick their own address for Laravel’s throttles. The renderer
cannot see the peer address to put in its place, so Laravel sees the
renderer’s. If the renderer sits behind a proxy of its own that writes those
headers (a load balancer, Cloudflare), set RSC_TRUST_FORWARDED=1 on the
renderer and they are passed through.
Setting RSC_RENDERER_URL puts Laravel in front instead: it proxies anything
it does not route, which is what development does. In production it has the
cost the caution above describes, at scale — a worker is held for the whole
render, the render calls back for data, and with W workers that caps you at
W − 1 concurrent renders, deadlocking when every worker is busy proxying.
If you want it anyway, give /__rsc/host-call its own PHP-FPM pool so a
proxying worker can never starve a data worker.
What a host call costs
The transport is loopback, well under a millisecond. The cost is Laravel
handling a request: under PHP-FPM every call boots the framework, under
Octane it stays booted and a call is
closer to a millisecond. What matters is how many sequential calls a page
needs. A page with no middleware.ts middleware makes no guard call; sibling
components awaiting rpc() are rendered concurrently, so their calls
overlap — and calls issued in the same tick travel as one request, a
batch the package answers in one Laravel request, one line per call as
each finishes, so a fast read is not held behind a slow one; cache() dedupes
identical calls within a request; a frozen page makes none at all and a
shell only for its holes. A guarded page is therefore typically two Laravel
requests — the guard, then the batch of its reads — and a host-call-heavy app
is better served by Octane, where each is a millisecond rather than a boot.
Why there is a secret
Inertia runs a second process too, and posts to it with no secret at all. The difference is direction. Inertia’s SSR server receives: Laravel has already resolved the data and hands it over. This one is asked: the renderer has no data, so it calls back and runs a named function in your application, against your database, under the visitor’s session — and that function has to be a Laravel route, because a Laravel route is the only thing that has your session, your container and your models. It is on the same public surface as the rest of the site.
So without a secret the endpoint is not registered at all — absent, not open.
The renderer presents it in X-Rsc-Host-Secret; a mismatch is 403 before
anything is dispatched. The visitor’s cookie also travels, and the two answer
different questions: the cookie says who the page is for, the secret says who
is asking. Neither substitutes for the other, which is also why the endpoint
carries no CSRF check — a browser can be tricked into sending cookies, never
into sending a header it does not know.
Restrict it at the web server as well, as above. The secret is the layer this package can guarantee; the network is the one it cannot.
Build-time
The prerender probe replaces rpc() with a promise that never settles, so a
component awaiting PHP suspends by construction and the page is classified as
a shell or dynamic — never frozen with yesterday’s rows baked in. That is a
stronger guarantee than a pure JavaScript app gets. See
Partial prerendering.
The package is rsc-kit/laravel on Packagist, source and issues at
rsc-kit/laravel. A backend in another
language answers the same endpoint — Go does — and the contract
is Your own backend →