Inertia Starter
solophp/inertia-svelte-skeleton builds a single-page application the way you would build a server-rendered one. Routing, controllers, validation and the database stay on the server; the pages are Svelte 5 components, and Inertia.js 3 moves between them without full page loads. There is no API to design: a controller renders a page with its props, or redirects.
It is built on the same packages as the JSON API starter, with solophp/inertia as the server side of Inertia.
Requirements
- PHP 8.4 or later, with
pdo_sqlitefor the default database, orpdo_mysql/pdo_pgsql - Composer
- Node.js 20.19+, 22.12+ or 24+, for Vite
Create the Project
composer create-project solophp/inertia-svelte-skeleton my-app
cd my-app
npm installcreate-project copies .env.example to .env, fills in APP_KEY and runs the migrations. Then start PHP and Vite, each in its own terminal:
composer serve # http://127.0.0.1:8000npm run dev # the Vite dev server, with hot module replacementOpen http://127.0.0.1:8000. While npm run dev runs, the pages load their scripts from Vite; without it, from the last npm run build.
What You Get
One module, a task list, shows the parts of Inertia working together:
- Forms that check as you type. A field is validated by the server's own rules while the user types (Precognition), and a form that fails on submit gets its errors back after the redirect.
- Redirects with messages. A saved form redirects, and the layout shows the message from the next page's flash data.
- Partial reloads. The search and the filter reload only the list, not the whole page.
- Deferred and once props. The statistics load right after the page, in a request of their own, and the navigation is sent once and kept by the client.
- Security without a session. Forms are checked for an
XSRF-TOKEN, and errors and messages cross the redirect in a cookie signed withAPP_KEY.
| Package | Role here |
|---|---|
| solophp/inertia | Pages, redirects with errors and messages, deferred and once props, Precognition, the Inertia protocol |
| @inertiajs/svelte, Svelte 5, Vite | The client: pages, links, forms and partial reloads; the dev server and the build |
| solophp/application, solophp/router, solophp/container | The PSR-15 kernel, routes and the URLs made from their names, the container |
| solophp/input, solophp/validator | Typed form inputs and the rules behind #[Validate] |
| solophp/repository | The tasks table |
| solophp/logger, solophp/dotenv, solophp/http-emitter | Logging, .env, sending the response |
Next to the layout of the JSON API starter, the project has resources/ for the front end: views/app.php is the HTML document of a first visit, js/app.js the client's entry point, js/Layouts/AppLayout.svelte the layout of every page, and js/Pages/ one Svelte component per page.
How a Page Is Served
- The first visit is a plain browser request. The controller renders a page, such as
Tasks/Indexwith its props, and the answer isresources/views/app.phpwith the page object in a script element. - The client mounts
resources/js/Pages/Tasks/Index.sveltewith those props. From then on, a link or a form is an XHR with anX-Inertia: trueheader, and the answer is the next page object as JSON. - A form posts JSON. The controller reads it into an input class: invalid input throws, and
ErrorMiddlewareredirects back to the form with the messages in itserrorsprop. Valid input is saved, and the controller redirects with a message. InertiaMiddlewarekeeps the protocol for every response: a client built from older assets gets a409and reloads the page, and a redirect after aPATCHorDELETEbecomes a303, so the browser follows it with a GET.
Add a Page
An About page takes a route, a controller, a Svelte component and a test.
Add the route to config/routes.php:
use App\About\AboutController;
use App\Home\HomeController;
use App\Task\TaskRoutes;
use Solo\Router\RouteCollector;
return function (RouteCollector $r): void {
$r->get('/', HomeController::class)->name('home');
$r->get('/about', AboutController::class)->name('about');
(new TaskRoutes())->register($r);
};The controller renders the About component with its props. A page gets every URL it needs as a prop, made from a route name. Create src/About/AboutController.php:
<?php
declare(strict_types=1);
namespace App\About;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Solo\Inertia\Inertia;
use Solo\Router\UrlGenerator;
final readonly class AboutController
{
public function __construct(
private Inertia $inertia,
private UrlGenerator $urls,
) {
}
public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
return $this->inertia->render($request, $response, 'About', [
'php' => PHP_VERSION,
'tasksUrl' => $this->urls->generate('tasks.index'),
]);
}
}The component takes the props with $props(). Create resources/js/Pages/About.svelte:
<script>
import { Link } from '@inertiajs/svelte'
let { php, tasksUrl } = $props()
</script>
<svelte:head>
<title>About</title>
</svelte:head>
<h1>About</h1>
<p>This page is a Svelte component, rendered by AboutController on PHP {php}.</p>
<Link href={tasksUrl}>Back to the tasks</Link>The tests send requests as the browser and the client do. inertia() makes an Inertia visit, page() reads the page object from the answer, and props() its props. Create tests/Feature/AboutTest.php:
<?php
declare(strict_types=1);
namespace Tests\Feature;
use Tests\TestCase;
final class AboutTest extends TestCase
{
public function testAboutPage(): void
{
$page = $this->page($this->inertia('GET', '/about'));
self::assertSame('About', $page['component']);
self::assertSame('/tasks', self::props($page)['tasksUrl']);
}
}Open http://127.0.0.1:8000/about, or run the checks below. To show the page in the navigation, add a link to nav in src/Http/Middleware/SharedPropsMiddleware.php, and to the links that HomeTest expects.
Forms
A form needs an input class with #[Validate] rules and a handler that redirects; when the input is invalid, the errors find their way back to the form by themselves. The task form's input is src/Task/Inputs/TaskStoreInput.php:
#[Input]
final readonly class TaskStoreInput
{
public function __construct(
#[Validate('required|string|min:3|max:255')]
public string $title,
#[Validate('boolean')]
public bool $done = false,
) {
}
}To validate a field while the user types, the route names its input class and runs under PrecognitionMiddleware, as in src/Task/TaskRoutes.php. The middleware answers a precognitive request with a 204 or a 422 for the fields it asks about, and never runs the handler:
$r->group('/tasks', function (RouteCollector $r): void {
$r->post('', [TaskController::class, 'store'])
->name('tasks.store')
->attribute('input', TaskStoreInput::class);
// ...
}, middlewares: [PrecognitionMiddleware::class]);On the client, a form made with a method and a URL is precognitive, as in resources/js/Pages/Tasks/Index.svelte:
<script>
import { useForm } from '@inertiajs/svelte'
let { urls } = $props()
const form = useForm('post', () => urls.store, { title: '' }).setValidationTimeout(400)
</script>
<input bind:value={form.title} oninput={() => form.validate('title')}>
{#if form.errors.title}
<p class="field-error">{form.errors.title}</p>
{/if}Tests and Build
composer check # PSR-12, PHPStan at level max, then PHPUnit
npm run build # the production build, into public/buildThe tests do not need a build: they use a manifest of their own in tests/Fixtures.
Deploy
- Run
npm run buildand deploypublic/buildwith the code. - Set
APP_ENV=production,APP_DEBUG=falseand anAPP_KEYof your own;composer keymakes one. - Point the web server's document root to
public/and send every request for a file that does not exist topublic/index.php. - Delete
storage/cache/routes.phpon each deploy. The next request builds it again. - Make
storage/writable by PHP.
Inertia Without the Starter
solophp/inertia works in any PSR-7 and PSR-15 application; the starter is one way to wire it. Its documentation covers the installation, every kind of prop, flash data, history encryption, server-side rendering and Precognition, and an upgrade guide from 1.x.