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.

Maintainers

Package info

github.com/alisacorporation/inertia-codeigniter

pkg:composer/alisacorporation/inertia-codeigniter

Transparency log

Statistics

Installs: 7

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-08-25 01:01 UTC

This package is not auto-updated.

Last update: 2026-08-26 06:35:52 UTC


README

CI Packagist Version PHP Version License: MIT

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

  1. Compatibility
  2. Installation
  3. Configuration
  4. Basic usage
  5. Shared props
  6. Partial reloads
  7. Lazy, optional, always, deferred and merge props
  8. Redirects
  9. Asset versioning
  10. Vue 3 setup
  11. Vite setup
  12. Forms and validation
  13. Flash / session data
  14. Security
  15. Testing
  16. Server-side rendering (SSR)
  17. Deployment
  18. Troubleshooting
  19. Protocol compatibility
  20. 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 inertia service (service('inertia')),
  • the inertia filter alias (via Config\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):

  1. Local props passed to Inertia::render()
  2. 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-page attribute and passes it through esc($json, 'attr'). </script> sequences cannot escape the attribute; injected quotes are entity-encoded.
  • Inertia XHR responses ship the page as an application/json body with JSON_THROW_ON_ERROR — malformed prop input surfaces as a server-side exception rather than a silent 502.
  • Config\Inertia::$blacklistProps is 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

  1. Enable SSR in app/Config/Inertia.php:

    public bool   $ssrEnabled = true;
    public string $ssrUrl     = 'http://127.0.0.1:13714/render';
  2. 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
  3. 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

  1. Run npm run build on your build server.
  2. Ship the resulting public/build/ alongside your app.
  3. Ensure the version resolver returns a stable value across requests (default: hash of the manifest).
  4. If you use SSR, run node dist/ssr/ssr.js under supervisord/systemd and set $ssrUrl to its address.

Troubleshooting

  • HTML instead of JSON on Inertia navigation — the X-Inertia header 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.
  • app element not foundConfig\Inertia::$rootId and the id option of createInertiaApp() must be identical.
  • Vary header missing — the inertia filter is not registered in after globals.

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 with errors), url, version, clearHistory, encryptHistory, optional deferredProps, mergeProps, prependProps, deepMergeProps, matchPropsOn
  • initial HTML: <div id="…" data-page="…"> matching getInitialPageFromDOM(id, useScriptElement=false) in packages/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.