Skip to content

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

  1. Scaffold: php artisan make:model X -mf, make:controller XController, make:request StoreXRequest, make:request UpdateXRequest, make:seeder XSeeder (run via the PowerShell tool — see fyi-tempo-domain for Windows/Herd gotchas).
  2. Migration: real columns; mark naturally-unique columns ->unique().
  3. Model: $fillable allow-list (never $guarded = []), $casts; add $hidden for every encrypted attribute — a credential-bearing model that serialises into an Inertia prop without $hidden is a token-disclosure bug (B2 in docs/security/threat-model.md). use HasFactory with /** @use HasFactory<XFactory> */ and import the factory class. Add query helpers as typed static methods — note Eloquent Collection<int, static> (not <int, X>) for PHPStan covariance.
  4. FormRequests: all validation lives here — never $request->all() or inline rules in the controller. In the controller, call $request->validated(), not $request->all(). authorize() returns true. Update's unique rule: Rule::unique('table','col')->ignore($this->route('x')).
  5. Controller: resourceful + thin. Inertia::render('Pascal/Index'|'Create'|'Edit', [...]) for GET; to_route('x.index') after store/update/destroy; route-model-binding (X $x).
  6. Routes: Route::resource('kebab', XController::class)->except(['show']) inside the auth/verified group in routes/web.php.
  7. Wayfinder: php artisan wayfinder:generate --with-form so @/routes/kebab resolves (the generated resources/js/{routes,actions} are gitignored — regenerate, don't commit).
  8. Vue pages in resources/js/pages/Pascal/{Index,Create,Edit}.vue; shared row type in resources/js/types/kebab.ts. See "Frontend" below.
  9. Sidebar: add a NavItem to mainNavItems in AppSidebar.vue with a lucide icon and index() from the Wayfinder routes module.
  10. Seeder: X::upsert([...], uniqueBy: ['col'], update: [...]); register in DatabaseSeeder.
  11. Tests: Pest feature test (below). Then the gate: composer ci:check.
  12. Manual-testing guide: add docs/manual-testing/<kebab>.md (prereqs → env labels + how to obtain → numbered browser tests → troubleshooting; mirror helper-skills.md) and a row in that dir's README.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 its last_reviewed (the threat-model cadence trigger).

Frontend conventions

  • Forms use useForm({...}); submit with form.post(store().url) / form.put(update(id).url); delete via router.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 via assertSessionHasErrors, the auth redirect, unique-column rejection on both store and update, and any cast (e.g. boolean).
  • RefreshDatabase is applied to Feature tests automatically via tests/Pest.php. assertInertia fails until the named .vue page 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.