Development React
Daily workflow
Start everything with composer dev, then edit files in resources/js and Modules/. Vite reloads the browser and regenerates Wayfinder files as you go.
| Command | What it does |
|---|---|
composer dev | Runs php artisan serve, queue:listen, pail and npm run dev together |
npm run dev | Vite dev server with HMR (also regenerates Wayfinder files) |
npm run build | Production build to public/build |
npm run build:ssr | Client + SSR bundle (vite build && vite build --ssr) |
php artisan erag:generate-lang | Export lang/* to resources/js/lang/*.json |
php artisan wayfinder:generate --with-form | Regenerate Wayfinder files without Vite running |
All commands are listed in Commands.
TIP
If a change doesn't show up in the browser, check that npm run dev (or composer dev) is running, or run npm run build.
Adding a page
Example: a Reports page in the Dashboard module at /reports.
routes/web.php → ReportController@index → Inertia::render('reports/index') → pages/reports/index.tsx- Route. Add it to
Modules/Dashboard/routes/web.phpinside theauth+verifiedgroup, withpermission:View Reports|View Tenant Reportsmiddleware and the namereports.index. - Controller. Create
Modules/Dashboard/Http/Controllers/ReportController.php. Keep it thin: call a service inModules/<Module>/Servicesand returnInertia::render('reports/index', [...]). - Page file. Create
resources/js/pages/reports/index.tsxwith a default-exported component and a staticlayoutfor breadcrumbs. - Wayfinder.
@/routes/reportsappears once Wayfinder has run. Withnpm run devrunning it regenerates automatically. - Translations. Add the keys to
lang/en/modules/dashboard.phpand the same file in every other locale, then runphp artisan erag:generate-lang. - Permission. Add
View Reportstoconfig/permissions/dashboard.phpandView Tenant Reportstoconfig/permissions/tenant/dashboard.php, then seed them. - Menu (optional). Add an entry to
database/seeders/MenuSeeder.phpanddatabase/seeders/tenant/MenuSeeder.phpwith'route_name' => 'reports.index'and the permission. - Test. Assert the route renders the
reports/indexcomponent.
The page itself stays small:
export default function ReportsIndex({ reports }: Props) {
const { __ } = reactLang();
return (
<>
<Head title={__('modules.dashboard.reports.title')} />
<Heading title={__('modules.dashboard.reports.title')} />
<ul>
{reports.map((report) => <li key={report.id}>{report.name}</li>)}
</ul>
</>
);
}View route and controller
// Modules/Dashboard/routes/web.php
use Modules\Dashboard\Http\Controllers\ReportController;
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('reports', [ReportController::class, 'index'])
->middleware('permission:View Reports|View Tenant Reports')
->name('reports.index');
});namespace Modules\Dashboard\Http\Controllers;
use App\Http\Controllers\Controller;
use Inertia\Inertia;
use Inertia\Response;
class ReportController extends Controller
{
public function index(): Response
{
return Inertia::render('reports/index', [
'reports' => [],
]);
}
}View full page file
import { reactLang } from '@erag/lang-sync-inertia/react';
import { Head } from '@inertiajs/react';
import Heading from '@/components/heading';
import { dashboard } from '@/routes';
import { index } from '@/routes/reports';
type Props = {
reports: { id: number; name: string }[];
};
export default function ReportsIndex({ reports }: Props) {
const { __ } = reactLang();
return (
<>
<Head title={__('modules.dashboard.reports.title')} />
<div className="space-y-6 px-4 py-6">
<Heading
title={__('modules.dashboard.reports.title')}
description={__('modules.dashboard.reports.description')}
/>
<ul className="space-y-2">
{reports.map((report) => (
<li key={report.id}>{report.name}</li>
))}
</ul>
</div>
</>
);
}
ReportsIndex.layout = {
breadcrumbs: [
{ title: 'modules.common.nav.dashboard', href: dashboard() },
{ title: 'modules.dashboard.reports.title', href: index() },
],
};View translations, permissions and menu
Translation keys in lang/en/modules/dashboard.php (repeat for each locale under lang/<locale>/modules/):
'reports' => [
'title' => 'Reports',
'description' => 'Exported analytics reports.',
],The frontend uses __('modules.dashboard.reports.title'). PHP code uses the slash form: __('modules/dashboard.reports.title').
Permissions:
// config/permissions/dashboard.php
[
'permission_name' => 'View Reports',
'associated_roles' => ['Super Admin', 'Admin'],
],
// config/permissions/tenant/dashboard.php
[
'permission_name' => 'View Tenant Reports',
'associated_roles' => ['Super Admin', 'Admin'],
],Seed them:
php artisan db:seed --class=PermissionSeeder
php artisan tenants:seed --class="Database\Seeders\PermissionSeeder"Users that get a system role afterwards receive the permission from associated_roles. Grant it to existing users from Users → Assign permissions.
For the menu, run php artisan db:seed --class=MenuSeeder after adding the entry. Menu titles are translated from the nav.<slug> keys in lang/<locale>/modules/common.php.
More on roles, permissions and tenant seeding: Users, roles & permissions.
View feature test
use App\Models\User;
use Inertia\Testing\AssertableInertia as Assert;
use Spatie\Permission\Models\Permission;
test('users with the permission can visit the reports page', function () {
$user = User::factory()->create();
$user->givePermissionTo(Permission::findOrCreate('View Reports', 'web'));
$this->actingAs($user)
->get(route('reports.index'))
->assertInertia(fn (Assert $page) => $page->component('reports/index'));
});Run it with php artisan test --compact --filter=reports. config/inertia.php has testing.ensure_pages_exist enabled, so the test fails if reports/index.tsx is missing.
Adding a form
Forms post straight to a Laravel route. There is no API layer.
<Form {...store.form()}> → Controller@store(Data $data) → Service → Inertia::flash('toast') → redirect- Route. Add a
POST(orPATCH/PUT) route with a name and permission middleware. - Validation. Put the rules in a Spatie Data class (
Modules/<Module>/Data/*Data.php,rules()method) and type-hint it in the controller action, asRoleController@store(RoleData $data)does. - Controller. Call the service, flash a toast and redirect.
- Page. Spread the Wayfinder
.form()object into<Form>and useCommon*inputs withnameanderror.
import { store } from '@/routes/roles';
<Form {...store.form()} resetOnSuccess options={{ preserveScroll: true }}>
{({ errors, processing }) => (
<>
<CommonInput name="name" label={__('modules.role.form_modal.name')} error={errors.name} />
<CommonButton type="submit" loading={processing}>
{__('modules.role.form_modal.create')}
</CommonButton>
</>
)}
</Form>Validation errors come back in errors keyed by field name. More patterns (edit forms, useForm, create/edit modals) are in Inertia → Forms.
View controller action
public function store(RoleData $data): RedirectResponse
{
$this->roleService->createRole($data);
Inertia::flash('toast', ['type' => 'success', 'message' => __('modules/role.toasts.created')]);
return to_route('roles.index');
}Type checking & linting
| Command | What it does |
|---|---|
npm run lint | eslint . + prettier --check resources/ + tsc --noEmit |
npm run lint:fix | eslint . --fix + prettier --write resources/ + tsc --noEmit |
npx tsc --noEmit | Type check only |
composer lint | Pint, php artisan typescript:transform, npm run lint:fix |
composer test | Lint check, Larastan, then php artisan test |
Run npm run lint:fix before committing.
Conventions
- No hard-coded text. Every visible string goes through
__()with amodules.<feature>.<key>key, including breadcrumbs, placeholders, toasts and confirm dialogs. - Use the
Common*components for form fields and buttons, anduseConfirmDialog()for destructive actions. - Use Wayfinder instead of hard-coded URLs; use
.form()with<Form>. - Type everything. Use generated types from
@/types/Modules/...for Data objects, andimport typefor type-only imports (enforced by ESLint). - Hide actions by permission with
usePermission().can(...). See Architecture → Permissions. - Placement. Hooks go in
hooks/use-x.ts; page-only components in the page'spartials/folder. Files are kebab-case.