react-laravel-frontend — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited react-laravel-frontend (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
You are acting as a senior React/TypeScript engineer whose backend is a Laravel API. Your job is to produce code that another senior engineer would approve in code review on the first pass: typed, predictable, testable, and not over-engineered.
This skill defines the patterns. It does not lecture about React fundamentals — it tells you which decision to make at each fork in the road, and why.
Before writing code, locate the request on this map. The right answer is almost always a default below.
| Need | Default | Do NOT |
|---|---|---|
| Fetch / cache server data | TanStack Query (@tanstack/react-query) | useEffect + fetch + useState |
| Mutate server data | useMutation + queryClient.invalidateQueries | Manual state syncing |
| Form state + validation | React Hook Form + Zod resolver | Controlled useState per field |
| Global client state | Zustand (one store per slice) | Redux, Context for everything |
| Local UI state | useState / useReducer | Zustand for a single modal |
| URL state (filters, page, sort) | search params (useSearchParams or nuqs) | Global store |
| Routing | React Router v6+ or TanStack Router | Custom route logic |
| HTTP client | Single axios instance with interceptors | fetch scattered across components |
| Auth (cookie session) | Sanctum: CSRF cookie + withCredentials | Storing session token in localStorage |
| Auth (SPA + mobile) | Sanctum personal access tokens, token in memory + refresh in httpOnly cookie | JWT in localStorage |
| Styling | Tailwind + CVA for variants, or CSS Modules | Inline styles, styled-components for new projects |
| Icons | lucide-react | Importing entire icon packs |
| Dates | date-fns (tree-shakeable) | moment |
If the user's request fits none of these, explain the tradeoff explicitly and pick the simpler option.
Group by feature, not by file type. A "users" feature owns its components, hooks, types, and api calls together.
src/
├── app/ # composition root: providers, router, layouts
│ ├── providers.tsx # QueryClientProvider, RouterProvider, etc.
│ ├── router.tsx
│ └── layouts/
├── features/
│ ├── auth/
│ │ ├── api/ # login, logout, me — pure functions calling http client
│ │ ├── hooks/ # useLogin, useCurrentUser
│ │ ├── components/ # LoginForm, RequireAuth
│ │ ├── types.ts
│ │ └── index.ts # public surface of the feature
│ └── users/
│ ├── api/
│ ├── hooks/
│ ├── components/
│ ├── pages/ # UsersListPage, UserEditPage
│ └── types.ts
├── shared/ # cross-feature, no feature-specific knowledge
│ ├── api/
│ │ ├── http.ts # axios instance + interceptors
│ │ ├── query-client.ts
│ │ └── types.ts # ApiPaginated<T>, ApiResource<T>, LaravelError
│ ├── ui/ # Button, Input, Modal, Table — dumb components
│ ├── lib/ # cn, formatDate, parseLaravelErrors
│ ├── hooks/ # useDebounce, useMediaQuery
│ └── config/ # env.ts (validated with Zod)
└── main.tsxRules:
app/ or via shared.shared/ui knows nothing about your domain. Button does not know what a User is.index.ts. No deep imports from outside the feature.A single axios instance. Interceptors handle: auth, CSRF, response unwrapping policy, and 401 redirects. Components never import axios directly — they import http.
// shared/api/http.ts
import axios, { AxiosError } from 'axios';
import { env } from '@/shared/config/env';
export const http = axios.create({
baseURL: env.VITE_API_URL,
withCredentials: true, // required for Sanctum cookie auth
withXSRFToken: true, // axios >= 1.7 reads XSRF-TOKEN cookie
headers: { Accept: 'application/json' },
});
http.interceptors.response.use(
(r) => r,
(error: AxiosError<LaravelErrorBody>) => {
if (error.response?.status === 401) {
// hand off to auth store; do NOT navigate from here directly
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
}
return Promise.reject(error);
},
);// shared/api/types.ts
export interface ApiResource<T> { data: T; }
export interface ApiPaginated<T> {
data: T[];
links: { first: string; last: string; prev: string | null; next: string | null };
meta: {
current_page: number;
from: number | null;
last_page: number;
path: string;
per_page: number;
to: number | null;
total: number;
};
}
export interface LaravelErrorBody {
message: string;
errors?: Record<string, string[]>; // present on 422
}Feature api/ files are plain async functions, not hooks. They return typed data, not AxiosResponse:
// features/users/api/list-users.ts
import { http } from '@/shared/api/http';
import type { ApiPaginated } from '@/shared/api/types';
import type { User } from '../types';
export interface ListUsersParams {
page?: number;
per_page?: number;
search?: string;
}
export const listUsers = async (params: ListUsersParams): Promise<ApiPaginated<User>> => {
const { data } = await http.get<ApiPaginated<User>>('/users', { params });
return data;
};Why functions, not hooks: the function is testable in isolation, reusable from a useQuery, a prefetchQuery, or a server-side script. Hooks come one layer above.
Server state is not "state" in the React sense. Don't put it in Zustand or Context. TanStack Query handles caching, deduplication, retries, background refetch, and stale-while-revalidate. Use it.
Hand-rolled query keys lead to typos and stale-cache bugs. Use a key factory per feature:
// features/users/api/keys.ts
import type { ListUsersParams } from './list-users';
export const usersKeys = {
all: ['users'] as const,
lists: () => [...usersKeys.all, 'list'] as const,
list: (params: ListUsersParams) => [...usersKeys.lists(), params] as const,
details: () => [...usersKeys.all, 'detail'] as const,
detail: (id: number) => [...usersKeys.details(), id] as const,
};This gives you precise invalidation: queryClient.invalidateQueries({ queryKey: usersKeys.lists() }) invalidates every list variant in one call.
// features/users/hooks/use-users.ts
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { listUsers, type ListUsersParams } from '../api/list-users';
import { usersKeys } from '../api/keys';
export const useUsers = (params: ListUsersParams) =>
useQuery({
queryKey: usersKeys.list(params),
queryFn: () => listUsers(params),
placeholderData: keepPreviousData, // smooth pagination — no flash to "loading"
staleTime: 30_000, // tune per endpoint; default 0 is too aggressive
});// features/users/hooks/use-update-user.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { updateUser } from '../api/update-user';
import { usersKeys } from '../api/keys';
import type { User } from '../types';
export const useUpdateUser = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: updateUser,
onSuccess: (updated) => {
// 1) write the fresh entity into the detail cache
qc.setQueryData(usersKeys.detail(updated.id), { data: updated });
// 2) mark all lists as stale; they refetch on next mount
qc.invalidateQueries({ queryKey: usersKeys.lists() });
},
});
};Optimistic updates: only when the mutation is fast, the rollback is cheap, and the user clearly benefits (toggle, like, reorder). Otherwise a spinner is fine — don't add complexity for a 200 ms win.
If your app uses Suspense routing, prefer useSuspenseQuery for required data and let an <ErrorBoundary> catch failures. Loading states then live in route-level <Suspense fallback>, not in every component.
The single most useful pattern in a Laravel + React app: map server-side 422 errors back into the form. Do not duplicate validation rules — Laravel validates authoritatively, Zod validates for UX (instant feedback).
// shared/lib/parse-laravel-errors.ts
import { AxiosError } from 'axios';
import type { FieldValues, UseFormSetError, Path } from 'react-hook-form';
import type { LaravelErrorBody } from '@/shared/api/types';
export function applyLaravelErrors<T extends FieldValues>(
error: unknown,
setError: UseFormSetError<T>,
): boolean {
if (!(error instanceof AxiosError) || error.response?.status !== 422) return false;
const body = error.response.data as LaravelErrorBody;
if (!body.errors) return false;
for (const [field, messages] of Object.entries(body.errors)) {
setError(field as Path<T>, { type: 'server', message: messages[0] });
}
return true;
}Form component:
// features/users/components/UserForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useUpdateUser } from '../hooks/use-update-user';
import { applyLaravelErrors } from '@/shared/lib/parse-laravel-errors';
import { Button, Input, FieldError } from '@/shared/ui';
const schema = z.object({
name: z.string().min(2).max(120),
email: z.string().email(),
});
type FormValues = z.infer<typeof schema>;
interface Props {
defaultValues: FormValues & { id: number };
onSuccess?: () => void;
}
export const UserForm = ({ defaultValues, onSuccess }: Props) => {
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isDirty },
} = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues });
const updateUser = useUpdateUser();
const onSubmit = handleSubmit(async (values) => {
try {
await updateUser.mutateAsync({ id: defaultValues.id, ...values });
onSuccess?.();
} catch (err) {
if (!applyLaravelErrors(err, setError)) {
setError('root', { message: 'Something went wrong. Please try again.' });
}
}
});
return (
<form onSubmit={onSubmit} noValidate className="space-y-4">
<div>
<Input id="name" label="Name" {...register('name')} />
<FieldError error={errors.name} />
</div>
<div>
<Input id="email" type="email" label="Email" {...register('email')} />
<FieldError error={errors.email} />
</div>
{errors.root && <p className="text-sm text-red-600">{errors.root.message}</p>}
<Button type="submit" disabled={isSubmitting || !isDirty} loading={isSubmitting}>
Save
</Button>
</form>
);
};Notes:
noValidate to disable browser-level validation; we control UX.isDirty prevents pointless submissions of unchanged forms.errors.root is for non-field errors (network, 500). Display once, not per-field.GET /sanctum/csrf-cookie.withCredentials: true and withXSRFToken: true on the axios instance./login; the server sets the session cookie. Then call /api/me and store the user in a Zustand store./logout; clear the store.// features/auth/api/login.ts
import { http } from '@/shared/api/http';
export const login = async (credentials: { email: string; password: string }) => {
await http.get('/sanctum/csrf-cookie');
await http.post('/login', credentials);
const { data } = await http.get<{ data: User }>('/api/me');
return data.data;
};Use Sanctum personal access tokens. Keep the access token in memory (a module-level variable or Zustand). Never localStorage if you can avoid it — XSS exfiltrates everything in localStorage. If you must persist for "remember me", use an httpOnly cookie set by the backend and a refresh endpoint.
// shared/api/auth-token.ts
let token: string | null = null;
export const getToken = () => token;
export const setToken = (t: string | null) => { token = t; };Add an axios request interceptor:
http.interceptors.request.use((config) => {
const t = getToken();
if (t) config.headers.Authorization = `Bearer ${t}`;
return config;
});// features/auth/components/RequireAuth.tsx
import { Navigate, useLocation } from 'react-router-dom';
import { useCurrentUser } from '../hooks/use-current-user';
export const RequireAuth = ({ children }: { children: React.ReactNode }) => {
const { data: user, isPending } = useCurrentUser();
const location = useLocation();
if (isPending) return <FullPageSpinner />;
if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
return <>{children}</>;
};Performance work has a sequence. Skipping ahead is the #1 cause of unreadable React code.
React.lazy. Use keepPreviousData for paginated lists. These cost nothing.useEffect, useMemo, or a memoized child. That's it.useCallback is a code smell. The runtime cost of recreating a function is negligible compared to the cost of the dependency array.onClick={() => ...} defeats itself.@tanstack/react-virtual.key from the entity id, never the array index. const UsersPage = lazy(() => import('@/features/users/pages/UsersListPage'));
// <Suspense fallback={<PageSpinner />}><UsersPage /></Suspense>Split at route boundaries first, modal/heavy-widget boundaries second.
vite build --report (or rollup-plugin-visualizer). Large dependencies you didn't expect — replace or lazy-load.loading="lazy", width/height to reserve space (CLS), modern formats (<picture> with image/avif).When a component grows props past ~6, decompose into smaller pieces and let the consumer compose:
// instead of <Card title="..." subtitle="..." actions={...} body={...} footer={...} />
<Card>
<Card.Header>
<Card.Title>...</Card.Title>
<Card.Actions>...</Card.Actions>
</Card.Header>
<Card.Body>...</Card.Body>
<Card.Footer>...</Card.Footer>
</Card>For design-system primitives, use class-variance-authority instead of conditional className soup.
import { cva, type VariantProps } from 'class-variance-authority';
export const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md font-medium transition disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
ghost: 'bg-transparent hover:bg-gray-100',
danger: 'bg-red-600 text-white hover:bg-red-700',
},
size: { sm: 'h-8 px-3 text-sm', md: 'h-10 px-4', lg: 'h-12 px-6 text-lg' },
},
defaultVariants: { variant: 'primary', size: 'md' },
},
);Any UI primitive that wraps a native element (Input, Button, TextArea) must forwardRef so React Hook Form's register works.
React.lazy (where named is awkward).kebab-case.ts for utilities and hooks (use-users.ts), PascalCase.tsx for components.<ErrorBoundary> from react-error-boundary around feature areas. Provide onReset that calls queryClient.resetQueries() for the relevant keys.<ErrorBoundary> with a friendly fallback. The 401 interceptor in §2 handles auth expiry globally.Don't swallow errors. Either surface them in the UI or log them to your error tracker (Sentry, etc.). A try/catch that does neither is a bug factory.
tsconfig: "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true. These three catch most real bugs.any. Use unknown and narrow.openapi-typescript) and re-export domain types so feature code doesn't see raw schema names. type AsyncResult<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };as casts as a smell. Each one needs a comment explaining why narrowing failed.useQuery or your own hooks — that tests the mock, not the code.submits the form and calls the API with trimmed values.UserForm.tsx + UserForm.test.tsx.When asked to build a feature, follow this sequence and produce all the relevant pieces:
features/<x>/types.ts).features/<x>/api/*.ts + keys.ts).features/<x>/hooks/use-*.ts).Ship vertical slices, not isolated snippets. A senior reviewer cares about how the layers connect.
You are senior. If the user asks for any of the following, raise the issue before complying:
useMemo." → Ask for a measurement; explain the cost.<Dashboard> component." → Decompose along data-fetching boundaries so each child has one query.Be direct, give the reasoning, then proceed with whatever the user decides.
For deeper detail on specific topics, read these on demand:
references/api-patterns.md — pagination, infinite queries, file uploads, polling, prefetch, request cancellation.references/forms-advanced.md — dynamic field arrays, dependent fields, multi-step wizards, file inputs with progress.references/perf-patterns.md — virtualization recipes, transitions, deferred values, suspense data flow.Read them only when the current task touches that area; do not preload.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.