Speeding Up Laravel Tests with tmpfs (MySQL in RAM)
If your Laravel test suite is painfully slow and does a lot of database work (multi-tenancy, migrations, seeding), the bottleneck is almost…
When building SaaS applications with Laravel and Inertia.js, you'll typically start with a nested layout approach. But there's a more powerful pattern available: persistent layouts. Let me show you the difference through a practical example.
In the traditional approach, every page component imports and wraps itself in a layout component:
<!-- Traditional Page Component (Dashboard.vue) -->
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import DashboardStats from '@/Components/DashboardStats.vue';
import { ref } from 'vue';
// Local state that will be reset on every navigation
const isSidebarOpen = ref(true);
</script>
<template>
<AppLayout>
<template #header>
<h2 class="font-semibold text-xl">Dashboard</h2>
</template>
<div>
<button @click="isSidebarOpen = !isSidebarOpen">
Toggle Sidebar
</button>
<DashboardStats />
</div>
</AppLayout>
</template>
With persistent layouts, your page component becomes simpler and cleaner:
<!-- Persistent Layout Page Component (Dashboard.vue) -->
<script setup>
import DashboardStats from '@/Components/DashboardStats.vue';
</script>
<template>
<div>
<DashboardStats />
</div>
</template>
That's it! When using persistent layouts with a default layout configured in app.js, you don't even need to specify the layout in each page component.
For pages that need a different layout than the default one:
<!-- Admin page with custom layout -->
<script setup>
import AdminLayout from '@/Layouts/AdminLayout.vue';
import { defineOptions } from 'vue';
// Override the default layout
defineOptions({
layout: AdminLayout
});
</script>
<template>
<div>
<h1>Admin Dashboard</h1>
<!-- Admin page content -->
</div>
</template>
For pages like login screens that shouldn't use any layout:
<!-- Login page with no layout -->
<script setup>
import { defineOptions } from 'vue';
// Set layout to null to avoid using any layout
defineOptions({
layout: null
});
</script>
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-100">
<div class="w-full max-w-md">
<h1 class="text-center text-2xl mb-6">Login</h1>
<form class="bg-white rounded-lg shadow-md p-8">
<!-- Login form fields -->
</form>
</div>
</div>
</template>
The key differences:
app.jsNormally, when you navigate between pages in an Inertia app, the entire page component tree (including your layout) is torn down and rebuilt. Persistent layouts change this.
With persistent layouts:
MainLayout.vue) doesn't get destroyed and recreated on navigation. This means any local state within that layout (like a sidebar's open/closed status, component state, or even the scroll position of the layout itself) is preserved automatically.Dashboard.vue or UserProfile.vue) become simpler, as they no longer need to explicitly import and wrap themselves in the layout.Let's look at how to implement persistent layouts in your Inertia.js application:
The magic starts in your main JavaScript entry point (resources/js/app.js) within the createInertiaApp setup:
// resources/js/app.js
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import MainLayout from './Layouts/MainLayout.vue'; // Import your default layout
const appName = import.meta.env.VITE_APP_NAME || 'Laravel App';
createInertiaApp({
title: (title) => `${title} - ${appName}`,
resolve: async (name) => {
const page = await resolvePageComponent(
`./Pages/${name}.vue`,
import.meta.glob('./Pages/**/*.vue')
);
// Assign MainLayout as the default if the page doesn't specify its own
page.default.layout = page.default.layout || MainLayout;
return page;
},
setup({ el, App, props, plugin }) {
return createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el);
},
});
This configuration ensures that:
MainLayout.vue is used as the default layout for all pagesYour layout component will now use the default slot to render the current page:
<!-- resources/js/Layouts/MainLayout.vue -->
<script setup>
import { computed } from "vue";
import { usePage } from "@inertiajs/vue3";
import Navigation from "@/Components/Navigation.vue";
// IMPORTANT: Use computed properties for reactivity with page props
const page = usePage();
const title = computed(() => page.props.title || 'Default Title');
const user = computed(() => page.props.auth.user);
</script>
<template>
<div class="min-h-screen bg-gray-100">
<Navigation :user="user" />
<header class="bg-white shadow">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<h1 class="text-2xl font-semibold text-gray-900">{{ title }}</h1>
</div>
</header>
<main>
<div class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<!-- The page component is rendered in this slot -->
<slot />
</div>
</main>
</div>
</template>
The most important consideration with persistent layouts is how shared data is handled. Since the layout doesn't remount on navigation, you must use Vue's computed properties to ensure your layout stays in sync with the latest data:
<script setup>
import { computed } from "vue";
import { usePage } from "@inertiajs/vue3";
// This is the correct way
const user = computed(() => usePage().props.auth.user);
const notifications = computed(() => usePage().props.notifications);
// DON'T do this - it won't update on navigation
// const user = ref(usePage().props.auth.user);
</script>
Computed properties create a reactive connection to Inertia's page props, ensuring that your layout always displays the most current data.
To convert your existing pages to use persistent layouts:
null for no layoutBefore:
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
</script>
<template>
<AppLayout>
<template #header>
<h2>User Profile</h2>
</template>
<div>Profile content here</div>
</AppLayout>
</template>
After:
<script setup>
// No layout import needed
</script>
<template>
<div>Profile content here</div>
</template>
And in your controller:
// Controller passes title to be used by layout
return Inertia::render('Profile/Show', [
'title' => 'User Profile',
// other data...
]);
Persistent layouts offer several significant benefits for SaaS applications:
These benefits are especially valuable for data-heavy SaaS applications where users frequently navigate between different sections.
There are a few considerations to keep in mind:
The computed property pattern shown earlier is important for avoiding stale data issues.
Implementing persistent layouts in your Inertia.js application can significantly improve both performance and user experience. By following the steps outlined in this post, you can transform your application to take full advantage of this powerful pattern.
The initial setup requires some careful thinking about data flow, but the benefits in terms of smoother navigation and state preservation make it well worth the effort for sophisticated SaaS applications.
Give Vroni a GitHub issue, bug report, spec, or rough idea. It reads the repo, plans the change, writes code, runs checks, and works toward a review-ready pull request.
Take a look at vroni.com