start-vibing-stacks 2.8.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2 -1
- package/dist/setup.js +10 -2
- package/dist/types.d.ts +8 -0
- package/package.json +1 -1
- package/stacks/frontend/react-api/skills/axios-laravel-api/SKILL.md +466 -0
- package/stacks/frontend/react-api/skills/react-api-standards/SKILL.md +509 -0
- package/stacks/frontend/react-inertia/skills/inertia-react/SKILL.md +11 -2
- package/stacks/frontend/react-inertia/skills/react-standards/SKILL.md +9 -2
- package/stacks/php/skills/api-design/SKILL.md +281 -47
- package/stacks/php/skills/api-security/SKILL.md +128 -49
- package/stacks/php/skills/inertia-react/SKILL.md +16 -3
- package/stacks/php/skills/laravel-api-architecture/SKILL.md +650 -0
- package/stacks/php/skills/laravel-inertia-i18n/SKILL.md +11 -3
- package/stacks/php/skills/laravel-patterns/SKILL.md +123 -61
- package/stacks/php/stack.json +19 -6
- package/templates/CLAUDE-php.md +202 -101
package/dist/index.js
CHANGED
|
@@ -59,7 +59,7 @@ if (FLAGS.help) {
|
|
|
59
59
|
--version, -v Show version
|
|
60
60
|
|
|
61
61
|
${chalk.bold('Supported Stacks:')}
|
|
62
|
-
🐘 PHP 8.3+ Laravel 12 + Octane +
|
|
62
|
+
🐘 PHP 8.3+ Laravel 12 + Octane + Sanctum SPA API + React/Vite/Axios
|
|
63
63
|
📦 Node.js/TS Next.js, Nuxt, Express, Fastify
|
|
64
64
|
🐍 Python 3.12+ FastAPI, Django, Flask
|
|
65
65
|
`);
|
|
@@ -283,6 +283,7 @@ async function main() {
|
|
|
283
283
|
database,
|
|
284
284
|
frontend,
|
|
285
285
|
frontendSkillsDir: selectedFrontend?.skillsDir,
|
|
286
|
+
frontendBaseSkillsDir: selectedFrontend?.baseSkillsDir,
|
|
286
287
|
deploy,
|
|
287
288
|
path: projectDir,
|
|
288
289
|
createdAt: new Date().toISOString(),
|
package/dist/setup.js
CHANGED
|
@@ -89,13 +89,21 @@ export async function setupProject(projectDir, config, options = {}) {
|
|
|
89
89
|
const stackConfigDir = join(PACKAGE_ROOT, 'stacks', config.stack, 'config');
|
|
90
90
|
copyDirRecursive(stackConfigDir, join(claudeDir, 'config'), options.force);
|
|
91
91
|
// 8. Copy frontend-specific skills if applicable
|
|
92
|
+
// Order matters: base first, then specific (specific can override base).
|
|
92
93
|
if (config.frontend && config.frontend !== 'none') {
|
|
94
|
+
let feSkillCount = 0;
|
|
95
|
+
if (config.frontendBaseSkillsDir) {
|
|
96
|
+
const baseDir = join(PACKAGE_ROOT, 'stacks', 'frontend', config.frontendBaseSkillsDir);
|
|
97
|
+
if (existsSync(baseDir)) {
|
|
98
|
+
feSkillCount += copyDirRecursive(join(baseDir, 'skills'), join(claudeDir, 'skills'), options.force);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
93
101
|
const frontendId = config.frontendSkillsDir || config.frontend;
|
|
94
102
|
const frontendDir = join(PACKAGE_ROOT, 'stacks', 'frontend', frontendId);
|
|
95
103
|
if (existsSync(frontendDir)) {
|
|
96
|
-
|
|
97
|
-
spinner.text = `Loaded ${feSkillCount} frontend skills`;
|
|
104
|
+
feSkillCount += copyDirRecursive(join(frontendDir, 'skills'), join(claudeDir, 'skills'), options.force);
|
|
98
105
|
}
|
|
106
|
+
spinner.text = `Loaded ${feSkillCount} frontend skills`;
|
|
99
107
|
}
|
|
100
108
|
// 9. Write active-project.json
|
|
101
109
|
writeFileSync(join(claudeDir, 'config', 'active-project.json'), JSON.stringify(config, null, 2));
|
package/dist/types.d.ts
CHANGED
|
@@ -47,6 +47,12 @@ export interface FrontendOption {
|
|
|
47
47
|
icon: string;
|
|
48
48
|
frameworks?: string[];
|
|
49
49
|
skillsDir?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Optional sibling folder under `stacks/frontend/` whose `skills/` directory
|
|
52
|
+
* is copied BEFORE this option's own `skills/`. Lets a stack inherit base
|
|
53
|
+
* skills (e.g. `react-api` re-uses `react/skills/` plus its own additions).
|
|
54
|
+
*/
|
|
55
|
+
baseSkillsDir?: string;
|
|
50
56
|
default?: boolean;
|
|
51
57
|
}
|
|
52
58
|
export interface DeployTarget {
|
|
@@ -72,6 +78,8 @@ export interface ProjectConfig {
|
|
|
72
78
|
database: string;
|
|
73
79
|
frontend: string;
|
|
74
80
|
frontendSkillsDir?: string;
|
|
81
|
+
/** Inherited base frontend folder copied BEFORE the main frontend skills. */
|
|
82
|
+
frontendBaseSkillsDir?: string;
|
|
75
83
|
deploy: string;
|
|
76
84
|
path: string;
|
|
77
85
|
createdAt: string;
|
package/package.json
CHANGED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: axios-laravel-api
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
description: Axios HTTP client for Laravel 12 + Sanctum SPA — `withCredentials`,
|
|
5
|
+
`withXSRFToken`, `/sanctum/csrf-cookie` flow, and `401/403/419/422/5xx`
|
|
6
|
+
interceptors. Use when wiring a React (or any SPA) frontend to a Laravel
|
|
7
|
+
cookie-authenticated JSON API. Pairs with `laravel-api-architecture` and
|
|
8
|
+
`react-api-standards`.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Axios + Laravel Sanctum SPA Client
|
|
12
|
+
|
|
13
|
+
**ALWAYS invoke when creating the API client, login flow, or CSRF/cookie
|
|
14
|
+
configuration for a React (Vite) SPA hitting a Laravel 12 API.**
|
|
15
|
+
|
|
16
|
+
## Why this exists
|
|
17
|
+
|
|
18
|
+
A modern React SPA must NOT block the first paint to wait for a controller to
|
|
19
|
+
SELECT from the database. Instead:
|
|
20
|
+
|
|
21
|
+
1. Laravel serves a **single static shell** (Vite-built `index.html` /
|
|
22
|
+
`app.blade.php`) — instant.
|
|
23
|
+
2. React mounts and renders **page shell + skeleton** — instant.
|
|
24
|
+
3. Each page calls `api.get('/api/whatever')` (Axios) — async.
|
|
25
|
+
4. Laravel's API Controller delegates to a Service and returns a Resource → JSON.
|
|
26
|
+
|
|
27
|
+
This eliminates the `Inertia::render()` round-trip where the controller hits the
|
|
28
|
+
DB before the first byte of HTML is sent.
|
|
29
|
+
|
|
30
|
+
## Architecture
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
┌──────────────────────────────────────────────────────────────────┐
|
|
34
|
+
│ SAME ORIGIN (RECOMMENDED) │
|
|
35
|
+
│ │
|
|
36
|
+
│ Browser ──GET /───▶ Laravel (web.php catch-all → Vite shell) │
|
|
37
|
+
│ Browser ──GET /sanctum/csrf-cookie──▶ XSRF-TOKEN cookie set │
|
|
38
|
+
│ Browser ──POST /login──▶ Laravel session cookie set │
|
|
39
|
+
│ Browser ──GET /api/x──▶ auth:sanctum (cookie) ──▶ JSON │
|
|
40
|
+
└──────────────────────────────────────────────────────────────────┘
|
|
41
|
+
|
|
42
|
+
┌──────────────────────────────────────────────────────────────────┐
|
|
43
|
+
│ CROSS-ORIGIN (api.example.com ↔ app.example.com)│
|
|
44
|
+
│ │
|
|
45
|
+
│ Both hosts MUST be on the same parent domain (`example.com`) │
|
|
46
|
+
│ for the session cookie to be shared. Set: │
|
|
47
|
+
│ SESSION_DOMAIN=.example.com │
|
|
48
|
+
│ SANCTUM_STATEFUL_DOMAINS=app.example.com │
|
|
49
|
+
│ CORS supports_credentials: true + allowed_origins: app URL │
|
|
50
|
+
└──────────────────────────────────────────────────────────────────┘
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## The Axios Instance — `resources/js/lib/api.js`
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
import axios from 'axios';
|
|
57
|
+
|
|
58
|
+
const api = axios.create({
|
|
59
|
+
baseURL: import.meta.env.VITE_API_URL || '/',
|
|
60
|
+
withCredentials: true, // send + receive session cookies
|
|
61
|
+
withXSRFToken: true, // auto-attach X-XSRF-TOKEN header from cookie
|
|
62
|
+
headers: {
|
|
63
|
+
Accept: 'application/json',
|
|
64
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
65
|
+
},
|
|
66
|
+
timeout: 15000,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ── 1. CSRF cookie cache (avoid hitting /sanctum/csrf-cookie on every POST) ──
|
|
70
|
+
let csrfReady = false;
|
|
71
|
+
async function ensureCsrf() {
|
|
72
|
+
if (csrfReady) return;
|
|
73
|
+
await api.get('/sanctum/csrf-cookie');
|
|
74
|
+
csrfReady = true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── 2. Request interceptor — fetch CSRF cookie before any unsafe method ──
|
|
78
|
+
api.interceptors.request.use(async (config) => {
|
|
79
|
+
const method = (config.method ?? 'get').toLowerCase();
|
|
80
|
+
if (['post', 'put', 'patch', 'delete'].includes(method)) {
|
|
81
|
+
await ensureCsrf();
|
|
82
|
+
}
|
|
83
|
+
return config;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ── 3. Response interceptor — centralized error handling ──
|
|
87
|
+
api.interceptors.response.use(
|
|
88
|
+
(response) => response,
|
|
89
|
+
async (error) => {
|
|
90
|
+
const status = error.response?.status;
|
|
91
|
+
|
|
92
|
+
if (status === 401) {
|
|
93
|
+
// Session expired or not authenticated → bounce to login
|
|
94
|
+
csrfReady = false;
|
|
95
|
+
if (window.location.pathname !== '/login') {
|
|
96
|
+
window.location.assign(
|
|
97
|
+
`/login?redirect=${encodeURIComponent(window.location.pathname)}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (status === 419) {
|
|
103
|
+
// CSRF token mismatch — re-prime cookie and retry ONCE
|
|
104
|
+
csrfReady = false;
|
|
105
|
+
await ensureCsrf();
|
|
106
|
+
return api.request(error.config);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (status === 403) {
|
|
110
|
+
// Authorization (Policy) failed — surface as toast
|
|
111
|
+
window.dispatchEvent(new CustomEvent('api:forbidden', {
|
|
112
|
+
detail: error.response?.data?.message ?? 'Forbidden',
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (status === 422) {
|
|
117
|
+
// Validation errors — return shaped error so callers can render
|
|
118
|
+
const errors = error.response?.data?.errors ?? {};
|
|
119
|
+
error.validation = errors;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (status >= 500) {
|
|
123
|
+
window.dispatchEvent(new CustomEvent('api:server-error', {
|
|
124
|
+
detail: error.response?.data?.message ?? 'Server error',
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return Promise.reject(error);
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
export default api;
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Rules:**
|
|
136
|
+
|
|
137
|
+
- `withCredentials: true` is MANDATORY — without it, session cookie is dropped.
|
|
138
|
+
- `withXSRFToken: true` is MANDATORY (Axios ≥ 1.4) — auto-copies `XSRF-TOKEN`
|
|
139
|
+
cookie value into the `X-XSRF-TOKEN` header.
|
|
140
|
+
- The `csrfReady` cache avoids hitting `/sanctum/csrf-cookie` on every POST.
|
|
141
|
+
Reset it on any 401/419 so the next mutation re-primes.
|
|
142
|
+
- 419 retry is bounded to ONE attempt (don't pass through interceptor twice —
|
|
143
|
+
use the original `error.config`).
|
|
144
|
+
- 401 → redirect, 403 → toast, 422 → return for inline form errors,
|
|
145
|
+
5xx → toast. NEVER swallow errors silently.
|
|
146
|
+
|
|
147
|
+
## Auth Helpers — `resources/js/lib/auth.js`
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
import api from './api';
|
|
151
|
+
|
|
152
|
+
export async function login(email, password) {
|
|
153
|
+
await api.get('/sanctum/csrf-cookie');
|
|
154
|
+
const { data } = await api.post('/login', { email, password });
|
|
155
|
+
return data;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function logout() {
|
|
159
|
+
await api.post('/logout');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function fetchCurrentUser() {
|
|
163
|
+
const { data } = await api.get('/api/user');
|
|
164
|
+
return data;
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Page Pattern — Shell + Skeleton + Async Fetch
|
|
169
|
+
|
|
170
|
+
```tsx
|
|
171
|
+
// resources/js/Pages/Users/Index.tsx
|
|
172
|
+
import { useEffect, useState } from 'react';
|
|
173
|
+
import api from '@/lib/api';
|
|
174
|
+
import UsersTable from './_components/UsersTable';
|
|
175
|
+
import UsersTableSkeleton from './_components/UsersTableSkeleton';
|
|
176
|
+
import ErrorState from '@/Components/ErrorState';
|
|
177
|
+
|
|
178
|
+
const STYLES = {
|
|
179
|
+
page: 'max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8',
|
|
180
|
+
heading: 'text-2xl font-bold text-foreground mb-6',
|
|
181
|
+
} as const;
|
|
182
|
+
|
|
183
|
+
const LABELS = {
|
|
184
|
+
title: 'Users',
|
|
185
|
+
} as const;
|
|
186
|
+
|
|
187
|
+
export default function UsersIndexPage() {
|
|
188
|
+
const [data, setData] = useState(null);
|
|
189
|
+
const [error, setError] = useState(null);
|
|
190
|
+
const [loading, setLoading] = useState(true);
|
|
191
|
+
|
|
192
|
+
const load = async () => {
|
|
193
|
+
try {
|
|
194
|
+
setError(null);
|
|
195
|
+
setLoading(true);
|
|
196
|
+
const res = await api.get('/api/users', { params: { per_page: 25 } });
|
|
197
|
+
setData(res.data);
|
|
198
|
+
} catch (e) {
|
|
199
|
+
setError(e.response?.data?.message ?? 'Failed to load users');
|
|
200
|
+
} finally {
|
|
201
|
+
setLoading(false);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
useEffect(() => { load(); }, []);
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<div className={STYLES.page}>
|
|
209
|
+
<h1 className={STYLES.heading}>{LABELS.title}</h1>
|
|
210
|
+
|
|
211
|
+
{error && <ErrorState error={error} onRetry={load} />}
|
|
212
|
+
{!error && loading && !data && <UsersTableSkeleton />}
|
|
213
|
+
{!error && data && <UsersTable users={data.data} meta={data.meta} />}
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
**Critical:** the page renders the heading + layout IMMEDIATELY. The skeleton
|
|
220
|
+
shows only inside the data area while Axios fetches. NEVER block the entire
|
|
221
|
+
page render on the API call.
|
|
222
|
+
|
|
223
|
+
## Production-Grade: TanStack Query (Recommended for Lists)
|
|
224
|
+
|
|
225
|
+
```tsx
|
|
226
|
+
// resources/js/lib/queryClient.ts
|
|
227
|
+
import { QueryClient } from '@tanstack/react-query';
|
|
228
|
+
|
|
229
|
+
export const queryClient = new QueryClient({
|
|
230
|
+
defaultOptions: {
|
|
231
|
+
queries: {
|
|
232
|
+
staleTime: 30_000,
|
|
233
|
+
gcTime: 5 * 60_000,
|
|
234
|
+
retry: (failureCount, error: any) => {
|
|
235
|
+
const status = error?.response?.status;
|
|
236
|
+
if (status === 401 || status === 403 || status === 422) return false;
|
|
237
|
+
return failureCount < 2;
|
|
238
|
+
},
|
|
239
|
+
refetchOnWindowFocus: false,
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// resources/js/Pages/Users/Index.tsx
|
|
245
|
+
import { useQuery } from '@tanstack/react-query';
|
|
246
|
+
import api from '@/lib/api';
|
|
247
|
+
|
|
248
|
+
const fetchUsers = async (page: number) => {
|
|
249
|
+
const { data } = await api.get('/api/users', { params: { page, per_page: 25 } });
|
|
250
|
+
return data;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
export default function UsersIndexPage() {
|
|
254
|
+
const [page, setPage] = useState(1);
|
|
255
|
+
const { data, error, isPending, refetch } = useQuery({
|
|
256
|
+
queryKey: ['users', page],
|
|
257
|
+
queryFn: () => fetchUsers(page),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
if (error) return <ErrorState error={error} onRetry={refetch} />;
|
|
261
|
+
if (isPending && !data) return <UsersTableSkeleton />;
|
|
262
|
+
return <UsersTable users={data.data} meta={data.meta} onPageChange={setPage} />;
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
**Rules:**
|
|
267
|
+
|
|
268
|
+
- Use TanStack Query for any list/detail page that benefits from cache,
|
|
269
|
+
refetch, or shared state across components.
|
|
270
|
+
- `retry` MUST exclude 4xx auth/validation errors (no point retrying).
|
|
271
|
+
- `refetchOnWindowFocus: false` prevents unwanted refetches in admin panels.
|
|
272
|
+
|
|
273
|
+
## Form Submissions — Surface 422 Validation Errors Inline
|
|
274
|
+
|
|
275
|
+
```tsx
|
|
276
|
+
import { useState } from 'react';
|
|
277
|
+
import api from '@/lib/api';
|
|
278
|
+
|
|
279
|
+
const STYLES = {
|
|
280
|
+
form: 'space-y-4 max-w-md',
|
|
281
|
+
field: 'block text-sm font-medium text-foreground mb-1',
|
|
282
|
+
input: 'w-full h-10 px-3 rounded-md border border-border bg-background',
|
|
283
|
+
error: 'mt-1 text-sm text-destructive',
|
|
284
|
+
btn: 'px-4 py-2 bg-primary text-primary-foreground rounded-lg disabled:opacity-50',
|
|
285
|
+
} as const;
|
|
286
|
+
|
|
287
|
+
export default function CreateUser({ onCreated }: { onCreated: () => void }) {
|
|
288
|
+
const [form, setForm] = useState({ name: '', email: '' });
|
|
289
|
+
const [errors, setErrors] = useState<Record<string, string[]>>({});
|
|
290
|
+
const [submitting, setSubmitting] = useState(false);
|
|
291
|
+
|
|
292
|
+
const submit = async (e: React.FormEvent) => {
|
|
293
|
+
e.preventDefault();
|
|
294
|
+
setErrors({});
|
|
295
|
+
setSubmitting(true);
|
|
296
|
+
try {
|
|
297
|
+
await api.post('/api/users', form);
|
|
298
|
+
onCreated();
|
|
299
|
+
} catch (err: any) {
|
|
300
|
+
if (err.validation) {
|
|
301
|
+
setErrors(err.validation);
|
|
302
|
+
} else {
|
|
303
|
+
setErrors({ _: ['Unexpected error. Try again.'] });
|
|
304
|
+
}
|
|
305
|
+
} finally {
|
|
306
|
+
setSubmitting(false);
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<form className={STYLES.form} onSubmit={submit}>
|
|
312
|
+
<div>
|
|
313
|
+
<label className={STYLES.field}>Name</label>
|
|
314
|
+
<input
|
|
315
|
+
className={STYLES.input}
|
|
316
|
+
value={form.name}
|
|
317
|
+
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
318
|
+
/>
|
|
319
|
+
{errors.name?.[0] && <p className={STYLES.error}>{errors.name[0]}</p>}
|
|
320
|
+
</div>
|
|
321
|
+
<div>
|
|
322
|
+
<label className={STYLES.field}>Email</label>
|
|
323
|
+
<input
|
|
324
|
+
type="email"
|
|
325
|
+
className={STYLES.input}
|
|
326
|
+
value={form.email}
|
|
327
|
+
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
|
328
|
+
/>
|
|
329
|
+
{errors.email?.[0] && <p className={STYLES.error}>{errors.email[0]}</p>}
|
|
330
|
+
</div>
|
|
331
|
+
<button type="submit" disabled={submitting} className={STYLES.btn}>
|
|
332
|
+
{submitting ? 'Saving...' : 'Save'}
|
|
333
|
+
</button>
|
|
334
|
+
</form>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
**Rule:** Laravel's `FormRequest` → on failure returns `422 + { message, errors:
|
|
340
|
+
{ field: [msg] } }`. The interceptor exposes this as `error.validation`. Render
|
|
341
|
+
inline under each field. NEVER use a global toast for field-level errors.
|
|
342
|
+
|
|
343
|
+
## Vite Configuration (Same-Origin)
|
|
344
|
+
|
|
345
|
+
```js
|
|
346
|
+
// vite.config.js
|
|
347
|
+
import { defineConfig } from 'vite';
|
|
348
|
+
import laravel from 'laravel-vite-plugin';
|
|
349
|
+
import react from '@vitejs/plugin-react';
|
|
350
|
+
|
|
351
|
+
export default defineConfig({
|
|
352
|
+
plugins: [
|
|
353
|
+
laravel({
|
|
354
|
+
input: ['resources/css/app.css', 'resources/js/app.jsx'],
|
|
355
|
+
refresh: true,
|
|
356
|
+
}),
|
|
357
|
+
react(),
|
|
358
|
+
],
|
|
359
|
+
server: {
|
|
360
|
+
host: 'localhost',
|
|
361
|
+
port: 5173,
|
|
362
|
+
hmr: { host: 'localhost' },
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
## React Router Catch-All Setup
|
|
368
|
+
|
|
369
|
+
```php
|
|
370
|
+
// routes/web.php — single shell route, React owns all paths
|
|
371
|
+
Route::get('/{any?}', fn () => view('app'))->where('any', '^(?!api|sanctum|login|logout|register).*$');
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
```blade
|
|
375
|
+
{{-- resources/views/app.blade.php --}}
|
|
376
|
+
<!DOCTYPE html>
|
|
377
|
+
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
|
378
|
+
<head>
|
|
379
|
+
<meta charset="UTF-8" />
|
|
380
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
381
|
+
<title>{{ config('app.name') }}</title>
|
|
382
|
+
@viteReactRefresh
|
|
383
|
+
@vite(['resources/css/app.css', 'resources/js/app.jsx'])
|
|
384
|
+
</head>
|
|
385
|
+
<body>
|
|
386
|
+
<div id="app"></div>
|
|
387
|
+
</body>
|
|
388
|
+
</html>
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
```jsx
|
|
392
|
+
// resources/js/app.jsx
|
|
393
|
+
import React from 'react';
|
|
394
|
+
import { createRoot } from 'react-dom/client';
|
|
395
|
+
import { BrowserRouter } from 'react-router-dom';
|
|
396
|
+
import { QueryClientProvider } from '@tanstack/react-query';
|
|
397
|
+
import { queryClient } from './lib/queryClient';
|
|
398
|
+
import App from './App';
|
|
399
|
+
|
|
400
|
+
createRoot(document.getElementById('app')).render(
|
|
401
|
+
<React.StrictMode>
|
|
402
|
+
<QueryClientProvider client={queryClient}>
|
|
403
|
+
<BrowserRouter>
|
|
404
|
+
<App />
|
|
405
|
+
</BrowserRouter>
|
|
406
|
+
</QueryClientProvider>
|
|
407
|
+
</React.StrictMode>,
|
|
408
|
+
);
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
## Environment Variables
|
|
412
|
+
|
|
413
|
+
```bash
|
|
414
|
+
# .env (Laravel)
|
|
415
|
+
APP_URL=http://localhost:8000
|
|
416
|
+
SESSION_DRIVER=cookie
|
|
417
|
+
SESSION_DOMAIN=localhost
|
|
418
|
+
SESSION_SAME_SITE=lax
|
|
419
|
+
SESSION_SECURE_COOKIE=false # true in production (HTTPS only)
|
|
420
|
+
SANCTUM_STATEFUL_DOMAINS=localhost,localhost:5173,127.0.0.1,127.0.0.1:8000
|
|
421
|
+
|
|
422
|
+
# Production:
|
|
423
|
+
# SESSION_DOMAIN=.example.com
|
|
424
|
+
# SESSION_SECURE_COOKIE=true
|
|
425
|
+
# SESSION_SAME_SITE=lax
|
|
426
|
+
# SANCTUM_STATEFUL_DOMAINS=app.example.com
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
```bash
|
|
430
|
+
# .env (Vite — only PUBLIC variables prefixed VITE_)
|
|
431
|
+
VITE_API_URL=/ # same-origin → leave as "/"
|
|
432
|
+
# VITE_API_URL=https://api.example.com # cross-origin
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
## Checklist — Before Shipping the Client
|
|
436
|
+
|
|
437
|
+
- [ ] `withCredentials: true` and `withXSRFToken: true` set on the instance
|
|
438
|
+
- [ ] CSRF cookie primed before any POST/PUT/PATCH/DELETE
|
|
439
|
+
- [ ] 401 interceptor redirects to `/login` (with `?redirect=...`)
|
|
440
|
+
- [ ] 419 interceptor re-primes CSRF and retries ONCE
|
|
441
|
+
- [ ] 422 interceptor exposes `error.validation` for inline form errors
|
|
442
|
+
- [ ] No raw `axios.get` in components — always via `api` instance
|
|
443
|
+
- [ ] No tokens or session data in `localStorage` (cookie only)
|
|
444
|
+
- [ ] `VITE_*` env vars are PUBLIC — never put secrets here
|
|
445
|
+
- [ ] Pages render shell + skeleton instantly; data arrives async
|
|
446
|
+
|
|
447
|
+
## FORBIDDEN
|
|
448
|
+
|
|
449
|
+
| Action | Reason |
|
|
450
|
+
|--------|--------|
|
|
451
|
+
| `Inertia::render()` for new pages | Blocks first paint on DB query — use `api.get` |
|
|
452
|
+
| `localStorage.setItem('token', ...)` | XSS-readable; use HttpOnly session cookie |
|
|
453
|
+
| `axios.get(...)` directly in component | Bypasses interceptors — always use `api` |
|
|
454
|
+
| Hitting `/sanctum/csrf-cookie` on every POST | Wasteful — cache the priming |
|
|
455
|
+
| Catching errors silently | Always surface 401/403/422/5xx to user |
|
|
456
|
+
| Skipping `withCredentials: true` | Cookie is dropped → 401 forever |
|
|
457
|
+
| `withCredentials` + `Access-Control-Allow-Origin: *` | Browser rejects — must be a specific origin |
|
|
458
|
+
| Storing tokens in `VITE_*` env vars | They're embedded in the bundle — public |
|
|
459
|
+
| Blocking page render on first fetch | Defeats the purpose of skeleton-first SPA |
|
|
460
|
+
| `axios.defaults.baseURL = absolute-prod-url` in dev | Use `import.meta.env.VITE_API_URL` |
|
|
461
|
+
|
|
462
|
+
## See Also
|
|
463
|
+
|
|
464
|
+
- `laravel-api-architecture` — backend pipeline (Controller → FormRequest → Policy → Service → Resource)
|
|
465
|
+
- `react-api-standards` — page/component conventions for API-first React
|
|
466
|
+
- `api-security` — Sanctum config, CORS, rate limiting, brute-force protection
|