Three tools enforce the baseline. All three run in CI on every pull request, and all three are runnable locally with the same result.
| Concern | Tool | Command |
|---|---|---|
| Formatting | Laravel Pint | composer lint |
| Static analysis | PHPStan + Larastan | composer analyse |
| Tests | Pest | composer test |
| All gates at once | — | composer check |
Pint is the only formatter. Do not add a second one (PHP CS Fixer, ECS, etc.).
composer lint # fix formatting in place
composer test:lint # check only — fails if anything is unformatted (CI mode)There is no committed pint.json; the project uses Pint's default laravel
preset. Add a pint.json only with a documented reason to diverge.
Run composer lint before committing. If CI fails on style, run it and commit
the result — never hand-format to match.
Configuration lives in phpstan.neon.dist (committed). A developer may add an
untracked phpstan.neon locally to experiment, but the committed baseline is
the source of truth.
composer analyse- Level 6. This gives full parameter/return type enforcement without fighting framework-shipped config defaults. Raise it one level at a time in a dedicated PR once the codebase clears the next level cleanly.
- Analysed paths:
app,bootstrap/app.php,config,database,routes,public/index.php. Test files are not analysed by PHPStan — Pest's functional API is not statically resolvable without full Pest support; test structure is covered bytests/Unit/ArchTest.phpand Pint instead. - Do not weaken the baseline to get green. No broad
ignoreErrors, no baseline file, no@phpstan-ignoresprinkled to silence a real problem. Fix the cause. A genuinely unavoidable ignore carries a comment explaining why.
- Source of truth:
resources/css/tokens.css, plain CSS custom properties (--nx-*) with no Tailwind or Vue syntax, so the Filament admin can reuse them (ADR-0020).resources/css/app.cssmaps them into Tailwind's@theme. - Tailwind's defaults are cleared for colour, radius, shadow, font, type
scale and tracking. Utilities such as
bg-red-500do not exist; use the semantic ones (bg-surface,text-fg-muted,text-danger,rounded-md). - Components use semantic tokens (
--nx-color-*), never--nx-palette-*. - Contrast is tested.
resources/js/design/contrast-pairs.tsderives the required pairs from token names (text 4.5:1, controls and focus 3:1, NFR-A11Y-03). A new colour token fails the suite until it is either covered by a pair or listed as decorative with a reason. - Gallery:
/dev/tokensrenders every token. It is registered only whenAPP_ENV=local(routes/local.php) and 404s everywhere else. - Arabic switches to IBM Plex Sans Arabic and taller line heights through
:lang(ar), so it also applies to Arabic content inside other locales.
- Locales:
en(primary and fallback),ms,ar(RTL), per ADR-0007. The PHP source of truth isApp\Http\Localization\Locale; the frontend mirror isresources/js/i18n/locales.ts. - URLs (NFR-I18N-04): storefront routes are declared once inside
LocalizedRoutes::group()inroutes/web.phpand registered for/,/msand/ar, named{locale}.{name}(e.g.ar.storefront.home)./en/...redirects (301) to the unprefixed URL. Every localised page emitshreflangalternates plusx-default. They are the sharedalternatesprop: Blade renders them for the first response and Inertia'sserverHead(resources/js/i18n/alternates.ts) replaces them on every client-side visit. The language switcher reads the same prop, never the DOM. - Strings:
resources/js/i18n/messages/{locale}/{namespace}.json; the key is{namespace}.{path}(e.g.storefront.home.title). Useconst { t } = useI18n(); parameters are{name}, and plurals are objects keyed byIntl.PluralRulescategory (Arabic needs all six). Each page loads only its own locale plus English. - Coverage (NFR-I18N-01):
resources/js/i18n/coverage.test.tsfails CI when a locale is missing or has extra keys, a plural lacks a category the language uses, placeholders differ from English, or source callst()with a key that does not exist. Every UI string ships in all three locales. - Formatting (NFR-I18N-03): use
formatMoney,formatNumber,formatDateandformatDateTimefromuseI18n(), nevertoFixedor hand-built strings. Dates are shown inAsia/Kuala_Lumpur; Arabic uses Latin digits; money isRM 2,418.50in every locale (render it LTR-isolated inside RTL text). - Direction (F4, NFR-I18N-02): logical utilities only (
ms-,pe-,start-,text-start,border-s,rounded-e). Enforced byresources/js/rules/logical-direction.test.ts; a line may opt out withf4-allowand a reason.
- Location:
resources/js/shared/ui/: shared by storefront and vendor pages (rule F1). The gallery at/dev/components(local only) shows every component in en, ms and ar. - Components:
AppButton,TextLink,FormField+TextInput/SelectInput,CheckboxInput,StatusBadge(generictone),SurfaceCard,ModalDialog,ToastRegion(+toasts.ts),LoadingSkeleton,EmptyState,MoneyPrice,PageNav. Names are multi-word to stay clear of HTML elements and Inertia'sLink. - Forms: put the control inside
<FormField label hint error>; it gets its id,aria-describedby(error, then hint),aria-invalidandrequiredautomatically (NFR-A11Y-02). Email, URL and phone inputs are always LTR. - Dialogs use the native
<dialog>withshowModal()(inert background, focus trap, Escape) and return focus to the trigger. - Toasts: call
push(tone, message)in the browser, or sendInertia::flash('toast', ['tone' => 'success', 'key' => '…', 'params' => [...]])from the server; the key is translated in the browser. Success and info toasts auto-dismiss (paused on hover or focus); errors stay until dismissed. - Money is shown only through
MoneyPrice(LTR-isolated, no arithmetic, F3). - Tests: every component has Vitest + Vue Test Utils tests including
keyboard/focus behaviour where it applies and an axe check
(
resources/js/test/axe.ts, NFR-A11Y-04; colour contrast is covered by the token tests instead). jsdom has no<dialog>, soresources/js/test/setup.tsstubsshowModal/close; real browser behaviour is checked manually and in Phase 9 browser tests.
- Storefront (
resources/js/storefront/layouts/StorefrontLayout.vue): skip link, header (brand, language switcher, cart count),<main id="main">, toasts. Checkout (CheckoutLayout.vue): the same frame with a quiet header and no cart or browsing links. Vendor (resources/js/vendor/layouts/VendorLayout.vue): static menu, no auth until ADR-0011; sections without a page are text, never dead links. - A page picks its layout with
defineOptions({ layout: StorefrontLayout }). Inertia keeps it mounted across visits. - Shared frame pieces (
SkipLink,LocaleSwitcher) live inresources/js/shared/layout/so both surfaces can use them (F1). - Local previews:
/dev/layouts/checkoutand/dev/layouts/vendor, in every locale (/ms/...,/ar/...). - Which screen uses which layout, and what each one waits for:
screen-inventory.md.
App\Http\Errors\ErrorPages (registered in bootstrap/app.php) renders
403, 404, 419, 500 and 503 as the Inertia page Errors/ErrorPage, in the
URL's locale. It receives only the status code (NFR-SEC-07). JSON clients
keep JSON errors; with APP_DEBUG=true a 500 shows Laravel's debug page.
composer checkThis runs composer validate --strict, composer audit, Pint (check mode),
PHPStan, and the test suite — the exact set CI enforces
(.github/workflows/ci.yml).