| Install | |
|---|---|
composer require mjoc1985/laravel-inertia-helpers |
|
| Latest Version: | v0.3.0 |
| PHP: | ^8.2 |
A collection of backend and frontend utilities for common Inertia.js + Laravel patterns. Type-safe, unopinionated about styling, and designed to eliminate the boilerplate every Inertia project ends up writing.
Every Inertia.js + Laravel project ends up solving the same problems:
This package solves all of them with a clean, typed API on both sides of the stack.
composer require mjoc1985/laravel-inertia-helpers
The service provider is auto-discovered. Optionally publish the config:
php artisan vendor:publish --tag=inertia-helpers-config
npm install @mjoc1985/inertia-helpers
The service provider automatically shares auth, flash messages, and breadcrumbs with Inertia — no middleware changes required. Just install the package and the SharedData service handles everything.
To customise the shared data, extend the SharedData class and rebind it in your AppServiceProvider:
// app/Services/CustomSharedData.php
use Illuminate\Http\Request;
use Mjoc1985\InertiaHelpers\SharedData;
class CustomSharedData extends SharedData
{
public function auth(Request $request): array
{
$user = $request->user();
return [
'user' => $user ? [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'avatar_url' => $user->avatar_url,
'roles' => $user->roles->pluck('name'),
] : null,
];
}
public function custom(Request $request): array
{
return [
'app' => [
'name' => config('app.name'),
'environment' => app()->environment(),
],
];
}
}
// app/Providers/AppServiceProvider.php
use Mjoc1985\InertiaHelpers\SharedData;
use App\Services\CustomSharedData;
public function register(): void
{
$this->app->singleton(SharedData::class, CustomSharedData::class);
}
// resources/js/types/inertia.d.ts
import type { SharedData } from '@mjoc1985/inertia-helpers'
// Extend with your app's user model
interface AppUser {
id: number
name: string
email: string
avatar_url: string | null
roles: string[]
}
// Register your types globally
declare module '@mjoc1985/inertia-helpers' {
interface SharedDataOverrides {
auth: {
user: AppUser | null
}
}
}
<script setup lang="ts">
import { useAuth, useFlash } from '@mjoc1985/inertia-helpers'
const { user, isAuthenticated, hasRole } = useAuth()
const { messages, dismiss } = useFlash()
</script>
<template>
<div v-if="isAuthenticated">
Welcome back, {{ user.name }}!
</div>
<div v-for="msg in messages" :key="msg.id">
{{ msg.text }}
<button @click="dismiss(msg.id)">×</button>
</div>
</template>
The SharedData service class structures all shared data into a predictable shape and is registered as a singleton. It is automatically wired into Inertia::share() by the service provider — no middleware changes needed.
Public methods:
| Method | Description |
|---|---|
toArray(Request $request): array |
Returns all shared data as lazy closures |
auth(Request $request): array |
User authentication payload |
flash(Request $request): array |
Flash messages |
breadcrumbs(Request $request): array |
Breadcrumb trail |
custom(Request $request): array |
Override hook, returns [] by default |
To customise, extend and rebind (see Quick Start).
What gets shared automatically:
[
'auth' => [
'user' => [...] | null,
],
'flash' => [
'success' => '...' | null,
'error' => '...' | null,
'warning' => '...' | null,
'info' => '...' | null,
],
'breadcrumbs' => [
['label' => 'Home', 'url' => '/'],
['label' => 'Users', 'url' => '/users'],
['label' => 'John Doe', 'url' => null], // current page, no link
],
]
Beyond Laravel's basic session()->flash(), the package provides a fluent API for richer flash messages:
use Mjoc1985\InertiaHelpers\Flash;
// Simple usage (works with standard Laravel flash)
return redirect()->route('users.index')->with('success', 'User created.');
// Rich flash messages with metadata
Flash::success('User created successfully.')
->action('View User', route('users.show', $user))
->autoDismiss(5000) // milliseconds, or false to persist
->send();
Flash::error('Payment failed.')
->detail('Your card was declined. Please try a different payment method.')
->autoDismiss(false) // errors should persist
->send();
Flash::warning('Your trial expires in 3 days.')
->action('Upgrade Now', route('billing.plans'))
->send();
// Stack multiple flash messages
Flash::success('Project saved.')->send();
Flash::info('Collaborators have been notified.')->send();
Register breadcrumbs in a dedicated file, referenced by route name:
// routes/breadcrumbs.php (auto-loaded by the service provider)
use Mjoc1985\InertiaHelpers\Breadcrumbs;
Breadcrumbs::for('home', function ($trail) {
$trail->push('Home', route('home'));
});
Breadcrumbs::for('users.index', function ($trail) {
$trail->parent('home');
$trail->push('Users', route('users.index'));
});
Breadcrumbs::for('users.show', function ($trail, $user) {
$trail->parent('users.index');
$trail->push($user->name); // no URL = current page
});
Breadcrumbs::for('users.edit', function ($trail, $user) {
$trail->parent('users.show', $user);
$trail->push('Edit');
});
Breadcrumbs are resolved automatically based on the current route and shared via the SharedData service. Route model binding works as expected — the parameters from the current route are passed to the breadcrumb callback.
Config (config/inertia-helpers.php):
return [
'breadcrumbs' => [
// Path to your breadcrumb definitions
'file' => base_path('routes/breadcrumbs.php'),
// Include 'Home' automatically on every trail
'auto_home' => true,
// Route name for the home breadcrumb
'home_route' => 'home',
],
'flash' => [
// Default auto-dismiss duration in milliseconds
'auto_dismiss' => 5000,
// Flash types to share (maps to session keys)
'types' => ['success', 'error', 'warning', 'info'],
],
];
A macro on Laravel's LengthAwarePaginator that formats pagination data cleanly for the frontend composable:
// In a controller
public function index(Request $request)
{
$users = User::query()
->filter($request->only(['search', 'role', 'status']))
->sort($request->get('sort', 'name'), $request->get('direction', 'asc'))
->paginate(15)
->withQueryString();
return inertia('Users/Index', [
'users' => $users,
'filters' => $request->only(['search', 'role', 'status']),
'sort' => [
'field' => $request->get('sort', 'name'),
'direction' => $request->get('direction', 'asc'),
],
]);
}
Type-safe access to the authenticated user.
<script setup lang="ts">
import { useAuth } from '@mjoc1985/inertia-helpers'
const { user, isAuthenticated, isGuest, hasRole, hasAnyRole } = useAuth()
</script>
<template>
<nav>
<template v-if="isAuthenticated">
<span>{{ user.name }}</span>
<AdminMenu v-if="hasRole('admin')" />
</template>
<template v-else>
<LoginLink />
</template>
</nav>
</template>
API:
interface UseAuthReturn<T = AuthUser> {
/** The authenticated user, or null. Reactive. */
user: ComputedRef<T | null>
/** Whether a user is authenticated. Reactive. */
isAuthenticated: ComputedRef<boolean>
/** Whether no user is authenticated. Reactive. */
isGuest: ComputedRef<boolean>
/** Check if the user has a specific role */
hasRole: (role: string) => boolean
/** Check if the user has any of the given roles */
hasAnyRole: (...roles: string[]) => boolean
}
Manages flash messages with auto-dismiss, stacking, and lifecycle.
<script setup lang="ts">
import { useFlash } from '@mjoc1985/inertia-helpers'
const { messages, dismiss, dismissAll, onFlash } = useFlash()
// Optional: react to new flash messages (returns an unsubscribe function)
const unsubscribe = onFlash((message) => {
if (message.type === 'error') {
console.error('Flash error:', message.text)
}
})
// Call unsubscribe() when you no longer need the callback
</script>
<template>
<TransitionGroup name="flash" tag="div" class="fixed top-4 right-4 space-y-2 z-50">
<div
v-for="msg in messages"
:key="msg.id"
:class="{
'bg-green-50 border-green-500': msg.type === 'success',
'bg-red-50 border-red-500': msg.type === 'error',
'bg-yellow-50 border-yellow-500': msg.type === 'warning',
'bg-blue-50 border-blue-500': msg.type === 'info',
}"
class="border-l-4 p-4 rounded shadow-lg max-w-sm"
>
<div class="flex justify-between items-start">
<div>
<p class="font-medium">{{ msg.text }}</p>
<p v-if="msg.detail" class="text-sm mt-1 opacity-75">{{ msg.detail }}</p>
</div>
<button @click="dismiss(msg.id)" class="ml-4 opacity-50 hover:opacity-100">×</button>
</div>
<a
v-if="msg.action"
:href="msg.action.url"
class="text-sm font-medium underline mt-2 inline-block"
>
{{ msg.action.label }}
</a>
<!-- Auto-dismiss progress bar -->
<div
v-if="msg.autoDismiss"
class="h-0.5 bg-current opacity-20 mt-2 rounded"
:style="{ width: msg.remainingPercent + '%', transition: 'width 100ms linear' }"
/>
</div>
</TransitionGroup>
</template>
API:
interface FlashMessage {
id: string
type: 'success' | 'error' | 'warning' | 'info'
text: string
detail?: string
action?: { label: string; url: string }
autoDismiss: number | false // milliseconds or false
remainingPercent: number // 100 → 0, reactive, for progress bars
createdAt: number
}
interface UseFlashReturn {
/** All currently visible flash messages. Reactive. */
messages: ComputedRef<FlashMessage[]>
/** Dismiss a specific message by ID */
dismiss: (id: string) => void
/** Dismiss all messages */
dismissAll: () => void
/** Register a callback for new flash messages. Returns an unsubscribe function. */
onFlash: (callback: (message: FlashMessage) => void) => () => void
}
Wraps an Inertia paginator response with reactive controls.
<script setup lang="ts">
import { usePagination } from '@mjoc1985/inertia-helpers'
const props = defineProps<{
users: InertiaPage<User> // Laravel's paginated response
}>()
const {
items,
meta,
goToPage,
nextPage,
prevPage,
updatePerPage,
isFirstPage,
isLastPage,
} = usePagination(() => props.users)
</script>
<template>
<table>
<tbody>
<tr v-for="user in items" :key="user.id">
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
</tr>
</tbody>
</table>
<div class="flex items-center justify-between mt-4">
<span>
Showing {{ meta.from }}–{{ meta.to }} of {{ meta.total }}
</span>
<div class="flex gap-2">
<button @click="prevPage" :disabled="isFirstPage">Previous</button>
<button
v-for="page in meta.links"
:key="page.label"
@click="goToPage(page.number)"
:class="{ 'font-bold': page.active }"
>
{{ page.label }}
</button>
<button @click="nextPage" :disabled="isLastPage">Next</button>
</div>
<select :value="meta.perPage" @change="updatePerPage(+$event.target.value)">
<option :value="10">10 per page</option>
<option :value="25">25 per page</option>
<option :value="50">50 per page</option>
</select>
</div>
</template>
API:
interface PaginationMeta {
currentPage: number
lastPage: number
perPage: number
total: number
from: number
to: number
links: Array<{
number: number | null
label: string
active: boolean
url: string | null
}>
}
interface UsePaginationReturn<T> {
/** The items on the current page. Reactive. */
items: ComputedRef<T[]>
/** Pagination metadata. Reactive. */
meta: ComputedRef<PaginationMeta>
/** Navigate to a specific page */
goToPage: (page: number) => void
/** Go to the next page */
nextPage: () => void
/** Go to the previous page */
prevPage: () => void
/** Change items per page (reloads from page 1) */
updatePerPage: (perPage: number) => void
/** Whether currently on the first page. Reactive. */
isFirstPage: ComputedRef<boolean>
/** Whether currently on the last page. Reactive. */
isLastPage: ComputedRef<boolean>
/** Whether a page transition is in progress. Reactive. */
isLoading: ComputedRef<boolean>
}
Options:
const pagination = usePagination(() => props.users, {
// Preserve these query params during navigation (e.g. active filters)
preserveQuery: ['search', 'role', 'status'],
// Use 'replace' instead of 'push' for browser history
replace: true,
// Preserve scroll position during navigation
preserveScroll: true,
// Only reload this prop (performance optimisation)
only: ['users'],
})
Syncs a filter form with URL query parameters via Inertia visits. Handles debouncing, resetting, and dirty tracking.
<script setup lang="ts">
import { useFilters } from '@mjoc1985/inertia-helpers'
const props = defineProps<{
filters: {
search: string
role: string
status: string
}
}>()
const { values, update, reset, isDirty, activeCount } = useFilters(
() => props.filters,
{
debounce: { search: 300 }, // debounce specific fields
only: ['users'], // only reload the users prop
}
)
</script>
<template>
<div class="flex gap-4 items-center">
<input
type="text"
:value="values.search"
@input="update('search', $event.target.value)"
placeholder="Search users..."
/>
<select :value="values.role" @change="update('role', $event.target.value)">
<option value="">All Roles</option>
<option value="admin">Admin</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
<select :value="values.status" @change="update('status', $event.target.value)">
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
<button v-if="isDirty" @click="reset">
Clear Filters ({{ activeCount }})
</button>
</div>
</template>
API:
interface UseFiltersReturn<T extends Record<string, any>> {
/** Current filter values. Reactive. */
values: Reactive<T>
/** Update a single filter value (triggers debounced Inertia visit) */
update: <K extends keyof T>(key: K, value: T[K]) => void
/** Update multiple filter values at once */
updateMany: (updates: Partial<T>) => void
/** Reset all filters to their defaults */
reset: () => void
/** Reset a single filter to its default */
resetField: <K extends keyof T>(key: K) => void
/** Whether any filter differs from its default. Reactive. */
isDirty: ComputedRef<boolean>
/** Number of active (non-default) filters. Reactive. */
activeCount: ComputedRef<number>
/** Whether an Inertia visit is in progress. Reactive. */
isLoading: ComputedRef<boolean>
}
Manages sortable table columns with Inertia visits.
<script setup lang="ts">
import { useSorting } from '@mjoc1985/inertia-helpers'
const props = defineProps<{
sort: { field: string; direction: 'asc' | 'desc' }
}>()
const { sortBy, isSortedBy, direction } = useSorting(() => props.sort, {
only: ['users'],
})
</script>
<template>
<table>
<thead>
<tr>
<th @click="sortBy('name')" class="cursor-pointer">
Name
<span v-if="isSortedBy('name')">
{{ direction === 'asc' ? '↑' : '↓' }}
</span>
</th>
<th @click="sortBy('email')" class="cursor-pointer">
Email
<span v-if="isSortedBy('email')">
{{ direction === 'asc' ? '↑' : '↓' }}
</span>
</th>
<th @click="sortBy('created_at')" class="cursor-pointer">
Joined
<span v-if="isSortedBy('created_at')">
{{ direction === 'asc' ? '↑' : '↓' }}
</span>
</th>
</tr>
</thead>
</table>
</template>
API:
interface UseSortingReturn {
/** Sort by a field. Toggles direction if already sorted by this field. */
sortBy: (field: string) => void
/** Whether currently sorted by the given field. */
isSortedBy: (field: string) => boolean
/** Current sort direction. Reactive. */
direction: ComputedRef<'asc' | 'desc'>
/** Current sort field. Reactive. */
field: ComputedRef<string>
}
Access the breadcrumb trail shared from the backend.
<script setup lang="ts">
import { useBreadcrumbs } from '@mjoc1985/inertia-helpers'
const { crumbs, hasCrumbs } = useBreadcrumbs()
</script>
<template>
<nav v-if="hasCrumbs" aria-label="Breadcrumb">
<ol class="flex items-center gap-2 text-sm text-gray-500">
<li v-for="(crumb, index) in crumbs" :key="index" class="flex items-center gap-2">
<span v-if="index > 0">/</span>
<Link
v-if="crumb.url"
:href="crumb.url"
class="hover:text-gray-700 underline"
>
{{ crumb.label }}
</Link>
<span v-else class="text-gray-900 font-medium">
{{ crumb.label }}
</span>
</li>
</ol>
</nav>
</template>
useAuth composable with typed user accessuseFlash composable with auto-dismiss and stackingusePagination composable with full navigation controlsSharedData service class (auto-wired via Inertia::share())Flash builder classuseFilters composable with debounce and dirty trackinguseSorting composable for table columnsuseBreadcrumbs composableBreadcrumbs registration APISharedData serviceContributions are welcome! Please see CONTRIBUTING.md for details.
The MIT License (MIT). See LICENSE.md for details.
Built by mjoc1985