Laravel 12 + Inertia 2 + React 19 + TailwindCSS conventions and patterns
From beenpx claudepluginhub george-popescu/bee-dev --plugin beeThis skill uses the workspace's default tool permissions.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
These standards apply when the project stack is laravel-inertia-react. All agents and implementations must follow these conventions.
Also read skills/standards/frontend/SKILL.md for universal frontend standards (component architecture, accessibility, responsive design, CSS methodology, design quality) that apply alongside these Laravel+React-specific conventions.
index, create, store, show, edit, update, destroy.Inertia::render('Page/Name', $props).Gate::authorize() -- NEVER $request->user()->can() + abort(403) or auth()->user()->can().router.put() (Inertia, from Edit pages) AND axios.put() (sub-resource modals), use $request->wantsJson() to return JsonResponse for axios and RedirectResponse for Inertia. Return type: JsonResponse|RedirectResponse.// Pattern: thin controller with Gate::authorize and dual-response
public function update(UpdateOrderRequest $request, Order $order): JsonResponse|RedirectResponse
{
Gate::authorize('update', $order);
$order = app(OrderService::class)->update($order, $request->validated());
if ($request->wantsJson()) {
return response()->json(['message' => __('models.order.updated')]);
}
return redirect()->route('orders.edit', $order);
}
public function store(StoreOrderRequest $request, OrderService $service): RedirectResponse
{
Gate::authorize('create', Order::class);
$order = $service->create($request->validated());
return redirect()->route('orders.show', $order);
}
hasMany, belongsTo, belongsToMany, morphMany, etc.scopeActive, scopeByUser.$casts for type casting (dates, enums, JSON, booleans).$fillable or $guarded for mass assignment protection.scopeWithSearch() on models that appear in index/list views.WithSortableScope from App\Models\Traits\ -- NEVER App\Traits\.use App\Models\Traits\WithSortableScope;
class Order extends Model
{
use HasFactory, WithSortableScope;
protected $fillable = ['client_id', 'status', 'total', 'notes'];
protected $casts = [
'status' => OrderStatus::class,
'metadata' => 'array',
'shipped_at' => 'datetime',
'total' => 'decimal:2',
];
// Search scope -- required for index controllers
public function scopeWithSearch(Builder $q, ?string $s, array $cols = ['name']): Builder
{
if (! $s) return $q;
return $q->where(fn (Builder $q) =>
collect($cols)->each(fn ($c) => $q->orWhere($c, 'like', "%{$s}%"))
);
}
// Sorting -- provided by WithSortableScope trait
// scopeWithSorting(?string $sortDef, ?string $defaultSort)
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
}
Route::resource) when possible.->name('orders.index')).routes/api.php; web routes in routes/web.php.// Search endpoint BEFORE resource route
Route::get('clients/search', [ClientController::class, 'search'])->name('clients.search');
Route::resource('clients', ClientController::class);
// Resource patterns
Route::resource('orders', OrderController::class)->only(['index', 'store', 'update', 'destroy']);
Route::resource('orders', OrderController::class)->except(['create']);
Route::apiResource('orders.items', OrderItemController::class)->except(['show']);
// Model-binding route BEFORE static route (action controllers)
Route::post('orders/{order}/actions/void', [OrderActionController::class, 'voidSingle']);
Route::post('orders/actions/void', [OrderActionController::class, 'void']);
If the project uses Wayfinder for type-safe route URLs:
php artisan wayfinder:generate (run after ANY route changes)import { index, edit, show } from '@/actions/App/Http/Controllers/OrderController'edit.url({ order: id }) produces "/orders/1/edit"index.url({}, { query: { page: 2 } }) produces "/orders?page=2"usePage().url -- NEVER route().current()OrderService, PaymentService).IF service used in 2+ methods --> constructor injection (protected property)
IF service used in 1 method --> method parameter injection
IF method must match parent signature --> constructor injection (even if 1 method)
Listeners / Observers --> ALWAYS constructor injection (handle/observer signatures are fixed)
Traits --> app() inline is acceptable (traits cannot have constructors)
// 2+ methods --> constructor injection
class OrderController
{
public function __construct(protected OrderService $service) {}
public function store(StoreOrderRequest $request): RedirectResponse
{
$order = $this->service->create($request->validated());
return redirect()->route('orders.show', $order);
}
}
// 1 method --> method parameter injection
public function autoAllocate(Request $request, AllocationService $svc): JsonResponse
{
$svc->autoAllocate($request->validated());
return response()->json(['message' => __('models.allocation.created')]);
}
// Listener (handle signature is fixed by Laravel)
class OrderCreatedListener
{
public function __construct(private readonly NotificationService $svc) {}
public function handle(OrderCreated $event): void { /* ... */ }
}
OrderCreated, PaymentProcessed.app/Listeners/ are auto-discovered via handle(Event $event) type-hint. NEVER use Event::listen() in AppServiceProvider (causes duplicates).php artisan event:list --event=App\\Events\\YourEvent -- should show exactly ONE listener.down() method. Define foreign key constraints with constrained().app/Enums/ directory.Inertia::render('Orders/Index', ['orders' => $orders]).resources/js/Pages/ with PascalCase directory structure matching the render path.// Pattern: Inertia page component with typed props
interface Props {
orders: Order[];
filters: { search: string; status: string };
}
export default function Index({ orders, filters }: Props) {
return (
<div>
<h1>Orders</h1>
{orders.map((order) => (
<OrderCard key={order.id} order={order} />
))}
</div>
);
}
HandleInertiaRequests middleware for data shared across all pages.usePage().props in any component.import { usePage } from '@inertiajs/react';
// Access auth user, flash messages, and app config from anywhere
export default function Layout({ children }: { children: React.ReactNode }) {
const { auth, flash } = usePage().props;
return (
<main>
<header>Logged in as: {auth.user.name}</header>
{flash.success && <div className="alert-success">{flash.success}</div>}
<article>{children}</article>
</main>
);
}
Use useForm() from @inertiajs/react for ALL form submissions. It provides reactive data binding, processing state, error handling, dirty tracking, and automatic CSRF.
import { useForm } from '@inertiajs/react';
export default function CreateOrder() {
const { data, setData, post, processing, errors, reset, transform } = useForm<{
client_id: number | '';
notes: string;
items: Array<{ product_id: number; quantity: number }>;
}>({
client_id: '',
notes: '',
items: [],
});
function submit(e: React.FormEvent) {
e.preventDefault();
post(route('orders.store'), {
preserveScroll: true,
onSuccess: () => reset(),
});
}
return (
<form onSubmit={submit}>
<select
value={data.client_id}
onChange={(e) => setData('client_id', Number(e.target.value))}
>
<option value="">Select client</option>
{/* options */}
</select>
{errors.client_id && <span className="text-red-500 text-sm">{errors.client_id}</span>}
<textarea
value={data.notes}
onChange={(e) => setData('notes', e.target.value)}
/>
{errors.notes && <span className="text-red-500 text-sm">{errors.notes}</span>}
<button type="submit" disabled={processing}>
{processing ? 'Saving...' : 'Create Order'}
</button>
</form>
);
}
useForm key features:
data / setData(key, value) -- reactive form statepost(url), put(url), patch(url), delete(url) -- HTTP methodsprocessing -- boolean, true while request is in-flighterrors -- object with server validation errors keyed by field namereset() / reset('field') -- reset all or specific fields to initial valuestransform(callback) -- modify data before submission (e.g., extract .id from objects)isDirty -- boolean, true if any field has changed from initial valuesclearErrors() / clearErrors('field') -- clear validation errorsFile uploads with useForm:
const { data, setData, post, progress } = useForm<{ avatar: File | null }>({
avatar: null,
});
<input type="file" onChange={(e) => setData('avatar', e.target.files?.[0] ?? null)} />
{progress && <progress value={progress.percentage} max="100">{progress.percentage}%</progress>}
Use the router object for navigation outside of JSX links.
import { router } from '@inertiajs/react';
// Navigate to a new page
router.visit('/orders');
router.visit(route('orders.edit', order.id));
// Shorthand methods (include data as second arg)
router.get('/orders', { search: 'pending' });
router.post('/orders', { client_id: 1, notes: 'Rush order' });
router.put(route('orders.update', order.id), { status: 'shipped' });
router.delete(route('orders.destroy', order.id));
// With options
router.post(route('orders.store'), formData, {
preserveScroll: true,
preserveState: true,
onSuccess: () => { /* handle success */ },
onError: (errors) => { /* handle validation errors */ },
});
Reload specific props without fetching all page data. Reduces bandwidth and improves performance for paginated data, search results, and filtered lists.
import { router } from '@inertiajs/react';
// Reload only the 'orders' prop
router.reload({ only: ['orders'] });
// Reload everything except a heavy prop
router.reload({ except: ['analytics'] });
// Partial reload with Link component
<Link href="/orders?status=active" only={['orders']}>Show active</Link>
// Combine with search -- reload only filtered data
function handleSearch(query: string) {
router.get(route('orders.index'), { search: query }, {
preserveState: true,
preserveScroll: true,
only: ['orders'],
});
}
Keep layout state (scroll position, sidebar state, audio players) alive across page navigations. Define the layout as a static property on the page component.
import AppLayout from '@/Layouts/AppLayout';
export default function Index({ orders }: Props) {
return (
<div>
<h1>Orders</h1>
{/* page content */}
</div>
);
}
// Persistent layout -- NOT re-mounted on navigation between pages that share this layout
Index.layout = (page: React.ReactNode) => <AppLayout>{page}</AppLayout>;
For nested persistent layouts:
Index.layout = (page: React.ReactNode) => (
<AppLayout>
<DashboardLayout>{page}</DashboardLayout>
</AppLayout>
);
Load heavy data lazily after the initial page render. The server sends the page immediately, then fetches deferred props in a follow-up request.
// Controller -- defer heavy props
use Inertia\Inertia;
public function show(Order $order): \Inertia\Response
{
return Inertia::render('Orders/Show', [
'order' => $order->load('client'),
'analytics' => Inertia::defer(fn () => $this->analyticsService->forOrder($order)),
'auditLog' => Inertia::defer(fn () => $order->auditLogs()->latest()->paginate(20)),
]);
}
// React -- use WhenVisible or check for undefined
import { WhenVisible } from '@inertiajs/react';
export default function Show({ order, analytics, auditLog }: Props) {
return (
<div>
<OrderDetails order={order} />
{/* Render analytics once loaded */}
{analytics ? <AnalyticsPanel data={analytics} /> : <Skeleton />}
{/* Or use WhenVisible for viewport-triggered loading */}
<WhenVisible fallback={<Skeleton />} data="auditLog">
<AuditLogTable entries={auditLog} />
</WhenVisible>
</div>
);
}
// app/Http/Middleware/HandleInertiaRequests.php
public function share(Request $request): array
{
return [
...parent::share($request),
'auth' => [
'user' => $request->user() ? [
'id' => $request->user()->id,
'name' => $request->user()->name,
'email' => $request->user()->email,
'abilities' => $request->user()->abilities ?? [],
] : null,
],
'flash' => [
'success' => $request->session()->get('success'),
'error' => $request->session()->get('error'),
],
];
}
useState for local reactive state. Always use the setter function, never mutate directly.useEffect for side effects (subscriptions, timers, DOM manipulation). Always return a cleanup function when needed.useMemo for expensive derived values. Use instead of storing derived state in useState.useCallback for stable function references passed to child components.useRef for mutable values that do not trigger re-renders (DOM refs, timers, previous values).// Pattern: hooks usage in an Inertia page
const [search, setSearch] = useState(filters.search ?? '');
const filteredOrders = useMemo(
() => orders.filter((o) => o.name.toLowerCase().includes(search.toLowerCase())),
[orders, search]
);
useEffect(() => {
const timer = setTimeout(() => {
router.get(route('orders.index'), { search }, {
preserveState: true,
preserveScroll: true,
only: ['orders'],
});
}, 300);
return () => clearTimeout(timer);
}, [search]);
use() -- read promises and context directly in render. Replaces useContext for context reading. Can be called conditionally (unique among hooks).useActionState(fn, initialState) -- manage form action state with automatic pending tracking. Returns [state, formAction, isPending]. Note: Inertia's useForm() is usually preferred for server-submitted forms because it integrates with Inertia's error handling and navigation. Use useActionState for client-side-only actions or non-Inertia endpoints.useOptimistic(state, updateFn) -- optimistic UI updates during async actions. Show the expected result immediately, roll back on failure.useFormStatus() (from react-dom) -- read parent form submission status. Use in submit buttons to show loading state.// Pattern: useOptimistic with Inertia for inline toggle
import { useOptimistic } from 'react';
import { router } from '@inertiajs/react';
function OrderRow({ order }: { order: Order }) {
const [optimisticOrder, setOptimistic] = useOptimistic(order, (current, newStatus: string) => ({
...current,
status: newStatus,
}));
function toggleStatus() {
const newStatus = optimisticOrder.status === 'active' ? 'paused' : 'active';
setOptimistic(newStatus);
router.put(route('orders.update', order.id), { status: newStatus }, {
preserveScroll: true,
});
}
return (
<tr>
<td>{optimisticOrder.status}</td>
<td><button onClick={toggleStatus}>Toggle</button></td>
</tr>
);
}
children for composition. Callback props for child-to-parent communication.useFilters(), useDebounce(), usePagination()). Hooks follow use* naming and live in resources/js/Hooks/.// Pattern: custom hook for debounced Inertia search
function useInertiaSearch(routeName: string, propKey: string, delay = 300) {
const { url } = usePage();
const params = new URLSearchParams(url.split('?')[1] || '');
const [search, setSearch] = useState(params.get('search') ?? '');
useEffect(() => {
const timer = setTimeout(() => {
router.get(route(routeName), { search }, {
preserveState: true,
preserveScroll: true,
only: [propKey],
});
}, delay);
return () => clearTimeout(timer);
}, [search]);
return { search, setSearch };
}
In an Inertia application, the server is the source of truth. Page props delivered via Inertia::render() are the primary state. Local React state should only be used for UI concerns (open/close, search input, selected items).
Do NOT duplicate server state into React state. If you need to modify displayed data, use useMemo to derive it from props. If you need to submit changes, use useForm().
Detect what the project uses -- check package.json for installed state management libraries and follow THAT library's conventions. Do NOT introduce a different state library than what the project already uses.
useStore(selector) over useStore().createAsyncThunk for async, RTK Query for server state. Never mutate outside Immer.If no external store is installed: Inertia page props + local useState + Context API cover most needs. Only add an external library when you have a clear use case the existing tools cannot handle.
Key rule: Match the project. If the project uses Zustand, write Zustand. If it uses Redux, write Redux. Never mix state libraries without explicit user direction.
Use useForm() from @inertiajs/react for ALL forms that submit to Laravel. It automatically handles CSRF, validation error mapping, processing state, and Inertia navigation.
For forms requiring complex client-side validation (cross-field rules, real-time feedback, multi-step wizards), use React Hook Form with Zod. Submit via router.post() or axios after client-side validation passes.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { router } from '@inertiajs/react';
const schema = z.object({
email: z.string().email('Invalid email'),
phone: z.string().optional(),
}).refine((d) => d.email || d.phone, {
message: 'Email or phone is required',
path: ['email'],
});
type FormValues = z.infer<typeof schema>;
function ContactForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(schema),
});
const onSubmit = (values: FormValues) => {
router.post(route('contacts.store'), values);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span className="text-red-500">{errors.email.message}</span>}
<button type="submit">Save</button>
</form>
);
}
Inertia automatically maps Laravel validation errors to form.errors. Display them inline next to each field.
// Reusable error component
function FieldError({ message }: { message?: string }) {
if (!message) return null;
return <p className="mt-1 text-sm text-red-600">{message}</p>;
}
// Usage with useForm
<input
value={data.name}
onChange={(e) => setData('name', e.target.value)}
className={errors.name ? 'border-red-500' : 'border-gray-300'}
/>
<FieldError message={errors.name} />
const { data, setData, post, progress } = useForm<{ document: File | null }>({
document: null,
});
function submit(e: React.FormEvent) {
e.preventDefault();
post(route('documents.store'), {
forceFormData: true, // ensure multipart encoding
});
}
<input type="file" onChange={(e) => setData('document', e.target.files?.[0] ?? null)} />
{progress && <div className="w-full bg-gray-200 rounded">
<div className="bg-blue-600 h-2 rounded" style={{ width: `${progress.percentage}%` }} />
</div>}
resources/js/
Pages/ -- Inertia page components (match controller render paths)
Orders/
Index.tsx
Show.tsx
Edit.tsx
Components/ -- Shared reusable UI components
OrderCard.tsx
DataTable.tsx
FieldError.tsx
Layouts/ -- Persistent layouts
AppLayout.tsx
DashboardLayout.tsx
Hooks/ -- Custom hooks (use* naming)
useDebounce.ts
useFilters.ts
useInertiaSearch.ts
types/ -- TypeScript interfaces and types
models.ts
index.d.ts
Page.layout for persistent layouts.usePage() (exception: layout components that need auth data).@apply in most cases -- extract to React components instead when utility strings repeat.tailwind.config.js under theme.extend with semantic naming.sm:, md:, lg:, xl:, 2xl:) for larger screens.assertInertia() for testing Inertia page responses, props, and component rendering.RefreshDatabase trait).it('displays the orders list', function () {
$orders = Order::factory()->count(3)->create();
$this->actingAs(User::factory()->create())
->get(route('orders.index'))
->assertInertia(fn (Assert $page) => $page
->component('Orders/Index')
->has('orders', 3)
);
});
it('validates required fields on store', function () {
$this->actingAs(User::factory()->create())
->post(route('orders.store'), [])
->assertSessionHasErrors(['client_id', 'status']);
});
it('authorizes before updating', function () {
$order = Order::factory()->create();
$user = User::factory()->create(); // user without permission
$this->actingAs($user)
->put(route('orders.update', $order), ['status' => 'shipped'])
->assertForbidden();
});
render(), screen, and userEvent from @testing-library/react.usePage() and useForm() in tests.renderHook().import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
// Mock Inertia hooks
vi.mock('@inertiajs/react', () => ({
usePage: () => ({
props: {
auth: { user: { id: 1, name: 'Test User' } },
flash: {},
},
url: '/orders',
}),
useForm: () => ({
data: { name: '', email: '' },
setData: vi.fn(),
post: vi.fn(),
processing: false,
errors: {},
reset: vi.fn(),
}),
Link: ({ children, ...props }: any) => <a {...props}>{children}</a>,
router: { visit: vi.fn(), reload: vi.fn(), get: vi.fn() },
}));
test('submits the form with order data', async () => {
const user = userEvent.setup();
render(<CreateOrder clients={mockClients} />);
await user.type(screen.getByLabelText('Notes'), 'Rush delivery');
await user.click(screen.getByRole('button', { name: /create order/i }));
expect(mockPost).toHaveBeenCalledWith(expect.stringContaining('orders'));
});
test('renders order list from Inertia props', () => {
const orders = [
{ id: 1, name: 'Order A', status: 'active' },
{ id: 2, name: 'Order B', status: 'pending' },
];
render(<Index orders={orders} filters={{ search: '', status: '' }} />);
expect(screen.getByText('Order A')).toBeInTheDocument();
expect(screen.getByText('Order B')).toBeInTheDocument();
});
store() and update() needs a FormRequest.auth()->user()->can() or $request->user()->can() + abort(403) -- ALWAYS use Gate::authorize().with() to prevent N+1 query problems..env and config().scopeWithSearch() on models used in index views.App\Models\Traits\WithSortableScope, NOT App\Traits\.Event::listen() in AppServiceProvider -- Laravel 12 auto-discovers listeners.any type in TypeScript -- define proper interfaces and types.useFormState -- it is deprecated in React 19. Use useActionState instead.fetch or axios for form submissions that should navigate -- use useForm() or router.post(). Raw HTTP calls bypass Inertia's page management, CSRF handling, and error routing.router.visit() with method argument when shorthand exists -- use router.get(), router.post(), router.put(), router.delete() for clarity.usePage().props in any component that needs auth or flash data.useState if you need local modifications.These are non-negotiable requirements for every feature implementation.
store() and update() controller method must use a dedicated FormRequest class. Never validate inline.Gate::authorize(). Never use $request->user()->can() + abort(403).useForm() from @inertiajs/react for every form that submits to Laravel. It provides processing state, error handling, dirty tracking, and CSRF out of the box.with() to prevent N+1 query problems.->name() or resource route conventions.Page.layout to avoid re-mounting layouts on navigation.Recommended patterns that improve code quality and maintainability.
router.reload({ only: ['orders'] }) to refresh specific props without full page data reload.useFilters(), useDebounce(), usePagination()). Hooks live in resources/js/Hooks/.useMemo instead of storing derived values in useState.scopeActive, scopeByUser, scopeWithSearch) on models.preserveScroll prop on Inertia <Link> components and router calls.Inertia::defer() for analytics, audit logs, and other expensive queries that are not needed for initial render.useOptimistic for mutations that should feel instant (toggles, likes, status changes).form.transform() to modify data before submission (extract .id from objects, add computed fields).Frequent issues encountered in Laravel + Inertia + React projects.
with(). Use Laravel Debugbar or DB::enableQueryLog() to detect.useState before mutating.$model->relationship->field without checking if loaded. Use optional chaining (?->) in PHP and (?.) in TypeScript.axios or fetch calls must include X-XSRF-TOKEN header. Results in 419 status code.useEffect + setState to compute values from props. Use useMemo instead -- synchronous and avoids extra render cycle.key in lists that reorder or filter. Causes incorrect component reuse and state bugs.router.visit(url, { method: 'post' }) instead of router.post(url). Both work but the shorthand is clearer and less error-prone.preserveState: true, component state resets on every Inertia visit. Search inputs lose focus and local state is lost.useOptimistic on server error leaves ghost data in the UI.$request->validated('sort') returns null for BOTH missing AND empty. Use $request->has('sort') to distinguish.Patterns that must be avoided in all circumstances.
style={{}} objects. Use Tailwind utility classes. Extract repeated classes into React components.any type in TypeScript. Never use any. Define proper interfaces. Use unknown with type narrowing when genuinely uncertain..env + config().useForm() or router.useForm(). Derived state -> useMemo. Navigation -> router.useMemo if needed.Naming, structure, and convention standards for consistency across the codebase.
OrderCard.tsx, UserProfile.tsx). Pages follow directory structure matching Inertia render paths (Pages/Orders/Index.tsx).created_at, user_id). Laravel serializes props as snake_case: use props.item.client_id not props.item.clientId.use prefix for all custom hooks. Custom hooks must start with use (useFilters, useDebounce). Enforced by React's rules of hooks linter.Route::resource() for standard CRUD operations. Only override with explicit routes when non-standard behavior is needed.OrderService, StoreOrderRequest) use PascalCase. Methods (getActiveOrders, calculateTotal) use camelCase. Follow PSR-12./storage-units, /payment-links). Route names use dot notation (storage-units.index).tests/Feature/, unit tests in tests/Unit/. Feature tests cover HTTP request/response cycles. Unit tests cover isolated business logic.Props suffix. OrderListProps, CreateFormProps. Page-level props can simply use Props when unambiguous.When looking up framework documentation, use these Context7 library identifiers:
laravel/framework -- controllers, routing, Eloquent, validation, events, middleware/websites/inertiajs_v2 -- pages, forms, shared data, navigation, partial reloads, deferred props/facebook/react (use version /facebook/react/v19_1_1 for React 19 specifics)pestphp/pest -- test syntax, assertions, datasets, hookstailwindlabs/tailwindcss -- utility classes, configuration, responsive designvitest-dev/vitest -- test runner, assertions, mocking, configurationtesting-library/react-testing-library -- render, screen, queries, user eventsAlways check Context7 for the latest API when working with version-specific features. Training data may be outdated for Laravel 12, Inertia 2, and React 19 specifics.