Laravel 12 + Inertia 2 + Vue 3.5 + TailwindCSS conventions and patterns
From beenpx claudepluginhub bee-coded/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.
Enables AI agents to execute x402 payments with per-task budgets, spending controls, and non-custodial wallets via MCP tools. Use when agents pay for APIs, services, or other agents.
These standards apply when the project stack is laravel-inertia-vue. 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+Vue-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: base controller with overridable methods
class ResourceController extends Controller
{
protected function getModelClass(): string { return Resource::class; }
protected function getResourceName(): string { return 'Resources'; }
protected function getRoutePrefix(): string { return 'resources'; } // override for multi-word
protected function getSearchableColumns(): array { return ['name']; }
protected function getDefaultSort(): string { return '-id'; } // '-id' = desc, 'name' = asc
public function store(Request $request): JsonResponse
{
Gate::authorize('create', $this->getModelClass());
$model = $this->getModelClass()::create($this->validateStore($request));
return response()->json(['message' => __('models.resource.created')], 201);
}
public function update(Request $request, string|int $id): JsonResponse|RedirectResponse
{
$model = $this->findModel($id);
Gate::authorize('update', $model);
$model->update($this->validateUpdate($request, $model));
if ($request->wantsJson()) {
return response()->json(['message' => __('models.resource.updated')]);
}
return redirect()->route($this->getRoutePrefix() . '.edit', $model);
}
}
getRoutePrefix() (e.g., return 'storage-units';).validateStore() / validateUpdate() for inline validation in base controllers. Use FormRequest classes for complex validation.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 Resource extends Model
{
use HasFactory, WithSortableScope;
protected $fillable = ['name', 'status'];
protected $casts = [
'status' => ResourceStatus::class,
'metadata' => 'array',
'active' => 'boolean',
];
// 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)
}
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('r', RC::class)->only(['index', 'store', 'update', 'destroy']); // simple modal CRUD
Route::resource('r', RC::class)->except(['create']); // hybrid: modal create + tabbed edit
Route::apiResource('p.c', CC::class)->except(['show']); // sub-resource
// Model-binding route BEFORE static route (action controllers)
Route::post('entries/{entry}/actions/void', [AC::class, 'voidSingle']);
Route::post('entries/actions/void', [AC::class, 'void']);
php artisan wayfinder:generate (run after ANY route changes)import { index, edit, show } from '@/actions/App/Http/Controllers/ClientController'edit.url({ client: id }) produces "/clients/1/edit"index.url({}, { query: { page: 2 } }) produces "/clients?page=2"import { edit as editClient } from '@/actions/.../ClientController'usePage().url -- NEVER route().current()new URLSearchParams(usePage().url.split('?')[1] || '')// Helper functions for composables that need URL manipulation
function getUrlParams(): URLSearchParams {
const page = usePage();
const [, search] = page.url.split('?');
return new URLSearchParams(search || '');
}
function getPathname(): string {
const page = usePage();
const [pathname] = page.url.split('?');
return pathname;
}
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 ResourceActionController
{
public function __construct(protected ResourceService $service) {}
}
// 1 method --> method parameter
public function autoAllocate(Request $request, AllocationService $svc): JsonResponse
{
$svc->autoAllocate($rental);
}
// 1 method but parent signature constraint --> constructor injection
class ChildController extends ResourceController
{
public function __construct(protected ChildService $cs) {}
public function store(Request $r): JsonResponse { $this->cs->create(); }
}
// Listener (handle signature is fixed by Laravel)
class MyListener
{
public function __construct(private readonly SomeService $svc) {}
}
// Observer (method signatures are fixed by Laravel)
class MyObserver
{
public function __construct(private readonly SomeService $svc) {}
}
OrderCreated, UserRegistered, 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.Pattern: $user->notify() + broadcast event for in-app + real-time delivery.
class SendNotificationListener implements ShouldQueue
{
public function handle(SomeEvent $event): void
{
$user->notify(new SomeNotification($model)); // DB + email
$this->broadcastNotification($user, $model); // WebSocket
}
private function broadcastNotification(User $user, Model $model): void
{
$locale = $user->preferredLocale();
UserNotificationCreated::dispatch($user->id, [
'id' => Str::uuid()->toString(), // Generate UUID directly
'title' => __('notifications.some.title', ['param' => $model->name], $locale),
'message' => __('notifications.some.message', [...], $locale),
'action_url' => route('resource.edit', $model),
]);
}
}
$user->notifications()->latest()->first() after $user->notify() -- race condition under concurrent load.Str::uuid()->toString() for the notification ID.create_orders_table, add_status_to_orders_table.down() method for rollback support.constrained().app/Enums/ directory.bootstrap/app.php via withSchedule() callback.routes/console.php for scheduling (causes duplicates).// bootstrap/app.php
->withSchedule(function (Schedule $schedule): void {
$schedule->job(MyJob::class)
->everyMinute()
->name('My job')
->withoutOverlapping()
->onOneServer();
$schedule->command('my:command')
->daily()
->at('02:00')
->withoutOverlapping()
->onOneServer();
})
MailPreviewController MUST NEVER create database records.::first() to fetch existing records.::factory()->make() (NOT create()) for unsaved instances.replicate() to clone without saving.->save(), ->create(), ->update(), or ::create().// Correct pattern
$invoice = Invoice::with(['client'])->first();
if ($invoice) {
$id = $invoice->id;
$invoice = $invoice->replicate();
$invoice->id = $id;
} else {
$invoice = Invoice::factory()->make(['id' => 1]);
}
// NEVER
$invoice = Invoice::factory()->create(); // Creates DB record!
$invoice->save(); // Persists to DB!
Inertia::render('Orders/Index', ['orders' => $orders]).resources/js/Pages/ with PascalCase directory structure matching the render path.defineProps<{ orders: Order[] }>().HandleInertiaRequests middleware for data shared across all pages.usePage().props.useForm() helper for basic Inertia form submissions.useForm() provides: reactive data binding, processing state, error handling, dirty tracking.form.errors.form.post(), form.put(), form.delete() for submissions.For complex forms, use the vee-validate + zod system with FormDialog / ResourceForm.
Field types: input | textarea | select | combobox | switch | date | file | spacer | phone
Static (< 100 items): locations, countries, statuses, enums, types.
loc_id: {
type: 'combobox',
comboboxOptions: locations.map(l => ({ label: l.name, value: l })),
displayValue: l => l?.name || '',
}
Backend (100+ items): use searchUrl with HasSearchEndpoint trait.
import { search as searchClients } from '@/actions/App/Http/Controllers/ClientController';
client_id: {
type: 'combobox',
searchUrl: searchClients.url(),
displayValue: i => i?.name || '',
searchFilters: { loc_id: '@loc_id' }, // cross-reference another field
}
HasSearchEndpoint trait + override getSearchableColumns(), getSearchOrderColumn(), getSearchOrderDirection(), transformSearchResult().Route::get('clients/search', [CC::class, 'search'])->name('clients.search'); BEFORE resource route.@field_name syntax in searchFilters references the current value of another form field.props.item.main_container not mainContainer..id from combobox objects that return full objects.import { update } from '@/actions/App/Http/Controllers/ResourceController';
onSubmit: async (v) => {
const payload = {
...v,
loc_id: v.loc_id?.id || v.loc_id,
client_id: v.client_id?.id || v.client_id,
};
router.put(update.url({ resource: props.item.id }), payload);
}
Config: cascadingDependencies: FieldDependency[] -- bidirectional: downward clear + upward auto-populate.
interface FieldDependency { child: string; parents: ParentFieldRef[]; }
interface ParentFieldRef { field: string; key: string; }
cascadingDependencies: [
{ child: 'main_container_id', parents: [{ field: 'location_id', key: 'location' }] },
{ child: 'storage_unit_id', parents: [
{ field: 'location_id', key: 'location' },
{ field: 'main_container_id', key: 'main_container' },
]},
]
transformSearchResult() MUST return nested parent data for upward auto-population.crossFieldValidation: (s) => s.refine(
(d: any) => d.email || d.phone,
{ message: t('...'), path: ['email'] }
)
Backend: 'email' => ['required_without:phone', 'nullable', 'email'] or custom ValidationException::withMessages().
const handleSubmit = async (v: any) => {
try {
/* axios call */
toast.success();
isDialogOpen.value = false;
router.reload({ only: ['resources'] });
} catch (e: any) {
if (!e.response?.data?.errors) {
toast.error(e.response?.data?.message || t('failed'));
}
throw e; // MUST throw for ResourceForm to display field errors
}
};
@submit on FormDialog -- use onSubmit in formDefinition (Vue emit does not await async).catch block MUST throw e so ResourceForm can process validation errors.<Link> component for SPA-style navigation (no full page reload).router.visit() for programmatic navigation.router.reload() for refreshing current page data.preserve-scroll prop on Link.only option to reload specific props without full page data refresh.except option to exclude heavy props on subsequent visits.router.reload({ only: ['orders'] });
<script setup> is the ONLY accepted syntax. NEVER use Options API.<script setup lang="ts"> with TypeScript.ref() for primitive reactive values.reactive() for objects (use sparingly -- prefer ref() for clarity).computed() for derived values that depend on reactive state.watch() and watchEffect() for side effects on state changes.defineProps<{ title: string; items: Item[] }>() with TypeScript interface.defineEmits<{ (e: 'update', id: number): void }>() with typed events.defineModel() for two-way binding (v-model support).useFilters(), useDebounce(), usePagination().use* naming convention.resources/js/Composables/ directory.resources/js/Components/ with PascalCase naming.resources/js/Layouts/.resources/js/Pages/ mirroring route structure.@apply in most cases -- it defeats the purpose of utility-first CSS.@apply is acceptable ONLY in base styles for elements that cannot have classes (e.g., prose content).tailwind.config.js under theme.extend.primary, secondary, success, danger, warning.sm: (640px), md: (768px), lg: (1024px), xl: (1280px), 2xl: (1536px).Simple (< 8 fields): Index.vue + columns.ts, modal create/edit, routes only(['index', 'store', 'update', 'destroy']).
Complex (tabs): Index.vue (modal create) + Edit.vue (tabbed), routes except(['create']).
Sub-resource: components in components/parents/Children.vue + pages in pages/Parents/Children/Index.vue, apiResource, axios + router.reload.
import { destroy } from '@/actions/App/Http/Controllers/ResourceController';
const isDialogOpen = ref(false);
const isEditing = ref(false);
const currentItem = ref<Resource | null>(null);
const formDef = computed(() => ({
fields: { /* ... */ },
initialValues: currentItem.value ? { /* edit values */ } : { /* create defaults */ },
submitText: isEditing.value ? t('update') : t('create'),
onSubmit: handleSubmit,
}));
const openEdit = (item: Resource) => {
isEditing.value = true;
currentItem.value = item;
isDialogOpen.value = true;
};
// Window events for column-triggered actions
onMounted(() => {
window.addEventListener('edit-resource', handleEdit as EventListener);
window.addEventListener('delete-resource', handleDelete as EventListener);
});
onUnmounted(() => {
window.removeEventListener('edit-resource', handleEdit as EventListener);
window.removeEventListener('delete-resource', handleDelete as EventListener);
});
Delete patterns:
router.delete): NO toast -- backend flash message handles it.axios.delete): YES toast -- no flash system available.// Main resource -- no toast
router.delete(destroy.url({ resource: item.id }), {
preserveScroll: true,
onSuccess: () => { showDeleteDialog.value = false; },
onError: () => { toast.error(); },
});
// Sub-resource -- yes toast
await axios.delete(destroy.url({ resource: item.id }));
toast.success(t('deleted'));
router.reload({ only: ['resources'] });
import { h } from 'vue';
import { can, canAny } from '@/utils/abilities';
import { router, usePage } from '@inertiajs/vue3';
import { edit } from '@/actions/App/Http/Controllers/ResourceController';
export const getColumns = (
t: Composer['t'],
locale: string = 'en',
dateFormat: string | null = null, // or dateTimeFormat for timestamp columns
): ColumnDef<Resource>[] => [
{
accessorKey: 'name',
header: () => t('...'),
cell: ({ row }) => h('span', {
class: 'font-medium cursor-pointer hover:underline',
onClick: () => router.visit(edit.url({ resource: row.original.id })),
}, row.original.name),
},
{
accessorKey: 'status',
cell: ({ row }) => h(Badge, {
variant: row.getValue('status') === 'active' ? 'default' : 'secondary',
}, () => t(`statuses.${row.getValue('status')}`)),
},
{
accessorKey: 'active',
cell: ({ row }) => {
const v = row.getValue<boolean>('active');
return h('div', { class: 'flex items-center' },
h(v ? Check : X, {
class: v ? 'h-4 w-4 text-green-600' : 'h-4 w-4 text-muted-foreground',
}),
);
},
},
{
id: 'actions',
cell: ({ row }) => {
const abilities = usePage<AppPageProps>().props.auth.user.abilities;
const canEdit = canAny(abilities, 'resource', ['canUpdateAny', 'canDeleteAny']);
// Pencil (edit), Trash2 (delete), Eye (view-only)
},
},
];
Cell patterns:
font-medium cursor-pointer hover:underlineBadge component with variantCheck / X iconsformatDate(d, locale, dateFormat) -- for date columnsformatDateTime(d, locale, dateTimeFormat) -- for created_at, updated_atformatCurrency() -- produces "150.00 RON"font-monoUse FLAT pagination format. NEVER use nested links/meta format.
// FLAT format (correct)
interface PaginatedResponse<T> {
data: T[];
current_page: number;
last_page: number;
per_page: number;
total: number;
from: number;
to: number;
}
// NEVER use nested format
// { data: [...], links: {...}, meta: {...} }
Backend: WithSortableScope trait + scopeWithSorting().
// $request->validated('sort') returns null for BOTH missing AND empty -- use has() to distinguish
$query->withSorting(
$request->has('sort') ? $request->validated('sort') ?? '' : null,
$this->getDefaultSort()
);
// sortDef = null --> use defaultSort
// sortDef = '' --> no sorting (cleared by user)
// sortDef = 'col' --> asc
// sortDef = '-col' --> desc
Frontend: useSorting() composable + DataTable :sort prop.
const page = usePage();
const currentSort = computed(() =>
new URLSearchParams(page.url.split('?')[1] || '').get('sort') || undefined
);
<DataTable :sort="currentSort ?? 'deadline'" ... />
Sort cycle: asc -> desc -> clear (custom implementation -- TanStack only does asc <-> desc).
URL params:
?sort=col -- ascending?sort=-col -- descending?sort= -- explicitly cleared (empty string, no sorting)Empty string sort= is NOT the same as missing param: empty = explicitly cleared (no sorting), missing = use backend default.
<script setup>
import { search as searchClients } from '@/actions/App/Http/Controllers/ClientController';
</script>
<!-- Static filter -->
<MultiSelectFilter :options="opts" v-model="qp.status_filters" />
<!-- Backend filter -->
<MultiSelectFilter :search-url="searchClients.url()" v-model="qp.client_ids" />
<!-- Cascading filter (depends on another filter value) -->
<MultiSelectFilter
:search-url="searchContainers.url()"
:filters="{ loc_id: qp.loc_filters[0] }"
v-model="qp.mc_filters"
/>
Policy methods: viewAny, create, updateAny, deleteAny -> keys: canViewAny, canCreate, canUpdateAny, canDeleteAny.
Custom action policies: Policy {actionName}Action(User $user): bool -> Abilities.php '{actionName}Action' -> FE permissions: ['resource.can{ActionName}Action'].
Frontend usage split:
import { can, canAny } from '@/utils/abilities' -- utils functions because columns run outside Vue component context.import { useAbilities } from '@/composables/useAbilities' -- composable for reactive access in Vue components.#page-actions slot -- NEVER #actions.<!-- Simple layout -->
<AppLayout>
<ResourceLayout :title="...">
<template #page-actions>
<Button @click="openCreate">
<Plus /> {{ t('create') }}
</Button>
</template>
</ResourceLayout>
</AppLayout>
<!-- Tabbed layout (for complex resources) -->
<AppLayout>
<ResourceTabbedLayout :resource="item">
<ResourceForm />
</ResourceTabbedLayout>
</AppLayout>
?view=articles for same resource tabs | /resources/1/sub-resources for sub-resource pages.usePage().url for current URL detection in tab highlighting.const { t } = useI18n()__('models.r.created')lang/en/models.php, lang/ro/*import { toast } from '@/lib/toast'CRITICAL -- Placeholder syntax:
__('key', ['var' => $v]) uses :var syntax in lang files.t('key', { var: v }) uses {var} syntax in lang files.t(), it MUST use {variable} syntax.__(), use :variable syntax.success_single keys (no :count). Bulk actions use success with count param.Escape sequences in PHP lang files: {'@'}, {'{'}, {'}'}, {'$'}, {'|'}.
import { formatDate, formatDateTime } from '@/lib/utils';
// Date columns: due_date, start_date, end_date, issue_date, payment_date
formatDate(d, locale, page.props.dateFormat)
// Timestamp columns: created_at, updated_at, processed_at, failed_at
formatDateTime(d, locale, page.props.dateTimeFormat)
new Date().toLocaleDateString() or manual date formatting.dateFormat (for dates) or dateTimeFormat (for timestamps) as the third parameter.Files: table-actions/{resource}.ts + table-actions/shared/ (visibility.ts, form-fields.ts, index.ts).
Interface: ActionDefinition in @/types/actions.ts.
| Frontend Config | Backend Pattern | Response |
|---|---|---|
singleRowAction: true + endpoint: (item) => url | Route: {model}/actions/x, Gate::authorize() | FLAT |
singleRowAction: false + requiresSelection: true | Route: actions/x, executeBulkAction() + ids[] | NESTED |
singleRowAction: false + requiresSelection: false | Route: actions/x, executePageAction() | NESTED |
actionType: 'custom' | No backend call | N/A |
singleRowAction: true MUST have endpoint as a function: endpoint: (item) => actionFn.url({ model: item.id }).// FLAT (Pattern A -- single-row): no 'success' boolean
return response()->json(['message' => __('actions.xxx.success'), 'result_id' => $result->id]);
// NESTED (Pattern B/C -- bulk/page): with 'success' boolean + counts
return response()->json([
'success' => true,
'message' => $msg,
'processedCount' => $n,
'totalCount' => $t,
]);
import { doAction } from '@/actions/App/Http/Controllers/Actions/ResourceActionController';
export const getResourceActions = (t: Composer['t']): ActionDefinition[] => [
// Single-row action -- endpoint MUST be a function
{
key: 'do-action',
label: t('actions.do.name'),
singleRowAction: true,
endpoint: (item) => doAction.url({ resource: item.id }),
method: 'post',
permissions: ['resources.canDoAction'],
},
// Bulk action -- endpoint as string
{
key: 'bulk-action',
label: t('actions.bulk.name'),
singleRowAction: false,
requiresSelection: true,
endpoint: () => bulkAction.url(),
method: 'post',
},
];
// table-actions/shared/visibility.ts
export const canDoAction = (item: any): boolean => item.status === 'active' && item.amount > 0;
// table-actions/shared/form-fields.ts
export const createReasonField = (t: Composer['t']): Record<string, FormFieldDefinition> => ({ ... });
// table-actions/shared/index.ts -- barrel exports
// table-actions/resource.ts
import { canDoAction, createReasonField } from './shared';
{ visibilityCondition: canDoAction, formFields: createReasonField(t) }
shared/visibility.ts.shared/form-fields.ts.When the same action needs BOTH single-row (inline button) AND bulk (dropdown with selection):
table-actions/*.ts with the same label (used for deduplication).ActionDropdown auto-deduplicates by label: 1 selected -> shows single-row, multiple -> shows bulk.voidSingle) + Pattern B (void).Controller: Actions/{Resource}ActionController.php
Policy: {actionName}Action(User $user): bool in {Resource}Policy.php
Abilities: Abilities.php -> add '{actionName}Action' to resource array
FE Permissions: permissions: ['resources.can{ActionName}Action']
assertInertia() for testing Inertia page responses, props, and component rendering.RefreshDatabase trait).php artisan test --parallel -- NEVER composer test (too slow).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)
);
});
NEVER commit if ANY of these report errors (even pre-existing ones):
vendor/bin/pint -- code stylevendor/bin/phpstan analyse --memory-limit=1G -- static analysisphp artisan test --parallel -- testsFix ALL errors first, then commit.
@vue/test-utils with mount() or shallowMount().usePage() and useForm() in tests.<script setup>.with() to prevent N+1 query problems.@apply for component styling -- extract to Vue components instead..env and config().any type in TypeScript -- define proper interfaces and types.scopeWithSearch() on models used in index views.App\Models\Traits\WithSortableScope, NOT App\Traits\.getRoutePrefix() for multi-word resource names.#actions slot -- it is #page-actions.displayValue on combobox fields or skip extracting .id on submit.can, canAny) in Vue components -- use useAbilities composable. Conversely, NEVER use composable in .ts column files -- use utils.onMounted/onUnmounted for window event listeners (memory leaks).toLocaleDateString() -- use formatDate() / formatDateTime() from utils.@submit on FormDialog.throw e in catch blocks -- ResourceForm needs the re-thrown error to display field errors.JSON.parse(JSON.stringify()) for deep cloning -- use deepClone(obj) from @/lib/deepClone (recursively strips Vue proxies + functions, then structuredClone preserves File/Date/Blob).Event::listen() in AppServiceProvider -- Laravel 12 auto-discovers listeners via type-hint.$user->notify() -- race condition. Build broadcast data directly with Str::uuid().app/Http/Controllers/{Resource}Controller.php
app/Http/Controllers/Actions/{Resource}ActionController.php
app/Models/{Resource}.php
app/Policies/{Resource}Policy.php
resources/js/components/{resource}/columns.ts
resources/js/components/{resource}/table-actions/{resource}.ts
resources/js/components/{resource}/table-actions/shared/
resources/js/pages/{Resources}/Index.vue
resources/js/pages/{Resources}/Edit.vue
resources/js/types/models.ts
routes/web.php
lang/{en,ro}/models.php
These are non-negotiable requirements. Every implementation MUST satisfy all of them.
Gate::authorize() for authorization -- NEVER use $request->user()->can() + abort(403) or auth()->user()->can(). Always Gate::authorize('ability', $model).store() and update() action MUST use a FormRequest class (or the base controller's validateStore()/validateUpdate() methods). Never validate inline in controllers.scopeWithSearch() on every listed model -- Any model that appears in an index/list view MUST define scopeWithSearch(). Missing this scope breaks the search bar silently.WithSortableScope trait from the correct namespace -- Always use App\Models\Traits\WithSortableScope. The trait provides scopeWithSorting() for backend sort support.bootstrap/app.php -- ALL scheduled jobs and commands MUST be registered via the withSchedule() callback in bootstrap/app.php. NEVER use routes/console.php for scheduling (causes duplicate execution).<script setup lang="ts"> on every Vue component -- All components use Composition API with <script setup> and TypeScript. No exceptions.php artisan test --parallel (never composer test). Pre-commit gate: Pint + PHPStan + tests must all pass.These are strongly recommended patterns that lead to maintainable, performant code.
OrderService, PaymentService).protected property). If used in 1 method, inject as a method parameter. If the method must match a parent signature, use constructor injection even for 1 method. Listeners and Observers always use constructor injection (their method signatures are fixed by Laravel). Traits may use app() inline (no constructor available).app/Listeners/ via handle(Event $event) type-hint. Verify with php artisan event:list.router.reload({ only: ['prop'] }) to refresh specific props without full page data transfer. Reduces bandwidth and improves perceived performance for paginated data, search results, and filtered lists.php artisan wayfinder:generate after any route change. Import actions: import { edit } from '@/actions/App/Http/Controllers/ResourceController'. Use edit.url({ resource: id }) instead of hardcoded strings.with() to prevent N+1 query problems. Never lazy-load relationships in loops.use* composables in resources/js/Composables/. A composable returns reactive state and functions as a self-contained unit.Known pitfalls that have caused production issues or wasted significant debugging time.
$user->notifications()->latest()->first() after $user->notify(). Under concurrent load, another notification may be inserted between the two calls. Build broadcast data directly with Str::uuid()->toString() for the notification ID.Event::listen() duplicates -- NEVER register listeners via Event::listen() in AppServiceProvider. Laravel 12 auto-discovers listeners, so manual registration causes duplicate execution. Verify with php artisan event:list --event=App\\Events\\YourEvent (should show exactly ONE listener).App\Models\Traits\WithSortableScope, NOT App\Traits\WithSortableScope. Using the wrong namespace causes a class-not-found error that is easy to overlook in large diffs.getRoutePrefix() override -- Multi-word resource controllers (e.g., StorageUnitController) MUST override getRoutePrefix() to return the kebab-case route prefix (e.g., 'storage-units'). Without this, route generation and redirects break silently.colSpan:2 ignored in grid layout -- The form grid does not honor colSpan:2. To control row alignment, use a spacer field type with the same condition as the related fields.singleRowAction endpoint must be a function -- When singleRowAction: true, the endpoint property MUST be a function: endpoint: (item) => actionFn.url({ model: item.id }). A string endpoint causes the action to fire against the wrong URL or fail entirely.$request->validated('sort') returns null for BOTH missing AND empty string values. Use $request->has('sort') to distinguish: empty string (?sort=) means user explicitly cleared sorting (no sort applied), while missing param means use the backend default sort.Code patterns that are explicitly banned in this stack. Reject any PR that introduces them.
export default { data(), methods: {} }. All components must use <script setup> with Composition API.defineModel() for two-way binding. Direct mutation causes silent failures and hard-to-trace bugs.@apply for component styling -- NEVER use @apply in CSS/SCSS. It defeats TailwindCSS utility-first design. Extract repeated utility strings into Vue components instead. The only acceptable use is base styles for elements that cannot have classes (e.g., prose content).{ data: [...], links: {...}, meta: {...} }. Always use the FLAT pagination format: { data: [...], current_page, last_page, per_page, total, from, to }.any type in TypeScript -- NEVER use any as a type annotation. Define proper interfaces and types for all data structures. The only exception is catch block parameters (catch (e: any)).Show.vue pages -- NEVER create Show.vue pages. The Edit.vue page serves as a read-only view when the user lacks update permission. Use computed(() => can('resource', 'canUpdateAny')) to toggle edit controls.Naming conventions, file organization, and structural rules for consistency.
OrderList.vue, PaymentForm.vue. Page directories mirror route structure: resources/js/Pages/Orders/Index.vue.snake_case database columns -- All database columns and model attributes use snake_case. Laravel serializes props as snake_case: use props.item.main_container not props.item.mainContainer.Route::resource() for CRUD operations. Simple CRUD: ->only(['index', 'store', 'update', 'destroy']). Complex (tabbed): ->except(['create']). Sub-resources: Route::apiResource('parents.children', ChildController::class)->except(['show']).lang/{en,ro}/models.php. Backend uses :variable placeholders with __('key', ['var' => $v]). Frontend uses {variable} placeholders with t('key', { var: v }). Single-row actions use success_single keys; bulk actions use success with a count param..ts column files (outside Vue component context), use utility functions: import { can, canAny } from '@/utils/abilities'. In Vue components (layouts, pages), use the composable: import { useAbilities } from '@/composables/useAbilities'. Never mix the two contexts.When looking up framework documentation, use these Context7 library identifiers:
laravel/framework -- controllers, routing, Eloquent, validation, events, middlewareinertiajs/inertia -- pages, forms, shared data, navigation, partial reloadsvuejs/core -- Composition API, reactivity, components, lifecycle hookspestphp/pest -- test syntax, assertions, datasets, hookstailwindlabs/tailwindcss -- utility classes, configuration, responsive designAlways check Context7 for the latest API when working with version-specific features. Training data may be outdated for Laravel 12, Inertia 2, and Vue 3.5 specifics.