Skip to content

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.ts or types/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-level Error page → GuestLayout
  • Pages/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 absent

auth.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 the route() helper (or the useRoute composable).
  • Navigate with Inertia's <Link> rather than <a> to preserve SPA state. Import it as InertiaLink so 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> — not Record<string, unknown>.

Common Patterns ​

  • Disable the submit button while processing.
  • Show inline errors near fields.
  • Use preserveScroll to avoid scroll jumps on validation failure.

Gotchas ​

  • When mixing file + JSON data, rely on forceFormData to 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-i18n with catalogs in resources/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() in app.ts.

Styling (Tailwind v4) ​

  • Import Tailwind with @import "tailwindcss"; in the resources/css entry.
  • Use the semantic theme tokens (bg-primary, text-foreground, border-line-2, bg-card, …). They handle light and dark mode automatically — see theme.md for 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 singleAttributePerLine in vite.config.ts).
  • 4-space indentation in both <template> and <script> (vueIndentScriptAndStyle).
  • Floating UI & tooltips via @headlessui-float/vue and vue-tippy (registered globally as the v-tooltip directive).
  • One confirm dialog for the whole app: emit confirm-modal on @/Plugins/eventBus.ts and ConfirmModal.vue (mounted by AppLayout) renders it. The payload takes title, message, acceptText, onConfirm, and optionally cancelText, plus alternateText / onAlternate for a third button between cancel and accept. Every choice hides the dialog itself, so a handler must not set show = false — and each open resets the optional fields, otherwise a previous dialog's third button leaks into the next one. ClientLayout still carries its own older copy, so the three-button form only works under AppLayout.

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 one router.reload({ only }). Autosave partials call useRealtime().guard(form) so a live update banners instead of discarding an edit in progress, and fields mirroring server state use syncFormFromServer (@/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/vue with browser tracing and the Pinia plugin).
  • DSN comes from VITE_SENTRY_LARAVEL_DSN; source maps are uploaded by @sentry/vite-plugin at build time.

Testing (Vitest) ​

  • Unit & component testing via vitest + @testing-library/vue / @vue/test-utils.
  • DOM environment simulated using happy-dom (see vitest.config.ts and vitestSetupFile.js).
  • vp test, vp test watch, vp test run --coverage.

Imports & Aliases ​

  • Path alias @ → resources/js (defined in tsconfig.json and resolved by Vite).
  • Linting is Oxlint, configured in the lint block of vite.config.ts. The rule import/extensions is set to always with ignorePackages: true — relative imports need their .ts / .vue extension, bare package specifiers do not.

Performance Guidelines ​

  • Pages are code-split automatically by Inertia's dynamic resolvePageComponent imports; Pages/Auth/* is pinned to its own chunk and vendor code to a vendor chunk (see vite.config.ts).
  • Lazy-load heavy modules (e.g., TipTap) with dynamic import.
  • Avoid large reactive objects in global stores; prefer granular refs.
  • Use watchEffect sparingly; prefer computed for 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.