emargareten/inertia-modal
Inertia Modal lets Laravel and Inertia.js apps render pages as modals without extra routes or controllers, keeping server-side routing intact and navigation fast.
The package solves a specific Inertia pain point: a controller needs to return a full page for a direct visit, but the same route should open as a modal when navigated to from within the app. Instead of duplicating routes or hand-rolling modal state in a global store, you return a modal-aware Inertia response from the backend and let the package's frontend outlet render it.
Install and wire up
php artisan vendor:publish --tag="inertia-modal-config"
<!-- resources/js/Layouts/AppLayout.vue -->
<template>
<slot />
<ModalRoot /> <!-- name per README; often <InertiaModal /> or <Modal /> -->
</template>
web group in bootstrap/app.php (Laravel 11+) or Kernel.php.Where to look first
config/inertia-modal.php — the root component name, modal component resolver, and any URL/base-route settings live here.app/Http/Middleware registration and HandleInertiaRequests — most "modal doesn't open" bugs are middleware-ordering or shared-prop issues.First use case: edit-from-index
The canonical flow is an index page with an "Edit" link that opens a form modal without losing the index page's scroll position, filters, or pagination:
// UserController
public function index()
{
return inertia('Users/Index', [
'users' => User::paginate(),
]);
}
public function edit(User $user)
{
return Modal::render('Users/Edit', [
'user' => $user,
]);
}
<Link :href="`/users/${user.id}/edit`">Edit</Link>
On a normal visit /users/1/edit renders a full page; when clicked from the index, the outlet intercepts the response and displays the modal over the existing page. That single behavior is the package's entire value proposition — everything else is convention around it.
Keep index and modal routes separate, but let one controller method serve both. You generally want /users for the page and /users/{user}/edit for the modal. Deep-linking and refreshing the modal URL should still produce something usable — either the modal over a fallback base page, or a full-page edit form. Check the package's base-route/fallback option and use it; a modal route that 500s on refresh is the most common production complaint.
Form submissions inside modals. Use a normal Inertia useForm inside the modal component. The key detail is what happens on success: redirect()->route('users.index') from a modal context can replace the background page or close everything, depending on the package version. Prefer a redirect back to the underlying page URL and let the modal close, or emit the package's close event after a successful post. Preserve the background page's state with preserveState/preserveScroll where applicable.
public function update(UpdateUserRequest $request, User $user)
{
$user->update($request->validated());
return redirect()
->route('users.index')
->with('success', 'User updated.');
}
Validation errors. Because the modal is a real Inertia visit, validation errors arrive in the normal errors prop. Do not render a second error bag or re-fetch the modal. If errors are not appearing, the usual cause is a redirect that changes the component rather than staying on the modal component (back() vs. an explicit route).
Stacked and nested modals. Treat modal depth as UI state, not backend state. If the package supports stacking, keep the stack shallow (two levels maximum); each layer needs its own focus trap and ESC handling, and the browser back button behavior becomes unintuitive quickly. For wizard-style flows, prefer a single modal with internal steps over multiple stacked modals.
Reusable modal components. Give modal components the same prop contract as their full-page counterparts. A common pattern is one UserForm component rendered by both Users/Edit (page) and Users/EditModal (modal shell), so validation and field logic are not duplicated.
Shared props and authorization. Modal routes are ordinary routes: they must carry the same auth, verified, and policy middleware as their page counterparts. A modal is not a security boundary. Also remember that Inertia shared props (HandleInertiaRequests::share) are evaluated for modal requests too — keep that method cheap, or modals will pay the cost on every open.
Lazy and closure props. Props are serialized per request. If a modal only needs a small slice of a model, return that slice explicitly rather than passing the whole model plus relationships; the modal request is a separate round trip and you will notice the payload size.
Testing. Assert against the Inertia response rather than the rendered DOM. In feature tests, use AssertableInertia to confirm the component and modal flag/props, and use assertRedirect for the post-update flow. For the frontend behavior, one or two browser tests (Dusk/Playwright) covering "click edit → modal opens → submit → modal closes" is usually enough; don't try to unit-test the outlet component.
Integration with resource controllers. Keep edit returning the modal, create returning the modal for new records, and store/update returning redirects. This maps cleanly onto Route::resource and means the index page never needs to know which actions are modalized.
<ModalRoot /> inside a page component means it unmounts on navigation and modals silently stop appearing. Put it in the layout that wraps every page.preserveState: false (the default for many Inertia visits) will remount the background page and wipe local component state. When opening a modal from a filtered/paginated index, verify the background state survives and set preserveState/preserveScroll deliberately. If your filters live in local refs rather than the URL, they will not survive a remount — move them to query params.router.visit(baseUrl) or history.back() consistently; mixing both produces double-history entries. Pick one close strategy and use it everywhere.keydown listener in each modal; nested or stacked modals will close multiple layers at once. Let the package handle dismissal, and only add custom handlers for unsaved-changes confirmation.aria-hidden or inert. If the package's outlet does not do this, wrap the modal content in a headless dialog primitive (Headless UI, Radix Vue, Reka UI) rather than reinventing it.web group. Registering it globally can break on API routes.Users/EditModal on macOS may fail in production if the file is Users/EditModal.vue vs. a directory mismatch. Keep modal component paths identical in casing to the filesystem.composer update.How can I help you explore Laravel packages today?