| name | route-contracts-match-fe-shape |
| description | FE payload shapes must match BE validator shapes; E2E specs must post what the controller accepts; TanStack parent routes host children via `<Outlet />`; Artisan wrappers distinguish positional from option signatures. Trigger when editing a FE `api.ts` file, a controller `FormRequest` / inline `validate()`, a new TanStack Router route tree, or when wrapping Artisan commands via `Artisan::call`. |
Route contracts match the FE payload shape
Rule
The boundary between FE and BE is a contract. Drift on EITHER side is
a bug:
- FE payload ↔ BE validator shape match. When the SPA posts
{ documents: [{ project_key, content }, ...] }, the controller
must validate that exact shape. { project, markdown } → 422.
- FE route shape ↔ Laravel route definition match. If reset
emails are generated by Laravel with
GET /reset-password/{token},
the SPA cannot host a /reset-password?token=X route — the email
link will 404.
- TanStack Router parent routes render
<Outlet /> — or nested
children never mount. If chatRoute hosts $conversationId, and
chatRoute's component doesn't render <Outlet />, the child
never appears for /app/chat/:id.
- Artisan wrappers distinguish positional from option — parse
the
protected $signature and only prepend -- to option args.
kb:delete {path} takes a positional; passing --path=... leaves
the positional empty.
- Laravel implicit bindings that need
withTrashed() declare
it in the route definition: Route::...->withTrashed() or an
explicit ->bindWithTrashed().
Symptoms in a review diff
- FE test / api.ts payload key mismatch vs controller FormRequest.
- TanStack child route
.update({ getParentRoute: () => chatRoute })
with chatRoute's component missing <Outlet />.
foreach ($args as $k => $v) $out["--$k"] = $v; — treats every
arg as an option.
- FE router adds
/reset-password + ?token= while routes/web.php
keeps Route::get('reset-password/{token}', ...).
How to detect in-code
rg -n '\.(post|put|patch)\([^,]+,' frontend/src/features/ -A 3
rg -n 'createRoute\({[^}]*getParentRoute' frontend/src/routes/ -A 2
rg -l '<Outlet' frontend/src/routes/
rg -n '"-{2}' app/Services/Admin/ -B1
rg -n "Route::.*Document" routes/ | rg -v 'withTrashed'
Fix templates
FE payload matches controller validator (PR #24 admin-kb.spec)
await request.post('/api/kb/ingest', {
data: { project: 'hr-portal', markdown: '# hello' }
});
await request.post('/api/kb/ingest', {
data: {
documents: [{
project_key: 'hr-portal',
source_path: 'hello.md',
content: '# hello\n',
}],
}
});
Side note: keep the shape in a shared test fixture so all callers use
the same source of truth:
export function ingestPayload(overrides = {}) {
return {
documents: [{
project_key: 'hr-portal',
source_path: 'hello.md',
content: '# hello\n',
...overrides,
}],
};
}
Reset-password route shape matches Laravel (PR #19)
const resetPasswordRoute = createRoute({
path: '/reset-password',
...
});
const resetPasswordRoute = createRoute({
path: '/reset-password/$token',
...
});
TanStack parent route renders <Outlet /> (PR #20 ChatView)
function ChatView() {
return <div data-testid="chat-view">...</div>;
}
const chatRoute = createRoute({ path: '/chat', component: ChatView });
const chatConversationRoute = createRoute({
getParentRoute: () => chatRoute,
path: '$conversationId',
component: ChatConversation,
});
import { Outlet } from '@tanstack/react-router';
function ChatView() {
return (
<div data-testid="chat-view">
<ChatSidebar />
<Outlet /> {/* child routes mount here */}
</div>
);
}
Artisan wrapper respects positional vs option (PR #29 CommandRunnerService)
private function invokeArtisan(string $cmd, array $args): int
{
$out = [];
foreach ($args as $k => $v) {
$out["--{$k}"] = $v;
}
return Artisan::call($cmd, $out);
}
private function invokeArtisan(string $cmd, array $args): int
{
$signature = $this->resolveSignature($cmd);
$out = [];
foreach ($args as $k => $v) {
if ($signature->isPositional($k)) {
$out[$k] = $v;
} else {
$out["--{$k}"] = $v;
}
}
return Artisan::call($cmd, $out);
}
Implicit binding with soft-deleted models
Route::get('/api/admin/kb/docs/{document}', [KbDocumentController::class, 'show']);
Route::get('/api/admin/kb/docs/{document}', [KbDocumentController::class, 'show'])
->withTrashed();
Route::group(['prefix' => 'api/admin', 'middleware' => ['can:admin']], function () {
Route::get('kb/docs/{document}', [KbDocumentController::class, 'show'])
->withTrashed();
});
Related rules
- R2 — soft-delete awareness;
withTrashed() on implicit bindings is
the route-level expression of R2.
- R9 — docs match code; every route path in README / docs / swagger
must match the routes file.
- R13 — real-data E2E; a spec posting the wrong shape fails not
because the backend is broken but because the spec was wrong.
Enforcement
copilot-review-anticipator sub-agent reviews FE api.ts file diffs
AND the matching controller FormRequest to check key alignment.
- At runtime, a FE 422 that the user can reproduce by posting via
curl with the docced shape is a R20 bug on the spec, not on the
BE.
Counter-example
await request.post('/api/kb/ingest', { project: 'x', markdown: '...' });
path: '/reset-password'
function ChatView() { return <div/>; }
$out = []; foreach ($args as $k => $v) { $out["--$k"] = $v; }