trk/processwire-inertia
| Install | |
|---|---|
composer require trk/processwire-inertia |
|
| Latest Version: | v0.1.1 |
| PHP: | >=8.4 |
| License: | MIT |
| Last Updated: | Nov 9, 2026 |
| Links: | GitHub · Packagist |
ProcessWire Inertia (Totoglu\Inertia)
Inertia.js v3 server-side adapter for ProcessWire >= 3.0.271 and PHP >= 8.4.
Build Vue 3, React, or Svelte applications on top of ProcessWire's native routing, Custom Page Classes (site/classes/), templates, fields, and permissions. Requires only ProcessWire core and Vite (trk/processwire-vite).
Requirements & Installation
- PHP:
>= 8.4 - ProcessWire:
>= 3.0.271 - Required Module:
Vite(trk/processwire-vite)
composer require trk/processwire-inertia
php vendor/bin/wire module:install Inertia --json
To scaffold the Root View (site/templates/_inertia.php), Vite entry (site/templates/assets/js/inertia.ts), starter component, Custom Page Class, and TypeScript definitions (inertia.d.ts):
php vendor/bin/wire inertia:install --stack=vue --template=home --json
1. Template Lifecycle & Activation Modes
In a classic ProcessWire site (site/config.php), requests run _init.php → {template}.php → _main.php (with Markup Regions).
Inertia replaces server-side layout merging (_main.php) with two response modes handled automatically during Page::render:
- Initial Full-Page Visit: Renders the minimal HTML shell (
site/templates/_inertia.php) containing<head>tags,vite()assets, and$inertia->app($pageObject)(<div id="app"></div>+<script type="application/json" data-page="app">{...}</script>). - Subsequent XHR Visit (
X-Inertia: true): Skips HTML rendering and returns the JSON Page Object ({ component, props, url, version, ... }) withX-Inertia: trueandVary: X-Inertiaheaders.
For every Inertia-enabled template, the module sets $template->noAppendTemplateFile = true at runtime so _main.php is skipped without mutating $config->useMarkupRegions.
Hybrid Mode (Mix Classic & Inertia Templates)
Keep your existing site/config.php untouched for classic templates and enable Inertia only on selected templates:
<?php
declare(strict_types=1);
namespace ProcessWire;
// In site/init.php or site/ready.php (or configure in Modules -> Site -> Inertia):
inertia()->forTemplates(['home', 'articles', 'article']);
Full SPA Mode (All Frontend Templates)
When every frontend template renders through Inertia:
<?php
declare(strict_types=1);
namespace ProcessWire;
// In site/config.php:
$config->useMarkupRegions = false;
$config->appendTemplateFile = '';
// In site/init.php or site/ready.php:
inertia()->forAllTemplates();
2. Custom Page Class Architecture (InteractWithInertia)
Enabling $config->usePageClasses = true in site/config.php lets you place domain queries, default props (inertiaWithData()), shared props (inertiaShared()), and Root View metadata (inertiaViewData()) directly on your Custom Page Classes in site/classes/, keeping site/templates/{template}.php controllers to a single line.
Step 1: Base DefaultPage (site/classes/DefaultPage.php)
Create a base page class using Totoglu\Inertia\InteractWithInertia to centralize <html lang>, <title>, <meta> tags, and the base 'page' => $this prop:
<?php
declare(strict_types=1);
namespace ProcessWire;
use Totoglu\Inertia\InteractWithInertia;
/**
* @property string $title
*/
class DefaultPage extends Page
{
use InteractWithInertia;
public function locale(): string
{
$wire = $this->wire();
$languages = $wire->languages;
$user = $wire->user;
if ($languages instanceof Languages && $user instanceof User && $user->language instanceof Language) {
$code = trim((string) $user->language->name);
if ($code !== '' && $code !== 'default') {
return $code;
}
}
return 'en';
}
public function browserTitle(): string
{
return (string) $this->title;
}
public function metaDescription(): string
{
return (string) $this->get('summary');
}
/**
* Default component props merged automatically before explicit inertiaRender() props.
*
* @return array<string, mixed>
*/
public function inertiaWithData(): array
{
return [
'page' => $this,
'browserTitle' => $this->browserTitle(),
];
}
/**
* Server-only PHP variables ($locale, $head) passed to site/templates/_inertia.php.
*
* @param array<string, mixed> $pageObject
* @return array<string, mixed>
*/
public function inertiaViewData(array $pageObject = []): array
{
$sanitizer = $this->wire()->sanitizer;
$props = isset($pageObject['props']) && is_array($pageObject['props'])
? $pageObject['props']
: [];
$resolvedTitle = isset($props['browserTitle']) && is_string($props['browserTitle']) && $props['browserTitle'] !== ''
? $props['browserTitle']
: $this->browserTitle();
$safeTitle = $sanitizer->entities($resolvedTitle);
$safeDesc = $sanitizer->entities($this->metaDescription());
$safeUrl = $sanitizer->entities((string) $this->httpUrl);
return [
'locale' => $this->locale(),
'head' => [
"<title>{$safeTitle}</title>",
"<meta name=\"description\" content=\"{$safeDesc}\">",
"<meta property=\"og:title\" content=\"{$safeTitle}\">",
"<meta property=\"og:url\" content=\"{$safeUrl}\">",
],
];
}
}
Step 2: Template Page Class (site/classes/ArticlePage.php)
Extend DefaultPage for each template to encapsulate ProcessWire selector queries and template-specific props:
<?php
declare(strict_types=1);
namespace ProcessWire;
/**
* Custom Page Class for the `article` template.
*
* @property string $title
* @property string $summary
* @property string $body
* @property Pageimages $images
*/
class ArticlePage extends DefaultPage
{
public function browserTitle(): string
{
return "{$this->title} — Journal";
}
/**
* @return list<array<string, mixed>>
*/
public function relatedArticles(int $limit = 4): array
{
$providers = $this->inertia()->providers();
$items = [];
foreach ($this->wire()->pages->find("template=article, id!={$this->id}, sort=-created, limit={$limit}") as $item) {
if ($item instanceof Page) {
$items[] = $providers->providePage($item);
}
}
return $items;
}
/**
* @return array<string, mixed>
*/
public function inertiaWithData(): array
{
return [
...parent::inertiaWithData(),
'relatedArticles' => $this->inertia()->defer(fn(): array => $this->relatedArticles()),
];
}
}
Step 3: Thin Template Controller (site/templates/article.php)
In the template file, call $page->inertiaRender() to bind $page and render the frontend component:
<?php
declare(strict_types=1);
namespace ProcessWire;
/** @var ArticlePage $page */
return $page->inertiaRender('Articles/Show');
Any extra props passed to $page->inertiaRender('Articles/Show', ['extra' => 123]) (or pre-attached via $page->with('extra', 123)) are merged on top of inertiaWithData().
3. Root View (site/templates/_inertia.php)
_inertia.php renders only on initial full-page loads. It receives $inertia, $pageObject, $locale, $head (automatically combining SSR <head> output with inertiaViewData()['head']), and any custom variables registered via viewData():
<?php
declare(strict_types=1);
namespace ProcessWire;
if (!defined('PROCESSWIRE')) {
die();
}
/**
* @var Inertia $inertia
* @var Sanitizer $sanitizer
* @var array<string, mixed> $pageObject
* @var string $locale
* @var string $head
*/
?>
<!DOCTYPE html>
<html lang="<?= $sanitizer->entities((string) ($locale ?? 'en')) ?>" class="h-full">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<?= $head ?? '' ?>
<?php
if (function_exists(__NAMESPACE__ . '\vite')) {
echo vite([
'@assets/css/app.css',
'@assets/js/inertia.ts',
]);
}
?>
</head>
<body class="min-h-full antialiased">
<?= $inertia->app($pageObject) ?>
</body>
</html>
4. Shared State & Inertia v3 Prop Types
Global Shared Props (site/templates/_init.php or site/ready.php)
Use $inertia->shareOnce() for cross-navigation cached datasets (navigation trees, directory counts, filter presets) and $inertia->share() for request-scoped state:
<?php
declare(strict_types=1);
namespace ProcessWire;
/** @var ProcessWire $wire */
$inertia = inertia();
$pages = $wire->pages;
$user = $wire->user;
$page = $wire->page;
$inertia->shareOnce('navigation', function () use ($pages): array {
$items = [];
foreach ($pages->get('/')->children() as $item) {
$items[] = [
'title' => (string) $item->title,
'url' => (string) $item->url,
];
}
return $items;
})->as('site_navigation')->until(3600);
$inertia->share([
'auth.user' => fn(): ?User => $user->isLoggedin() ? $user : null,
'canEdit' => fn(): bool => $page instanceof Page && $page->id > 0 && $page->editable(),
]);
Lazy, Deferred, Merge, Once & Infinite Scroll Props
<?php
declare(strict_types=1);
namespace ProcessWire;
/** @var Page $page */
/** @var Pages $pages */
$inertia = inertia();
return $inertia->render('Articles/Index', [
'page' => $page,
// Evaluated asynchronously in a background request after initial render:
'stats' => $inertia->defer(fn(): array => [
'total' => $pages->count('template=article'),
], 'sidebar')->once(until: 1800),
// Infinite scroll pagination with ProcessWire PaginatedArray:
'articles' => $inertia->scroll(
fn(): PageArray => $pages->find('template=article, limit=12, sort=-created')
)->matchOn('id'),
// Append/prepend/deepMerge slices across partial reloads:
'activity' => $inertia->merge($activitySlice)->append('items', matchOn: 'id'),
// Evaluated only when explicitly requested via partial reload `only`:
'auditLog' => $inertia->optional(fn(): array => []),
// Included on every visit even when excluded by partial reload filters:
'permissions' => $inertia->always(['editable' => $page->editable()]),
]);
When ->until() is omitted on an OnceProp, Inertia inherits $page->template->cache_time and invalidates WireCache automatically on Pages::saved, Pages::trashed, and Pages::deleted according to $template->cacheExpire.
5. 3-Tier DataProvider Pipeline (site/inertia/)
Serialization of Page, Field, and User objects at runtime and static generation of site/classes/*Page.php + inertia.d.ts are managed by Totoglu\Inertia\Provider\ProviderRegistry:
- Tier 1 (Built-in Core Providers):
ScalarFieldDataProvider,MediaFieldDataProvider(serializingPageimagewithfocus: { top, left, zoom }and automatically enriching withsrcset,sizes,sources,lqip,dominantColor,palette,colorTheme, andimageHashwhentrk/processwire-intervention-imageis installed),PageReferenceFieldDataProvider,RepeaterFieldDataProvider,OptionsFieldDataProvider,FieldtypeFieldDataProvider(forFieldtypemodules implementingInertiaFieldtypeInterface),TemplateDataProvider, andUserDataProvider. - Tier 2 (Module Hook): Third-party modules hook
Inertia::registerProvidersand call$inertia->registerProvider(...). - Tier 3 (Project Auto-Discovery in
site/inertia/*Provider.php): Files insite/inertia/undernamespace Totoglu\Inertia\Provider;are discovered automatically and override lower tiers.
Example: Custom TemplateDataProvider (site/inertia/ArticleTemplateDataProvider.php)
<?php
declare(strict_types=1);
namespace Totoglu\Inertia\Provider;
use ProcessWire\Page;
use ProcessWire\Template;
final class ArticleTemplateDataProvider extends TemplateDataProvider
{
/**
* @return list<string>
*/
public function templates(): array
{
return ['article'];
}
/**
* @return list<string>
*/
public function excludedFields(): array
{
return ['internal_notes'];
}
/**
* @return array<string, mixed>
*/
protected function extraData(Page $page): array
{
$words = str_word_count(strip_tags((string) $page->get('body')));
return [
'readingTime' => max(1, (int) ceil($words / 200)),
];
}
/**
* @return array<string, string>
*/
public function extraTsTypes(Template $template): array
{
return [
'readingTime' => 'number',
];
}
}
Example: Custom FieldDataProvider (site/inertia/MapMarkerFieldDataProvider.php)
<?php
declare(strict_types=1);
namespace Totoglu\Inertia\Provider;
use ProcessWire\Field;
use ProcessWire\MapMarker;
use ProcessWire\Page;
final class MapMarkerFieldDataProvider extends FieldDataProvider
{
/**
* @return list<string>
*/
public function supports(): array
{
return ['FieldtypeMapMarker', MapMarker::class];
}
/**
* @return array{address: string, lat: float|null, lng: float|null, zoom: int}|null
*/
public function transform(mixed $value, ?Field $field = null, ?Page $page = null): ?array
{
if (!is_object($value)) {
return null;
}
$lat = isset($value->lat) ? (float) $value->lat : 0.0;
$lng = isset($value->lng) ? (float) $value->lng : 0.0;
return [
'address' => isset($value->address) ? trim((string) $value->address) : '',
'lat' => abs($lat) > 0.0001 ? round($lat, 6) : null,
'lng' => abs($lng) > 0.0001 ? round($lng, 6) : null,
'zoom' => isset($value->zoom) ? (int) $value->zoom : 13,
];
}
public function phpType(Field $field): string
{
return 'MapMarker|null';
}
public function tsType(Field $field): string
{
return 'InertiaMapMarker | null';
}
/**
* @return array<string, string>
*/
public function tsDefinitions(?Field $field = null): array
{
return [
'InertiaMapMarker' => <<<'TS'
export interface InertiaMapMarker {
address: string;
lat: number | null;
lng: number | null;
zoom: number;
}
TS,
];
}
}
6. Extendable Module Base Classes & Fieldtype Contract
Totoglu\Inertia\ProcessInertia: AbstractProcessbase class usingInteractWithInertiafor building full-page or embedded Inertia admin modules (return (string) $this->inertiaRender('Admin/Dashboard', [...]);).Totoglu\Inertia\MarkupInertia: AbstractModuleJSbase class implementingInertiaSerializableandInteractWithInertia. Renders<div class="pw-inertia-island" data-inertia-island="..." data-props="..."></div>in classic templates or serializes as{ component, props }when passed as a prop into a parent Inertia response.Totoglu\Inertia\InputfieldInertia: AbstractInputfieldbase class implementingInertiaSerializableandInteractWithInertia. Renders a synchronized hidden<input data-inertia-input="true">and island mount node inside classic ProcessWire forms (ProcessPageEdit,ProcessModule), and serializes cleanly when anInputfieldWrapperis passed into a full-page Inertia response.Totoglu\Inertia\Contracts\InertiaFieldtypeInterface&InteractWithInertiaFieldtype: Implement on customFieldtypemodules to definetoInertiaFieldValue(),toInertiaPhpType(),toInertiaTsType(), andtoInertiaTsDefinitions()directly on the Fieldtype class.
7. Forms, Validation, Flash & Redirects
Inertia automatically populates $input->post from application/json request bodies (syncJsonToPost), validates ProcessWire CSRF / X-XSRF-TOKEN headers, flashes validation errors to props.errors, exposes top-level page.flash alongside props.flash, and downgrades post-mutation redirects to 303 See Other:
<?php
declare(strict_types=1);
namespace ProcessWire;
$wire->addHook('/contact/submit', function (HookEvent $e): mixed {
$inertia = inertia();
$inertia->request->validateCsrf(true);
$email = $e->wire()->sanitizer->email((string) $inertia->request->input('email'));
if ($email === '') {
return $inertia->backWithErrors([
'email' => __('Please enter a valid email address.', __FILE__),
], bag: null, fallbackUrl: '/contact/', terminate: true);
}
$e->wire()->session->message(__('Your message has been sent!', __FILE__));
$inertia->flash('toast', __('Contact form submitted', __FILE__));
$inertia->preserveFragment();
$e->wire()->session->redirect('/contact/');
return null;
});
8. Configuration & CLI Commands
| Setting | Type | Default | Description |
|---|---|---|---|
mode |
string |
'selected' |
'selected' (Hybrid Mode) or 'all' (Full SPA Mode). |
enabledTemplates |
array<string> |
[] |
Template names handled by Inertia when mode === 'selected'. |
disableAppendTemplateFile |
bool |
true |
Sets $template->noAppendTemplateFile = true on Inertia templates to skip _main.php. |
disablePrependTemplateFile |
bool |
false |
Sets $template->noPrependTemplateFile = true on Inertia templates when enabled. |
rootView |
string |
'_inertia.php' |
Root View template filename inside site/templates/. |
rootElementId |
string |
'app' |
Root mount container ID (<div id="app">). |
syncJsonToPost |
bool |
true |
Populates $input->post from application/json bodies on POST/PUT/PATCH/DELETE. |
autoShareFlash |
bool |
true |
Shares ProcessWire $notices under props.flash and top-level page.flash. |
autoShareCsrf |
bool |
true |
Shares ProcessWire CSRF token under props.csrf and emits the XSRF-TOKEN cookie. |
encryptHistory |
bool |
false |
Enables Inertia v3 browser history encryption (encryptHistory: true) by default. |
ssrEnabled |
bool |
false |
Dispatches initial visits to the SSR Gateway (ssrUrl). |
ssrUrl |
string |
'http://127.0.0.1:13714/render' |
HTTP endpoint of the Node/Bun SSR server. |
ssrTimeout |
float |
1.5 |
SSR HTTP connection timeout in seconds before falling back to CSR. |
tracySupport |
bool |
true |
Registers InertiaTracyPanel in TracyDebugger when $config->debug is true. |
autoGenerateTypes |
bool |
true |
Regenerates inertia.d.ts when module settings or templates/fields are saved. |
autoScaffoldPageClasses |
bool |
true |
Scaffolds missing site/classes/[PascalCase]Page.php classes (never overwrites existing files). |
typeScriptOutputPath |
string |
'assets/js/types/inertia.d.ts' |
Output path for inertia.d.ts relative to site/templates/. |
CLI Commands (vendor/bin/wire)
| Command | Options | Description |
|---|---|---|
php vendor/bin/wire inertia:install |
--stack=vue|react|svelte, --template=<name>, --force, --json |
Publishes _inertia.php, assets/js/inertia.ts, starter component, Custom Page Class, template controller, and inertia.d.ts. |
php vendor/bin/wire inertia:types |
--template=<name>, --output=<path>, --ts-only, --php-only, --controllers, --force, --json |
Generates site/classes/*Page.php and site/templates/assets/js/types/inertia.d.ts from enabled templates and registered DataProviders. |
9. Multi-Language Support & Security
- Translations: Ships with chronological ProcessWire
LanguagePorterCSV packs inlanguages/{en,tr,de,es,fr,ru}.csv. Import via Setup → Languages or$language->porter->importCsv(). - CSRF Verification: Validates
$session->CSRFtokens via$input->post,X-CSRF-TOKEN, andX-XSRF-TOKENheaders ($inertia->request->validateCsrf(true)), and automatically bridges verifiedX-XSRF-TOKEN/X-CSRF-TOKEN/_tokenvalues into$input->postso native$session->CSRF->validate()succeeds. - Demo Mode (
$config->demo = true): Blocks state-changing requests (POST,PUT,PATCH,DELETE), purges$input->post,$_FILES, and JSON bodies, downgrades$inertia->request->method()to'GET', and disables disk type generation. - Safe User Serialization:
UserDataProviderstripspass, salts, tokens, and system fields before serializingUserinstances. - Root View Path Confinement: Custom
rootViewpaths are confined inside$config->paths->templatesor$config->paths->siteModulesviarealpath()boundary checks.
Related Packages
Inertia.js 3 server adapter for PSR-7 and PSR-15: pages, partial reloads, deferr...
Inertia.js server adapter for Phalcon 3.4 to 6, with Ziggy style named routes fo...
Define Laravel forms in PHP and render them with Inertia.js in Vue, React, or Sv...
Version History
| Version | Released | PHP | License |
|---|---|---|---|
| v0.1.1 | >=8.4 | MIT | |
| v0.1.0 | >=8.4 | MIT |