Appearance
build-crud-in-tempo
build-crud-in-tempo
buildwriteseither
Use this when: you're adding a DB-backed CRUD feature
Problem it solves — A new managed entity touches a dozen files in lockstep — model, migration, FormRequests, controller, typed Vue pages, sidebar, seeder, tests. This scaffolds all of them to Tempo's conventions so nothing is forgotten.
Build CRUD in Tempo
The repeatable shape for a new DB-backed, user-managed entity. The skill is one move: read the HelperSkill files (model, controller, requests, pages, tests) and mirror them — the checklist below is just the parts list so you don't miss a layer. Build every layer in one pass (see be-complete) — a half-scaffolded feature (controller done, tests stubbed) is the failure mode to avoid. Validation, naming and test rules from fyi-tempo-domain and CLAUDE.md still apply.
Doesn't fit the recipe? If the entity isn't user-managed, has no UI, or needs actions beyond resourceful create/edit/delete, don't force it — build it bespoke and skip this skill.
Workflow checklist
- Scaffold:
php artisan make:model X -mf,make:controller XController,make:request StoreXRequest,make:request UpdateXRequest,make:seeder XSeeder(run via the PowerShell tool — seefyi-tempo-domainfor Windows/Herd gotchas). - Migration: real columns; mark naturally-unique columns
->unique(). - Model:
$fillableallow-list (never$guarded = []),$casts; add$hiddenfor everyencryptedattribute — a credential-bearing model that serialises into an Inertia prop without$hiddenis a token-disclosure bug (B2 indocs/security/threat-model.md).use HasFactorywith/** @use HasFactory<XFactory> */and import the factory class. Add query helpers as typed static methods — note EloquentCollection<int, static>(not<int, X>) for PHPStan covariance. - FormRequests: all validation lives here — never
$request->all()or inline rules in the controller. In the controller, call$request->validated(), not$request->all().authorize()returnstrue. Update's unique rule:Rule::unique('table','col')->ignore($this->route('x')). - Controller: resourceful + thin.
Inertia::render('Pascal/Index'|'Create'|'Edit', [...])for GET;to_route('x.index')after store/update/destroy; route-model-binding (X $x). - Routes:
Route::resource('kebab', XController::class)->except(['show'])inside theauth/verifiedgroup inroutes/web.php. - Wayfinder:
php artisan wayfinder:generate --with-formso@/routes/kebabresolves (the generatedresources/js/{routes,actions}are gitignored — regenerate, don't commit). - Vue pages in
resources/js/pages/Pascal/{Index,Create,Edit}.vue; shared row type inresources/js/types/kebab.ts. See "Frontend" below. - Sidebar: add a
NavItemtomainNavItemsinAppSidebar.vuewith a lucide icon andindex()from the Wayfinder routes module. - Seeder:
X::upsert([...], uniqueBy: ['col'], update: [...]); register inDatabaseSeeder. - Tests: Pest feature test (below). Then the gate:
composer ci:check. - Manual-testing guide: add
docs/manual-testing/<kebab>.md(prereqs → env labels + how to obtain → numbered browser tests → troubleshooting; mirrorhelper-skills.md) and a row in that dir'sREADME.md. The same content seeds the PR's Manual testing section so reviewers can exercise it.
Trust-boundary check: a new managed entity can add a trust boundary — a model holding sensitive data, a new write path, a stored credential. When it does, review
docs/security/threat-model.md, add the boundary if it's new, and bump itslast_reviewed(the threat-model cadence trigger).
Frontend conventions
- Forms use
useForm({...}); submit withform.post(store().url)/form.put(update(id).url); delete viarouter.delete(destroy(id).url, { preserveScroll: true }). - Route helpers are named exports:
import { index, create, store, edit, update, destroy } from '@/routes/kebab'.edit(id)/update(id)take the model key. - Components:
Heading(variant="small"),InputError :message="form.errors.x", and@/components/ui/{button (Button + buttonVariants), input (Input, v-model), label, switch (Switch, v-model boolean)}. There is no ui textarea — use a styled<textarea>. - Per-page chrome:
defineOptions({ layout: { breadcrumbs: [{ title, href: index() }] } }). Keep breadcrumb hrefs to real GET routes (never a POST route or a fake id).
Test conventions
tests/Feature/XControllerTest.php,covers(XController::class). Assert->assertInertia(fn ($page) => $page->component('Pascal/Index')->has(...)->where(...)), validation viaassertSessionHasErrors, the auth redirect, unique-column rejection on both store and update, and any cast (e.g. boolean).RefreshDatabaseis applied toFeaturetests automatically viatests/Pest.php.assertInertiafails until the named.vuepage file exists.- Date-dependent logic: freeze the clock per-test in arrange (
Carbon::setTestNow(...)), never globally.
Gates
composer ci:check = Pint check, npm lint/format/types, PHPStan L7, coverage ≥ 80, mutation ≥ 90. Thresholds are fixed — fix by adding tests, never by lowering them.