alisacorporation / inertia-codeigniter
Inertia.js v3 server-side adapter for CodeIgniter 4. Build single-page apps with Vue 3 without the complexity of a REST API.
Package info
github.com/alisacorporation/inertia-codeigniter
pkg:composer/alisacorporation/inertia-codeigniter
Requires
- php: ^8.2
- ext-json: *
- ext-mbstring: *
- codeigniter4/framework: ^4.7
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.64
- mikey179/vfsstream: ^1.6.12
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5 || ^12.0
This package is not auto-updated.
Last update: 2026-08-26 06:35:52 UTC
README
Inertia.js v3 server-side adapter for CodeIgniter 4.
Build classic multi-page apps in CodeIgniter and hand your controllers' props straight to a Vue 3 client. No REST layer, no state duplication, no dedicated router.
use CodeIgniter\Inertia\Inertia; return Inertia::render('Dashboard', [ 'user' => $user, ])->toResponse($this->request, $this->response);
<script setup lang="ts"> defineProps<{ user: { id: number; name: string } }>() </script> <template> <main> <h1>Welcome, {{ user.name }}</h1> </main> </template>
Table of contents
- Compatibility
- Installation
- Configuration
- Basic usage
- Shared props
- Partial reloads
- Lazy, optional, always, deferred and merge props
- Redirects
- Asset versioning
- Vue 3 setup
- Vite setup
- Forms and validation
- Flash / session data
- Security
- Testing
- Server-side rendering (SSR)
- Deployment
- Troubleshooting
- Protocol compatibility
- Upgrade strategy
Compatibility
| Component | Verified version |
|---|---|
| PHP | ^8.2 (matches CI4 4.7.x) |
| CodeIgniter 4 | ^4.7 |
| Composer | 2.x |
| Inertia protocol | v3 |
@inertiajs/vue3 |
^3.7 |
| Vue | ^3.5 |
| Vite | ^6 (7/8 also compatible via @inertiajs/vue3 peers) |
| Node | ≥ 20 (Vite 6 requirement) |
| TypeScript | ^5.6 |
The wire protocol is validated against the current
@inertiajs/core
master branch (Page type, getInitialPageFromDOM, response schema).
Installation
Backend
composer require alisacorporation/inertia-codeigniter
That is enough. CodeIgniter's Composer auto-discovery registers:
- the
inertiaservice (service('inertia')), - the
inertiafilter alias (viaConfig\Registrar), - the
inertia()global helper.
Frontend
Copy resources/ from this repository into your CodeIgniter app (or
adapt its contents into your existing frontend), then:
cd resources npm install npm run dev # or `npm run build`
By default Vite writes hashed assets into public/build/. The default
Inertia root view expects a manifest at public/build/manifest.json.
Configuration
Publish a copy of the config into your app at app/Config/Inertia.php
by extending the base class:
<?php namespace Config; use CodeIgniter\Inertia\Config\Inertia as BaseConfig; final class Inertia extends BaseConfig { public string $rootView = 'inertia'; public string $title = 'Acme'; public $version = fn () => md5_file(FCPATH . 'build/manifest.json'); public $manifest = fn () => json_decode( (string) file_get_contents(FCPATH . 'build/manifest.json'), true, ); }
All properties are documented inline in
src/Config/Inertia.php.
Attach the filter in app/Config/Filters.php:
public array $globals = [ 'before' => ['inertia'], 'after' => ['toolbar', 'inertia'], ];
Basic usage
use CodeIgniter\Inertia\Inertia; final class DashboardController extends BaseController { public function index() { return Inertia::render('Dashboard', [ 'user' => $this->users->find(auth()->id()), ])->toResponse($this->request, $this->response); } }
Inertia::render() returns a pending response object; call
toResponse($request, $response) to serialize it to either JSON (for
Inertia XHRs) or an HTML shell (for the initial visit). The helper
form works too:
return inertia('Dashboard', ['user' => $user]) ->toResponse($this->request, $this->response);
Shared props
Inertia::share('appName', 'Acme'); Inertia::share([ 'auth' => ['user' => auth()->user()], 'flash' => session('flash') ?? [], ]);
Precedence (highest wins):
- Local props passed to
Inertia::render() - Shared props registered via
Inertia::share()
Closures registered as shared values are executed lazily only when the response actually includes them (partial reloads that exclude the key will not run the closure).
Partial reloads
Trigger from the client:
router.reload({ only: ['stats'] }) router.reload({ except: ['heavyList'] })
Server-side you don't need to do anything — the adapter automatically
inspects X-Inertia-Partial-Data, X-Inertia-Partial-Except, and
X-Inertia-Partial-Component and returns only the requested props.
Prop wrappers
| Wrapper | On full load | On partial only=X |
On partial except=X |
|---|---|---|---|
| plain value / closure | included | included | excluded |
Inertia::lazy(fn () => …) |
included, resolved lazily | included | not evaluated |
Inertia::optional(fn () => …) |
skipped | included when named | excluded |
Inertia::always($value) |
included | included | still included |
Inertia::defer(fn () => …, 'grp') |
key advertised in deferredProps |
included when named | excluded |
Inertia::merge(fn () => …, ['id']) |
included + tagged in mergeProps |
included when named | excluded |
Deferred groups become a single follow-up partial request per group.
Redirects
Ordinary CodeIgniter redirects work as expected — the filter upgrades
302 Found responses to 303 See Other after PUT/PATCH/DELETE
requests, exactly as required by the Inertia v3 protocol.
For redirects that must break out of the SPA (external URLs, hard reloads after a logout):
return Inertia::location('https://accounts.example.com/logout');
The adapter returns 409 Conflict with X-Inertia-Location for
Inertia XHR requests and falls back to a plain 302 for non-Inertia
requests (bookmarks, curl, etc.).
Asset versioning
Set Config\Inertia::$version to a string or a closure returning a
string. Whenever the value changes between requests, an inbound Inertia
GET whose X-Inertia-Version does not match receives a 409 Conflict
with X-Inertia-Location: <same URL>, which the client turns into a
hard reload — picking up fresh JS/CSS bundles.
The recommended pattern hashes the Vite build manifest:
public $version = fn (): ?string => is_file(FCPATH . 'build/manifest.json') ? md5_file(FCPATH . 'build/manifest.json') : null;
Vue 3 setup
The reference client lives in resources/js/app.ts:
import { createInertiaApp } from '@inertiajs/vue3' import { createApp, h } from 'vue' const pages = import.meta.glob('./Pages/**/*.vue', { eager: true, import: 'default' }) createInertiaApp({ id: 'app', // must match Config\Inertia::$rootId resolve: (name) => pages[`./Pages/${name}.vue`], setup: ({ el, App, props, plugin }) => createApp({ render: () => h(App, props) }) .use(plugin) .mount(el), })
Vite setup
resources/vite.config.ts writes the build into ../public/build/ and
enables the manifest. During npm run dev Vite serves modules from the
dev server; the default root view detects a public/hot file and
switches to dev-server-served scripts.
Forms and validation
<script setup lang="ts"> import { useForm } from '@inertiajs/vue3' const form = useForm({ name: '', email: '' }) const submit = () => form.put(`/users/${id}`) </script>
On the server, use CodeIgniter's Validation service. When it fails,
flash the errors and redirect back:
if (! $this->validate($rules)) { return redirect()->back() ->withInput() ->with('errors', $this->validator->getErrors()); }
The filter upgrades that 302 to 303; on the resulting GET the shared
errors prop is populated automatically from the session flash so
form.errors.email renders the message in Vue.
Flash / session data
Register a shared prop that reads (and clears) the CodeIgniter session flash:
Inertia::share('flash', function () { return [ 'success' => session()->getFlashdata('success'), 'error' => session()->getFlashdata('error'), ]; });
Security
- The root view embeds the page object in a
data-pageattribute and passes it throughesc($json, 'attr').</script>sequences cannot escape the attribute; injected quotes are entity-encoded. - Inertia XHR responses ship the page as an
application/jsonbody withJSON_THROW_ON_ERROR— malformed prop input surfaces as a server-side exception rather than a silent 502. Config\Inertia::$blacklistPropsis enforced after partial reload filtering; a compromised client cannot pull sensitive keys by asking for them explicitly.
You are still responsible for what you put into your props. Do not send
password hashes, remember tokens, encryption keys, or full Eloquent-
style models with unaudited __get accessors.
Testing
composer install composer test # PHPUnit composer analyse # PHPStan level 6
The tests/Protocol/ suite validates the wire behaviour of the
adapter against the Inertia v3 protocol: request detection, initial
HTML page, JSON response schema, shared props, partial reloads,
version mismatches, redirects, prop serialization, and security.
Server-side rendering (SSR)
Turn on SSR when SEO or social previews matter. The client-only default serves an empty root element and relies on the browser to render Vue; crawlers that do not execute JS (Slack, X, LinkedIn, Bing, most in-app previews) will see a blank page.
Wiring
-
Enable SSR in
app/Config/Inertia.php:public bool $ssrEnabled = true; public string $ssrUrl = 'http://127.0.0.1:13714/render';
-
Build the SSR bundle and run the Node process:
cd resources npm run build:ssr npm run ssr # supervise this with systemd/supervisord in prod
-
Requests now flow:
Browser ──► CI4 controller ──► Inertia::render() │ ▼ Http\Response → SsrGateway (HTTP POST) │ ▼ Node @inertiajs/vue3/server │ { head: [...], body: "<h1>…" } ▼ Root view embeds body inside <div id="app">…</div> and injects head tags into <head>
Failure handling
If the SSR process is down, slow (>2 s), returns a non-2xx, or hands
back malformed JSON, the gateway returns null and the root view falls
back to plain client-side rendering. End users get a working page; the
error is logged out of band (add an entry to Config\Events if you
want to alert on it).
Overriding the gateway
Register your own implementation of
CodeIgniter\Inertia\Ssr\Gateway in app/Config/Services.php:
public static function inertiaSsrGateway($config = null, bool $getShared = true) { return new MyInProcessRenderer(); }
The default is HttpGateway — a thin CURLRequest wrapper.
Trade-offs
- +1 background process to run and monitor
- +1 short HTTP round-trip per initial page load (XHR navigations skip SSR)
- Pre-rendered HTML → fast FCP, real content for crawlers, working OG/Twitter cards.
Deployment
- Run
npm run buildon your build server. - Ship the resulting
public/build/alongside your app. - Ensure the version resolver returns a stable value across requests (default: hash of the manifest).
- If you use SSR, run
node dist/ssr/ssr.jsunder supervisord/systemd and set$ssrUrlto its address.
Troubleshooting
- HTML instead of JSON on Inertia navigation — the
X-Inertiaheader did not reach PHP. Double-check reverse-proxy header forwarding. - Full-page reload loop — the version resolver returns a value that changes on every request. Cache it or use a stable hash.
appelement not found —Config\Inertia::$rootIdand theidoption ofcreateInertiaApp()must be identical.- Vary header missing — the
inertiafilter is not registered inafterglobals.
Protocol compatibility
Verified against the Inertia v3 protocol as of August 2026, specifically:
- headers:
X-Inertia,X-Inertia-Version,X-Inertia-Partial-Data,X-Inertia-Partial-Except,X-Inertia-Partial-Component,X-Inertia-Reset,X-Inertia-Error-Bag,X-Inertia-Location - status codes:
200,302,303,409 - page schema:
component,props(always witherrors),url,version,clearHistory,encryptHistory, optionaldeferredProps,mergeProps,prependProps,deepMergeProps,matchPropsOn - initial HTML:
<div id="…" data-page="…">matchinggetInitialPageFromDOM(id, useScriptElement=false)inpackages/core/src/domUtils.ts
Upgrade strategy
Between minor Inertia v3 releases the protocol is expected to stay
stable. When a new page-object field lands upstream, the adapter can
add it to Page without breaking older clients — Inertia treats
unknown fields as inert.
If you build a custom root view, keep its data-page attribute in
sync with any additional fields you rely on.
License
MIT — see LICENSE.