Architecture Svelte
Everything framework-specific lives in resources/js. The Laravel side (modules, tenancy, auth) is described in Core architecture.
Overview
A request flows from a Laravel controller to a Svelte page like this:
Controller: Inertia::render('users/Index', $props)
→ @inertiajs/vite resolves resources/js/pages/users/Index.svelte
→ app.ts picks the default layout from the page name
→ page reads its own props with $props() and shared props from `page`resources/js/app.ts creates the app. There is no resolve callback; the @inertiajs/vite plugin in vite.config.ts resolves pages. The layout callback chooses a default layout by page name:
| Page name starts with | Default layout |
|---|---|
auth/ | AuthLayout |
settings/ | AppLayout wrapping settings/Layout.svelte |
| anything else | AppLayout |
A page can override this from <script module>. See Layouts.
app.ts also calls two setup functions:
initializeTheme()fromlib/theme.svelte.tsapplies light, dark or system mode.initializeFlashToast()fromlib/flash-toast.tsturns server flash data into toasts.
View app.ts
createInertiaApp({
title: (title) => (title ? `${title} - ${appName}` : appName),
layout: (name) => {
switch (true) {
case name.startsWith('auth/'):
return AuthLayout;
case name.startsWith('settings/'):
return [AppLayout, SettingsLayout];
default:
return AppLayout;
}
},
progress: { color: '#4B5563' },
});
initializeTheme();
initializeFlashToast();Folder map
| Folder | Purpose |
|---|---|
app.ts | createInertiaApp, default layouts, theme, flash toasts |
pages/ | Inertia pages (PascalCase .svelte): auth/, errors/, roles/, settings/, setup/, tenants/, users/, plus Dashboard.svelte and Home.svelte |
layouts/ | AppLayout.svelte, AuthLayout.svelte, their variants in app/ and auth/, and settings/Layout.svelte |
components/common/ | The Common* form kit and ConfirmDialog |
components/ui/ | shadcn-svelte primitives (bits-ui) |
components/ | App shell and feature components (AppHead, AppSidebar, AppHeader, UserMenuContent, …) |
lib/ | Shared logic; rune modules end in .svelte.ts |
types/ | Hand-written and generated TypeScript types |
lang/ | Generated translation JSON (php artisan erag:generate-lang) |
routes/, actions/, wayfinder/ | Generated by Wayfinder, ignored by git |
Naming
| Thing | Convention | Example |
|---|---|---|
| Page | PascalCase .svelte under a lowercase folder | pages/tenants/Index.svelte |
| Inertia page name | Path without extension | Inertia::render('tenants/Index') |
| Page-only child component | Partials/ next to the page | pages/users/Partials/UserFormModal.svelte |
| Shared component | PascalCase .svelte | components/UserMenuContent.svelte |
| Form component | Common prefix | components/common/CommonSelect.svelte |
| Shared logic | camelCase module in lib/; .svelte.ts when it uses runes | lib/permission.ts, lib/confirmDialog.svelte.ts |
| UI primitive | shadcn-svelte folder with index.ts | components/ui/dialog |
Components use <script lang="ts"> with runes ($props(), $state, $derived, $effect) and snippets ({#snippet} / {@render}) instead of slots.
ESLint (eslint-plugin-svelte) enforces type-only imports, alphabetised import groups, 1TBS braces and blank lines around control statements.
lib modules
| File | Exports |
|---|---|
confirmDialog.svelte.ts | useConfirmDialog() → confirm(options), setLoading() |
currentUrl.svelte.ts | useCurrentUrl(), isCurrentUrl(), isCurrentOrParentUrl(), currentUrlState() |
debounce.svelte.ts | useDebounce(getter, delay) → { value }, useDebounceFn(fn, delay) |
flash-toast.ts | initializeFlashToast() |
initials.ts | getInitials(), useInitials() |
language.ts | useLanguage(), DEFAULT_LANGUAGE_VALUE, languageLabel(), toLocaleValue(), transformLocale(), changeLanguage() |
permission.ts | can(), usePermission() |
secret.ts | generateSecret(), useSecret() |
slug.ts | slugify(), useSlug() |
statusBadge.ts | useStatusBadge(): workspace status labels and badge classes |
subdomain.ts | extractSubdomain(), useSubdomain(), formatDomain() |
theme.svelte.ts | initializeTheme(), useAppearance(), updateAppearance(), updateTheme(), themeState() |
twoFactorAuth.svelte.ts | useTwoFactorAuth(), twoFactorAuthState() (QR code, setup key, recovery codes via useHttp) |
users/assignUserPermissions.svelte.ts | State for the assign-permissions dialog |
utils.ts | cn(), toUrl() |
Generated files
| Generator | Output | Import as |
|---|---|---|
Wayfinder (Vite plugin or php artisan wayfinder:generate --with-form) | routes/, actions/, wayfinder/ | @/routes/users, @/actions/Modules/Settings/Http/Controllers/ProfileController |
php artisan typescript:transform | types/Modules/<Module>/Data and .../Enums from Spatie Data classes and enums in app/ and Modules/ | import type { DomainData } from '@/types/Modules/Tenant/Data' |
php artisan erag:generate-lang | lang/<locale>/…json | Read by svelteLang() |
Hand-written page prop types sit next to the generated ones (types/Tenants/tenants.ts, types/Users/users.ts, types/Roles/roles.ts) and are re-exported from @/types. How to call Wayfinder functions is covered in Inertia.
TIP
composer lint runs typescript:transform before the frontend linters, so generated types stay in sync with the PHP classes.
Shared props
HandleInertiaRequests shares these props on every page. They are typed as SharedData in types/global.d.ts.
| Prop | Type | Content |
|---|---|---|
name | string | App name (per-domain "App name", or the tenant company on tenant domains) |
auth | Auth | user, isTenant, permissions, isSuperAdmin |
locale, userLocale, defaultLocale | string | Active, user-selected and domain default locale |
languages | LanguageOption[] | Selectable languages |
menus, setupMenus | NavItem[] | Sidebar trees, already filtered by permission |
layout | LayoutSettings | app_layout, sidebar_variant, sidebar_collapsible, auth_layout |
sidebarOpen | boolean | Sidebar cookie state |
appUrl, domain | string (domain can be null) | App URL and central domain |
permissionsConfig | not declared in SharedData (unknown) | Grouped permissions used by the permission dialogs |
Read shared props from the reactive page object, and page-specific props from $props():
<script lang="ts">
import { page } from '@inertiajs/svelte';
let { canResetPassword }: { canResetPassword: boolean } = $props();
const user = $derived(page.props.auth.user);
</script>types/global.d.ts registers SharedData with Inertia, so page.props is typed everywhere. It also declares a global PageProps<T> type (T & SharedData).
Permissions on the frontend
Use usePermission() from lib/permission.ts instead of reading page.props.auth by hand.
can(...names)returnstruefor super admins, or when the user has any of the given permissions.- Pass the central and tenant names together, because the same page runs on both.
<script lang="ts">
import { usePermission } from '@/lib/permission';
const { can } = usePermission();
</script>
{#if can('Create User', 'Create Tenant User')}
<CommonButton>…</CommonButton>
{/if}INFO
Hiding a button is only a UI convenience. Routes are protected on the server by permission: middleware. See Users, roles & permissions.
The same pattern applies to language: useLanguage() from lib/language.ts returns getters (language.languages, language.userLocale) and changeLanguage(). Don't destructure the reactive values.
Translations
Import the helper from the Svelte subpath (@erag/lang-sync-inertia/svelte). The package root import breaks the build.
<script lang="ts">
import { svelteLang } from '@erag/lang-sync-inertia/svelte';
const { __ } = svelteLang();
</script>
<AppHead title={__('modules.user.index.title')} />- Keys map to
lang/<locale>/modules/<feature>.php. php artisan erag:generate-langexports them toresources/js/lang/<locale>/modules/<feature>.json.- Placeholders work as in Laravel:
__('modules.user.index.delete_confirm.message', { name: user.name }). svelteLang()also returnstrans,transChoiceandtrans_choice.
More in Localization.
Related files
resources/js/app.ts: app entry, default layoutsvite.config.ts: Inertia, Svelte, Tailwind and Wayfinder (formVariants: true) pluginsresources/js/types/global.d.ts:SharedData,PageProps<T>app/Http/Middleware/HandleInertiaRequests.php: shared propsresources/js/lib/: shared logiceslint.config.js: lint rules
INFO
vite.config.ts sets LARAVEL_BYPASS_ENV_CHECK when it is loaded by svelte-check, so type checking works without a running Laravel environment.