mjoc1985 / laravel-inertia-helpers
Backend and frontend utilities for common Inertia.js + Laravel patterns. Type-safe shared data, flash messages, pagination, filters, sorting, and breadcrumbs.
Installs: 28
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
Language:TypeScript
pkg:composer/mjoc1985/laravel-inertia-helpers
Requires
- php: ^8.2
- illuminate/contracts: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
- inertiajs/inertia-laravel: ^2.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
README
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.
Why?
Every Inertia.js + Laravel project ends up solving the same problems:
- Accessing shared data (auth user, flash messages) without type safety
- Wiring up flash messages with auto-dismiss and stacking
- Building pagination controls that work with Inertia's router
- Syncing filter/search forms with URL query parameters
- Managing breadcrumbs without duplicating logic across components
This package solves all of them with a clean, typed API on both sides of the stack.
Requirements
- PHP 8.2+
- Laravel 11+
- Vue 3.3+
- Inertia.js 2.x
- TypeScript 5+ (recommended but not required)
Installation
Backend (Laravel)
composer require mjoc1985/laravel-inertia-helpers
The service provider is auto-discovered. Optionally publish the config:
php artisan vendor:publish --tag=inertia-helpers-config
Frontend (Vue 3)
npm install @mjoc1985/inertia-helpers
Quick Start
1. Zero-config shared data
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); }
2. Define your shared data types
// 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 } } }
3. Use the composables
<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>
Backend API
SharedData Service
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
],
]
Flash Messages — Enhanced
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();
Breadcrumbs
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'], ], ];
Pagination — Backend Helper
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'), ], ]); }
Frontend API
useAuth()
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 }
useFlash()
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 }
usePagination()
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'], })
useFilters()
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> }
useSorting()
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> }
useBreadcrumbs()
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>
Versioning & Releases
v1.0 — Initial Release
useAuthcomposable with typed user accessuseFlashcomposable with auto-dismiss and stackingusePaginationcomposable with full navigation controlsSharedDataservice class (auto-wired viaInertia::share())Flashbuilder class- TypeScript types with module augmentation
v1.1
useFilterscomposable with debounce and dirty trackinguseSortingcomposable for table columns
v1.2
useBreadcrumbscomposableBreadcrumbsregistration API- Auto-sharing breadcrumbs via
SharedDataservice
v2.0 (Future)
- Renderless Vue components as alternatives to composables
- SSR support improvements
- Laravel 12+ specific optimisations
Contributing
Contributions are welcome! Please see CONTRIBUTING.md for details.
License
The MIT License (MIT). See LICENSE.md for details.
Built by mjoc1985