UNPKG

@rsc-kit/mcp

Version:

An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.

543 lines (435 loc) • 22.6 kB
# Laravel > React Server Components in front, a Laravel application behind them. ## The model A [BAP — Backend-Answered Pages](/hosts/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: ```tsx title="resources/js/app/orders/page.tsx" 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](/hosts/backend-answered-pages#what-the-backend-is-then). ## Install ```sh composer require rsc-kit/laravel php artisan rsc:install ``` `rsc: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`. ```ts title="vite.config.ts" 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 ```json title="package.json" { "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 ```sh npm run dev ``` Then 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. :::caution[`php artisan serve` needs workers] `artisan serve` is `php -S`: one worker by default, which cannot answer a second request while it is blocked on the first. When Laravel proxies a page it holds that worker for the whole render, and the renderer calls back to the same server for the page's data — with nobody left to answer. The call would time out after 30 seconds into a page that answers **200** with its data missing, because a failed host call is reported inside its Suspense boundary. The package refuses that shape up front instead. Give it workers and the refusal stands down: ```ini title=".env" PHP_CLI_SERVER_WORKERS=4 ``` ```sh php artisan serve --no-reload ``` `--no-reload` is Laravel's rule, not this package's: without it `serve` warns that it cannot respect the variable and starts one worker anyway. Herd, Valet, PHP-FPM and Octane run several without being asked. Or skip the proxy: open the renderer's origin directly and let Laravel answer host calls only, one short request each. ::: ## 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. ```php title="app/Rsc/Orders.php" 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(); } } ``` ```tsx title="resources/js/app/orders/page.tsx" 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: ```php public function find(int $id, ?FindOptions $options = null): OrderData ``` ```tsx const order = await rpc('Orders.find', 7) // OrderData; rpc('Orders.find', 'seven') fails the typecheck ``` - **Parameters** are positional, as the call is made: `int`, `float`, `string` and `bool` are 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`, `boolean` and `array` as such, the rest as strings, `required` ones required. - **A result** is typed when PHP can say what `json_encode` will 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, anything `JsonSerializable`, 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: ```php 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: ```sh 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. ```php title="app/Rsc/Actions/Orders.php" namespace App\Rsc\Actions; use RscKit\Rsc; class Orders { #[Authenticated] public function cancel(CancelOrder $request): void { $request->order()->cancel(); Rsc::revalidate('orders'); } } ``` ```tsx title="resources/js/app/orders/CancelButton.tsx" '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](/guides/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: ```tsx title="src/app/t/[team]/repos.section.tsx" export default section('repos', Repos, { refreshOn: ({ params }) => [`team:${params.team}:repos`] }) ``` ```php // 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. Set `RSC_SIGNING_SECRET` for the renderer, the same on every instance: it signs the names a page hands the tab, and is not the host-call secret. Generating and rotating both: [Secrets](/hosts/deployment#secrets). **What watching costs PHP.** The renderer asks Laravel which versions moved once for every tab it serves, not once per tab: one request about every two seconds per renderer process while any tab is watching, each a cache read. Three renderers are one or two small requests a second for the whole fleet. That is the default and the recommendation - Laravel stays the only thing that talks to its database. **Keeping versions in a table** (`RSC_VERSIONS=database`) is for when you want them in the database rather than the cache: pruned on a schedule with `rsc:prune-versions`, or written by something outside Laravel - a Go service, a worker in another language - through the [shared table](/guides/live-data#the-table). Laravel still answers the renderer from it; nothing else changes. ```sh php artisan vendor:publish --tag=rsc-migrations && php artisan migrate RSC_VERSIONS=database ``` **Instant, with broadcasting.** If the app has Laravel's broadcasting set up (`php artisan install:broadcasting`) with a server that speaks the Pusher protocol - Reverb, Pusher, Soketi - each `Rsc::changed()` can announce itself, and the renderer asks the moment it hears: ```sh RSC_BROADCAST=true # Laravel: announce every change RSC_BROADCAST_URL=wss://ws.example.com # the renderer: where to listen RSC_BROADCAST_KEY=your-app-key # the renderer: the server's app key ``` A change then reaches the tabs at once, and between changes the renderer asks only every thirty seconds, as a safety net. The announcement carries nothing - the channel is public, and names may be one visitor's - so Laravel still answers which names moved, and the renderer never touches the database. It is sent after the transaction commits. ## 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: ```ts title="resources/js/app/admin/middleware.ts" 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 ```sh composer install --no-dev --optimize-autoloader npm ci && npm run build ``` The 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: ```ini title="/etc/systemd/system/rsc-renderer.service" [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=always ``` ### Which 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. ```nginx 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](https://laravel.com/docs/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](/guides/ppr). --- The package is `rsc-kit/laravel` on Packagist, source and issues at [rsc-kit/laravel](https://github.com/rsc-kit/laravel). A backend in another language answers the same endpoint — [Go](/hosts/go) does — and the contract is [Your own backend →](/hosts/your-own-backend)