npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mjoc1985/inertia-helpers

v0.3.0

Published

Type-safe Vue 3 composables for Inertia.js + Laravel: auth, flash messages, pagination, filters, sorting, and breadcrumbs.

Downloads

377

Readme

Laravel Inertia Helpers

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.

Latest Version on Packagist npm version License: MIT


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

  • useAuth composable with typed user access
  • useFlash composable with auto-dismiss and stacking
  • usePagination composable with full navigation controls
  • SharedData service class (auto-wired via Inertia::share())
  • Flash builder class
  • TypeScript types with module augmentation

v1.1

  • useFilters composable with debounce and dirty tracking
  • useSorting composable for table columns

v1.2

  • useBreadcrumbs composable
  • Breadcrumbs registration API
  • Auto-sharing breadcrumbs via SharedData service

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