Appearance
Frontend (Vue 3 + Inertia.js v3)
Stack Summary
- Vue 3 (Composition API,
<script setup lang="ts">) - Inertia.js v3 (
@inertiajs/vue3,@inertiajs/vite) - TypeScript
- Vite+ 0.3 toolchain (
vp): Vite build, Vitest, Oxlint, Oxfmt - Tailwind CSS v4 (with forms & typography plugins) + Preline component library
- Pinia for state management
- Vue I18n for localization
- Sentry SDK for error & performance monitoring
Directory Conventions
resources/js/
Pages/ # Page components mapped to routes (Auth/, Client/, Shop/, Profile/, Public/)
Components/ # Reusable presentational or functional components
Elements/ # Domain UI building blocks (Agenda, TableComponent, Form, …)
Directives/ # Custom Vue directives (click-outside)
Helper/ # Shared helper modules
Hooks/ # Composables (useSearch, useModal, useTable, useShortcuts, …)
Layouts/ # AppLayout, ClientLayout, GuestLayout (+ Partials)
Locales/ # vue-i18n catalogs (en, fr)
Modals/ # Modal components, resolved lazily by the modal plugin
Plugins/ # auth, broadcasting, i18n, modal, switchDark, eventBus, …
Stores/ # Pinia stores
Utils/ # FormatNumber, FormatString, StringDate, UrlParams, HydrateEnums, …
types/ # Domain TS definitions (generated.ts, enumRegistry.ts, Props.ts, forms.ts)
app.ts # Inertia app bootstrap- Do NOT edit
types/generated.tsortypes/enumRegistry.ts; both are regenerated from PHP. - Component tests live in
__tests__/folders next to the source.
Layout Resolution
Handled in app.ts:
Pages/Auth/*and the top-levelErrorpage →GuestLayoutPages/Client/*→ClientLayout- everything else →
AppLayout
Override with defineOptions({ layout: MyLayout }); opt out with noLayout: true.
Enum Hydration
Backend enums are serialized as { __enum, value } envelopes. hydrateEnums() (from @/Utils/HydrateEnums.ts) runs in the Inertia resolve hook on every page swap — initial load, visits, partial reloads and history restores — so props are already hydrated by the time a component renders. Read the hydrated value (or the helpers in @/types/enumRegistry.ts), never the raw envelope.
Auth Helpers
useAuth() (resources/js/Plugins/auth.ts) wraps page.props.auth: can() / cannot() for permissions, isA() for roles, and hasModule() for the shop's plan.
ts
const { hasModule } = useAuth();
hasModule(Module.INVENTORY.value); // false when the module is off or absentauth.features is typed as AuthProps.features in resources/js/types/Props.ts — Partial<Record<ModuleEntries, { enabled, limit }>>. It is empty for guests, client users, and anyone who is not a member of the current shop. Hiding an entry is presentation only; the module:<value> route gate is what refuses. See Plans & modules.
Routing & Navigation
- Server-generated routes via
tightenco/ziggy; always reference routes with theroute()helper (or theuseRoutecomposable). - Navigate with Inertia's
<Link>rather than<a>to preserve SPA state. Import it asInertiaLinkso it doesn't collide with the HTML<link>element.
HTTP & Forms
Two complementary APIs, both from @inertiajs/vue3:
useForm— classic Inertia form state (processing,errors,reset, visit callbacks). Still the default for page forms.useHttp<TForm, TResponse>(data)— for requests that return data rather than a page visit. Use it instead of axios; the codebase has no axios dependency.
useHttp rules:
- Bind
http.<field>directly in templates — do NOT mirror fields into separate refs. - Type the payload with an explicit object type or
Record<string, string | number | boolean | null | undefined>— notRecord<string, unknown>.
Common Patterns
- Disable the submit button while
processing. - Show inline errors near fields.
- Use
preserveScrollto avoid scroll jumps on validation failure.
Gotchas
- When mixing file + JSON data, rely on
forceFormDatato prevent ambiguous encoding. - Do not manually append CSRF tokens; Inertia handles this via Laravel middleware.
State Management (Pinia)
- Stores live under
Stores/. - Prefer explicit actions over directly mutating state for traceability.
- Use stores for cross-page state only (ephemeral local state stays in components).
Internationalization
vue-i18nwith catalogs inresources/js/Locales/{en,fr}, compiled by@intlify/unplugin-vue-i18n.- Server-side strings live in
lang/; backend translatable model attributes use Spatie Translatable. - The active locale is shared through Inertia props and applied by
setupI18n()inapp.ts.
Styling (Tailwind v4)
- Import Tailwind with
@import "tailwindcss";in theresources/cssentry. - Use the semantic theme tokens (
bg-primary,text-foreground,border-line-2,bg-card, …). They handle light and dark mode automatically — seetheme.mdfor the full token reference. Do not hard-code hex values or raw palette colors. - Prefer
gap-*over margin hacks for spacing in flex/grid containers. dark:variants are for the rare override a token can't express; dark mode is toggled via@/Plugins/switchDark.ts.
Design System (Preline + Streamline Icons)
The UI builds on Tailwind v4 plus the Preline component library for accessible primitives. Preline is initialized in app.ts (HSStaticMethods.autoInit), with a MutationObserver re-initializing it after DOM updates — dynamically inserted Preline markup works without manual wiring.
Icons come from StreamlineHQ. Drop the SVG into resources/icons/; the Vite watcher runs npm run generate:icons and the icon becomes available through the generated barrel. Do not inline raw external SVGs in pages.
Components & Conventions
- Single root element per reusable component. Inertia pages are the standing exception —
<inertia-head>sits beside the page wrapper — as are components rendering sibling<tr>s into a parent<tbody>. Vue 3 supports fragments; the rule is about component contracts, not a hard constraint. - Kebab-case component names in templates; PascalCase filenames.
- One attribute per line (Oxfmt
singleAttributePerLineinvite.config.ts). - 4-space indentation in both
<template>and<script>(vueIndentScriptAndStyle). - Floating UI & tooltips via
@headlessui-float/vueandvue-tippy(registered globally as thev-tooltipdirective). - One confirm dialog for the whole app: emit
confirm-modalon@/Plugins/eventBus.tsandConfirmModal.vue(mounted byAppLayout) renders it. The payload takestitle,message,acceptText,onConfirm, and optionallycancelText, plusalternateText/onAlternatefor a third button between cancel and accept. Every choice hides the dialog itself, so a handler must not setshow = false— and each open resets the optional fields, otherwise a previous dialog's third button leaks into the next one.ClientLayoutstill carries its own older copy, so the three-button form only works underAppLayout.
Realtime & Events
- WebSockets via
laravel-echo+pusher-js, connecting to the Reverb server; wiring lives in@/Plugins/broadcasting.ts, which owns the single Echo instance. - Model broadcasts arrive on
models.{morphClass}.{id}channels and carry identifiers only — treat them as a signal and refetch. - Show pages subscribe through
useRealtime({ channels, props })(@/Hooks/useRealtime.ts), which coalesces a burst into onerouter.reload({ only }). Autosave partials calluseRealtime().guard(form)so a live update banners instead of discarding an edit in progress, and fields mirroring server state usesyncFormFromServer(@/Hooks/useSyncedForm.ts). - Reach for
useBroadcasting()directly only outside that pattern.
See queues-realtime.md for the full picture, including the opt-in models must make before they broadcast at all.
Error & Performance Monitoring
- Sentry Vue integration initialized in
app.ts(@sentry/vuewith browser tracing and the Pinia plugin). - DSN comes from
VITE_SENTRY_LARAVEL_DSN; source maps are uploaded by@sentry/vite-pluginat build time.
Testing (Vitest)
- Unit & component testing via
vitest+@testing-library/vue/@vue/test-utils. - DOM environment simulated using
happy-dom(seevitest.config.tsandvitestSetupFile.js). vp test,vp test watch,vp test run --coverage.
Imports & Aliases
- Path alias
@→resources/js(defined intsconfig.jsonand resolved by Vite). - Linting is Oxlint, configured in the
lintblock ofvite.config.ts. The ruleimport/extensionsis set toalwayswithignorePackages: true— relative imports need their.ts/.vueextension, bare package specifiers do not.
Performance Guidelines
- Pages are code-split automatically by Inertia's dynamic
resolvePageComponentimports;Pages/Auth/*is pinned to its own chunk and vendor code to avendorchunk (seevite.config.ts). - Lazy-load heavy modules (e.g., TipTap) with dynamic import.
- Avoid large reactive objects in global stores; prefer granular refs.
- Use
watchEffectsparingly; prefercomputedfor derived state.
Common Patterns
Example page component:
vue
<script setup lang="ts">
import { Link as InertiaLink } from '@inertiajs/vue3';
import { ref } from 'vue';
import CarCard from '@/Components/Car/CarCard.vue';
const filter = ref('');
</script>
<template>
<div class="space-y-4">
<div class="flex items-center gap-2">
<input
v-model="filter"
type="text"
class="bg-layer border-line-2 rounded-lg border px-3 py-2"
placeholder="Search"
/>
<inertia-link
:href="route('shop.car.create')"
class="bg-primary text-primary-foreground hover:bg-primary-hover rounded-lg px-4 py-2"
>
New car
</inertia-link>
</div>
<car-card
v-for="car in cars"
:key="car.id"
:car="car"
/>
</div>
</template>Proceed to dependencies.md for the package landscape.