Frontend — React & Tailwind
The frontend is Inertia v2 + React 18 + TypeScript, styled with Tailwind v4, and held to an Airbnb ESLint + Prettier standard. React is UI only — no business logic, no data fetching layer beyond what Inertia gives you.
Structure & conventions
resources/js/
├── app.tsx # Inertia client entry
├── bootstrap.ts # axios + Ziggy globals
├── Pages/ # Inertia pages (Breeze + app) ← Capitalized
├── Components/ # Breeze UI primitives ← Capitalized
├── Layouts/ # AuthenticatedLayout, GuestLayout ← Capitalized
├── features/ # feature modules ← lowercase
│ └── posts/
│ ├── components/
│ ├── hooks/
│ ├── types.ts
│ └── index.ts # barrel — the feature's public API
├── shared/ # cross-feature components/hooks/utils
└── types/ # generated.d.ts, index.d.ts, global.d.ts
| Rule | Detail |
|---|---|
| Casing split | Breeze scaffold stays Capitalized; app code is lowercase features//shared/ |
| Pages are thin | A page wires props to a container/feature; it holds no logic |
| Feature isolation | No cross-feature runtime imports; share via shared/ or type-only imports |
| Barrel files | Import from @/features/posts, never deep paths |
| Dependency direction | types/ → shared/ → features/ → Pages/ → Layouts/ |
| Types from DTOs | Consume App.Data.*; never hand-redefine (see DTOs) |
Forms — the standard pattern
Inertia useForm for client state + error bag; a Laravel FormRequest is the authority on validation; errors render through Breeze's InputError. No client-side schema library.
import { useForm } from '@inertiajs/react';
import InputError from '@/Components/InputError';
export default function CreatePost() {
const form = useForm({ title: '', body: '' });
const submit = (e: React.FormEvent) => {
e.preventDefault();
form.post(route('posts.store'));
};
return (
<form onSubmit={submit}>
<input
value={form.data.title}
onChange={(e) => form.setData('title', e.target.value)}
/>
<InputError message={form.errors.title} />
<button type="submit" disabled={form.processing}>Save</button>
</form>
);
}
Field names in form.errors.* line up 1:1 with the FormRequest rule keys — that's what makes error display automatic. Navigate with Ziggy's route() and Inertia's <Link>/router; reach for axios only for non-navigational JSON.
Casing at the boundary — camelCase front, snake_case back
The backend speaks Laravel-native snake_case (columns, rule keys); the frontend speaks idiomatic camelCase (props, variables). Neither side bends — a transformer converts keys at the Inertia boundary.
// app/Traits/HasCamelCaseKeys.php
namespace App\Traits;
use Illuminate\Support\Str;
trait HasCamelCaseKeys
{
public function transformKeysToCamelCase($data)
{
if ($data instanceof \Illuminate\Database\Eloquent\Collection
|| $data instanceof \Illuminate\Database\Eloquent\Model) {
$data = $data->toArray();
} elseif (is_object($data)) {
$data = (array) $data;
}
return array_map(function ($value) {
return is_array($value) || is_object($value)
? $this->transformKeysToCamelCase($value)
: $value;
}, array_combine(
array_map([Str::class, 'camel'], array_keys($data)),
$data,
));
}
}
On a DTO-first project (the default here), do the conversion at the source: name DTO constructor properties camelCase inside fromModel() (publishedAt: $post->published_at). The generated TS types are then camelCase by construction — no runtime reflection, and the type system sees exactly the keys that ship. This is the preferred approach: a runtime array transform is invisible to TypeScript and can produce keys the generated types don't know about.
The trait then covers the leftovers:
- Per-response (trait) —
use HasCamelCaseKeys;for legacy or non-DTO JSON responses:'channelData' => $this->transformKeysToCamelCase($channelData). - Globally (middleware) — a middleware after
HandleInertiaRequestsrunning the transform over every Inertia props array. Only worth it on a codebase that isn't DTO-first yet.
Either way the rule is the same — snake never leaks into React, camel never leaks into Eloquent.
Note — Inbound is the mirror image:
useFormfield names must match theFormRequestrule keys. Keep form fields snake_case (they're the contract, not UI variables), or map them in the form hook before posting.
Tailwind v4
Tailwind v4 uses the Vite plugin and CSS-first @theme tokens — no tailwind.config.js.
// vite.config.ts
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [
laravel({ input: ['resources/js/app.tsx'], refresh: true }),
react(),
tailwindcss(),
],
});
Base design tokens
This is the @theme block every project starts from — copy it into resources/css/app.css and re-tint the colors per brand. The names stay stable across projects; only values change.
/* resources/css/app.css */
@import "tailwindcss";
@theme {
/* Colors */
--color-background: #0f172a;
--color-surface: #111827;
--color-primary: #3b82f6;
--color-secondary: #8b5cf6;
--color-accent: #06b6d4;
--color-text: #f8fafc;
--color-muted: #94a3b8;
--color-border: #334155;
/* Typography */
--font-sans: Inter, system-ui, sans-serif;
--font-mono: "JetBrains Mono", monospace;
/* Radius */
--radius-xs: 4px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
/* Shadows */
--shadow-card: 0 8px 24px rgb(0 0 0 / 0.08);
--shadow-focus: 0 0 0 3px rgb(59 130 246 / 0.35);
/* Animation */
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--duration-fast: 150ms;
--duration-normal: 250ms;
}
Every token becomes a utility automatically — no config, no plugin:
| Token group | Generated utilities (examples) |
|---|---|
--color-* |
bg-background, bg-surface, text-text, text-muted, border-border, bg-primary, ring-accent |
--font-* |
font-sans, font-mono |
--radius-* |
rounded-xs … rounded-xl (overrides Tailwind's default scale with these values) |
--spacing-* |
p-md, gap-lg, mt-xl — named steps alongside the numeric scale |
--shadow-* |
shadow-card, shadow-focus |
--ease-* / --duration-* |
ease-standard, duration-fast, duration-normal |
Keep all tokens in this one block — components never hardcode a hex, a radius, or a duration. Rebranding a project is editing eight color lines.
Tip — The tokens are also plain CSS variables at runtime (
var(--color-primary)), so the rare hand-written CSS rule and any JS that needs a brand color read from the same source of truth.Warning — Breeze currently scaffolds Tailwind v3 (
tailwind.config.js+@tailwinddirectives). On a v4 project, upgrade the generated auth pages: swap the directives for@import "tailwindcss", move theme values into@theme, and register@tailwindcss/vite. Do this once, right afterbreeze:install.
Airbnb ESLint + Prettier
The JS/TS quality gate is Airbnb's ruleset plus Prettier for formatting, with tsc --strict as the type gate. (Packages are installed on Create the project.)
Airbnb's shared configs predate ESLint 9's flat config, so consume them through FlatCompat:
// eslint.config.js
import { FlatCompat } from '@eslint/eslintrc';
import prettier from 'eslint-config-prettier';
const compat = new FlatCompat({ baseDirectory: import.meta.dirname });
export default [
...compat.extends(
'airbnb',
'airbnb/hooks',
'airbnb-typescript',
),
prettier, // must be last — turns off rules Prettier owns
{
languageOptions: { parserOptions: { project: './tsconfig.json' } },
rules: {
'react/react-in-jsx-scope': 'off', // React 18 + automatic runtime
'import/prefer-default-export': 'off',
'react/jsx-props-no-spreading': 'off', // Inertia/Breeze spread props
},
},
];
// package.json
{
"scripts": {
"lint": "eslint resources/js --ext .ts,.tsx",
"lint:fix": "eslint resources/js --ext .ts,.tsx --fix",
"format": "prettier --write resources/js",
"build": "tsc && vite build"
}
}
| Gate | Command | Enforces |
|---|---|---|
| Lint | npm run lint |
Airbnb correctness + style rules |
| Format | npm run format |
Prettier (single source of formatting) |
| Types | npx tsc |
strict type safety; blocks build |
Tip —
eslint-config-prettier(last in the array) disables every ESLint rule that would fight Prettier. Let ESLint judge code, Prettier own formatting — never both on the same concern.Note — If flat-config friction with the Airbnb TS preset gets in the way, the fallback is to pin ESLint 8 and use a classic
.eslintrc.cjswith the sameextends. Prefer the flat-config route to stay current.