start-vibing-stacks 2.8.0 → 2.10.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.
@@ -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