CLAUDE.md updated for the Vuetify→PrimeVue migration phase per RFC-WS-FRONTEND-PRIMEVUE F2: - Stack line: notes PrimeVue + Tailwind v4 as target, Vuetify still present on un-migrated surfaces - Replaced "Vuexy reference source" + "Vuexy-first strategy" sections with a single "UI framework strategy (migration-aware)" section that splits guidance into migrated / un-migrated / new surfaces and forwards to PRIMEVUE_COMPONENTS.md - Forms section now documents both target (@primevue/forms + Zod resolver via FormField) and legacy (ref + VForm + :rules) patterns, with the surface-level-consistency rule - UI section reframed: PrimeVue + Tailwind on migrated surfaces, Vuetify utilities on legacy surfaces, three-state pattern preserved on both - Order of work: framework note added for new pages during F4 Framework-agnostic sections (database, multi-tenancy, ULID, controllers, models, security, testing) untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
20 KiB
Crewli — Claude Code Instructions
See
dev-docs/CLAUDE_CODE_TOOLING.mdfor the deterministic guard-rail layer (hooks, subagent, slash commands).
Project context
Crewli is a multi-tenant SaaS platform for event and festival management.
Built for a professional volunteer organisation, with potential to expand as SaaS.
Design document: /dev-docs/design-document.md
Tech stack
- Backend: PHP 8.2+, Laravel 12, Sanctum, Spatie Permission, MySQL 8, Redis
- Frontend: TypeScript, Vue 3 (Composition API), PrimeVue + Tailwind v4 (target state, migration in progress per RFC-WS-FRONTEND-PRIMEVUE) — Vuetify/Vuexy still present on un-migrated surfaces during F4; see
PRIMEVUE_COMPONENTS.md. Pinia, TanStack Query. - Testing: PHPUnit (backend), Vitest (frontend)
Quality gates
php artisan test— PHPUnit feature suite. See Testing rules below../vendor/bin/pint— code style. Runs pre-commit in scope; format before merge.composer analyse— Larastan static analysis at level 6 with accept-all baseline. New errors beyond the baseline must be fixed before merge. See/dev-docs/LARASTAN.md.composer rector— Rector dry-run for modernisation suggestions. See/dev-docs/RECTOR.md. Apply only in scoped sprints, never automatically.- ts-reset patches TypeScript's loosest default types in the SPA.
See
/dev-docs/FRONTEND-TOOLING.md. New TypeScript code adheres to ts-reset's stricter types automatically. - Vitest —
apps/apphas Vitest with 213 tests as of WS-3 PR-B2a. Test count grows with each PR; checkpnpm testfor current value.
Development tooling
- Laravel Telescope at
/telescope— queries, jobs, mails, redis, events. Local + testing only, never production. super_admin role required to view. See/dev-docs/TELESCOPE.md.
Repository layout
api/— Laravel backendapps/app/— Single SPA covering organizers, volunteers, crew, super admins (context-routed in-app) plus the public form-fill / artist-advance flows
App architecture
apps/app/ — single workspace, two access modes:
- Login-based (
auth:sanctum): organizers, volunteers, crew, super_admin. Includes Platform Admin section (/platform/*) for super_admin users (organisation management, user management, impersonation, activity log). Context-aware routing inside the SPA distinguishes organizer vs. volunteer experience based onuseAuthStore.availableContexts(seedev-docs/AUTH_ARCHITECTURE.md). - Token-based (
portal.tokenmiddleware): artists, suppliers, press — persons withoutuser_id. Stateless per-request token viaAuthorization: Bearerheader or?token=query parameter.
CORS
Single frontend origin in both Laravel (config/cors.php via env) and the Vite dev server proxy:
- dev:
localhost:5174 - prod:
https://crewli.app
See api/.env.example for FRONTEND_* and SANCTUM_STATEFUL_DOMAINS. crewli.nl is only for a future marketing site; this application stack uses crewli.app (not .nl for API, SPA, or transactional mail).
Backend rules (strict)
Multi-tenancy
- Every query on event data must scope on
organisation_id - Use
OrganisationScopeas an Eloquent global scope on all event-related models - Never use raw ID checks in controllers — always use policies
Controllers
- Use resource controllers (
index/show/store/update/destroy) - Namespace:
App\Http\Controllers\Api\V1\ - Return all responses through API resources (never return raw model attributes)
- Validate with form requests (never inline
validate())
Models
- Use the
HasUlidstrait on all business models (not UUID v4) - Soft deletes on: Organisation, Event, FestivalSection, Shift, ShiftAssignment, Person, Artist
- No soft deletes on: CheckIn, BriefingSend, MessageReply, ShiftWaitlist (audit records)
- JSON columns only for opaque configuration — never for queryable/filterable data
Database
Crewli uses MySQL 8.0 exclusively across all environments:
- Local development: MySQL 8.0 via
docker-compose.yml(bm_mysqlcontainer, port 3306). Runmake servicesto start. - Testing: MySQL 8.0 via the same Docker container, separate
crewli_testdatabase. Runmake test-db-createonce, thenmake test. - Staging/production: MySQL 8.0 on the deployment VPS.
SQLite is forbidden in all environments. Reasons:
- SQLite has known quirks around foreign-key constraints (rebuild-on-FK-add cascades), JSON queries (key-order non-determinism on round-trip differs from MySQL), and concurrent writes that mask bugs which MySQL would surface in production
- Cross-database query syntax differences cause silent test/production drift (e.g.
sqlite_mastervs.information_schema, VARCHAR length enforcement) - Crewli's enterprise-grade product philosophy doesn't tolerate "but it works on SQLite" as a passing test
If a contributor finds SQLite references in phpunit.xml, .env.testing, or any config file, treat it as a regression and fix immediately.
When writing migrations or queries:
- Use Laravel's query builder where possible — it handles cross-DB syntax consistently
- For raw SQL, write MySQL syntax (no need for SQLite fallbacks). Index introspection: query
information_schema.STATISTICS, neversqlite_master. - Foreign keys go on every relation column. The
nullOnDelete/cascadeOnDeletechoice is per the relationship's domain semantics; "no FK" is not an option. - JSON values stored in byte-stable columns (
schema_snapshot, webhookpayload_snapshot, activity-logpropertiesfor diff payloads) MUST be canonicalized at write viaApp\Support\Json\JsonCanonicalizer. MySQL JSON columns may reorder associative-array keys on round-trip; canonicalize so re-emits / HMAC signatures / audit-replay diffs are byte-identical. Opaque-config JSON (form_schemas.settings,translations) is exempt — key order has no semantic meaning there. Tests on canonicalized data useassertSame(JsonCanonicalizer::encode($a), JsonCanonicalizer::encode($b)).
Other database rules:
- Primary keys: ULID via
HasUlids(not UUID v4, not auto-increment on business tables) - Create migrations in dependency order: foundation first, then dependent tables
- Always add composite indexes as documented in the design document (section 3.5)
Schema dumps (CI fast path)
Laravel uses database/schema/{driver}-schema.sql as a fast-path
baseline that migrate:fresh loads in one statement (~1s) instead of
replaying every migration (~6s × N migrations). For Crewli's backfill
/ migration tests that call migrate:fresh per test, this drops the
4 backfill-test classes from ~128s to ~28s combined (78% reduction).
The dump is committed at api/database/schema/mysql-schema.sql
and is the default code path. CI loads it automatically via
migrate:fresh.
One-time host setup (required for both generating and loading the dump):
brew install mysql-client
echo 'export PATH="/opt/homebrew/opt/mysql-client/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc
Verify with which mysql && which mysqldump. Both must resolve to a
real binary; Laravel's schema:dump and migrate:fresh schema-load
both shell out to these.
After adding a new migration:
make schema-dump
git add api/database/schema/mysql-schema.sql
make schema-dump brings crewli_test to head and runs
php artisan schema:dump. Commit the regenerated file in the same
PR as the migration.
--prune is NOT used: individual migration files stay readable in
api/database/migrations/ for audit / rollback purposes.
Roles and permissions
- Use Spatie
laravel-permission - Check roles via
$user->hasRole()and policies — never hard-code role strings in controllers - Three levels: app (
super_admin), organisation (org_admin/org_member), event (event_manager, etc.)
Testing
- Write PHPUnit feature tests per controller
- Minimum per endpoint: happy path + unauthenticated (401) + wrong organisation (403)
- Use factories for all test data
- After each module:
php artisan test --filter=ModuleName
Frontend test architecture (5 tiers: Unit / Component / Integration /
Visual / E2E) is documented in dev-docs/ARCH-TESTING.md. Choose the
right tier per the decision tree there before adding new tests.
Frontend rules (strict)
UI framework strategy (migration-aware)
The SPA is migrating Vuetify/Vuexy → PrimeVue + Tailwind v4 per RFC-WS-FRONTEND-PRIMEVUE. During F4 (sub-packages F4a–F4d), both frameworks ship in the same build on different surfaces. The component-selection rules depend on which side of the migration the surface is on.
Always read PRIMEVUE_COMPONENTS.md
before any frontend task — it is the authoritative reference for
component selection, theming, forms, and DataTable conventions across
both phases.
On migrated surfaces (target state)
PrimeVue is the framework. Follow PRIMEVUE_COMPONENTS.md:
- Can a Tailwind utility do this? (layout, spacing, typography) → use it.
- Does PrimeVue provide a component? → use it (see §3 component mapping).
- Forms →
@primevue/forms+ Zod resolver via<FormField>(§5; full API in RFC Appendix A). - DataTables →
<DataTable>with:lazy="true"for server-side (§6). - None of the above? → cross-reference https://primevue.org/ for the closest match. Add a note in
PRIMEVUE_COMPONENTS.md§3 if it's a recurring need.
Customization order: Tailwind utilities (layout) → pt API (component-internal) → Aura preset extension (brand-wide) → <style scoped> (last resort, with comment).
On un-migrated surfaces (legacy, transient)
Vuetify + Vuexy @core/ components remain in use until the surface's F4
sub-package lands. When extending these surfaces during the transition:
- Match the surrounding code (
<VBtn>,<VTextField>,<v-data-table-server>, etc.) - Reference the pre-F2 Vuexy registry via git:
git show 1c449ff6204cae6371da08c34ea8934d6b2ffcb8:dev-docs/VUEXY_COMPONENTS.md - Vuexy template reference (when needed):
resources/vuexy-admin-v10.11.1/vue-version/typescript-version/full-version/— TypeScript Vue version is the only valid path
Do not introduce PrimeVue components inside an un-migrated surface
("no back-porting" — see PRIMEVUE_COMPONENTS.md §9).
On new surfaces (created during or after F4)
Start in PrimeVue. The migration phase is not a license to add new Vuetify code.
Vue components
- Always
<script setup lang="ts">— never the Options API - Type props with
defineProps<{...}>() - Declare emits with
defineEmits<{...}>()
API calls
- Use TanStack Query (
useQuery/useMutation) for all API calls - Never call axios directly from a component — always via a composable under
composables/api/use[Module].ts src/lib/axios.tsis the canonical axios instance — the only place axios is imported directly- Use Pinia stores for cross-component state — no prop drilling
TypeScript
- No
anytypes — ever. Every variable, prop, emit, return type, ref, computed must be fully typed - Types first: create
src/types/[module].tsbefore composables or components - Mirror backend PHP Enums as
as constobjects insrc/types/:export const ShiftStatus = { PENDING: 'pending', APPROVED: 'approved' } as const export type ShiftStatus = typeof ShiftStatus[keyof typeof ShiftStatus]
Forms
The canonical form pattern depends on the migration phase of the surface:
Target state (migrated surfaces, new surfaces): @primevue/forms +
Zod resolver via the <FormField> wrapper. Full API specification in
RFC-WS-FRONTEND-PRIMEVUE Appendix A;
Crewli conventions in PRIMEVUE_COMPONENTS.md §5.
One Zod schema per form, field names mirror backend Form Request keys
(snake_case), 422 errors merge via useFormError(formRef).
Legacy state (un-migrated surfaces, transient until each F4 sub-package):
ref({ field: ... })for form stateVFormref + per-field rules drawn from@core/utils/validators(requiredValidator,emailValidator, etc.)- A separate
errors: Ref<Record<string, string>>for server-validation feedback (mapped from 422 responses) - Zod for runtime validation of API payloads/responses (in
apps/app/src/schemas/*.ts) — schemas already mirror backend Form Requests and carry forward unchanged into the target state - No inline validation logic in components
A single form is either fully Zod-resolver-validated (target) or fully
:rules-validated (legacy) — never a hybrid. VeeValidate is NOT in
the stack on either side of the migration.
Reference forms (legacy pattern, will migrate during F4):
apps/app/src/components/sections/CreateShiftDialog.vue,
apps/app/src/components/timetable/AddPerformanceDialog.vue,
apps/app/src/pages/register/[public_token].vue.
Naming
- DB columns:
snake_case - TypeScript / JS variables:
camelCase - Vue components: PascalCase (e.g.
ShiftAssignPanel.vue) - Composables:
useprefix (e.g.useShifts.ts) - Pinia stores:
useprefix +Storesuffix (e.g.useEventStore.ts)
UI
- Component framework selection: see "UI framework strategy" above and
PRIMEVUE_COMPONENTS.md. PrimeVue + Tailwind v4 on migrated/new surfaces; Vuetify on un-migrated surfaces during F4 - Do not write custom CSS when a framework utility (Tailwind on migrated surfaces, Vuetify utilities on legacy surfaces) exists
- Responsive: mobile-first, usable from 375px width
- Three states per page: every data-driven view must handle loading (skeleton), error (
Message/v-alertwith retry button), and empty (helpful message with action button) — both frameworks support this pattern - Responsive layout: Tailwind grid (
grid grid-cols-12 gap-4+col-span-N md:col-span-M) on migrated surfaces; Vuetifyv-row+v-colwith breakpoint props on legacy surfaces — no fixed pixel widths - Custom CSS via
<style scoped>only as last resort when no framework utility /ptAPI / Aura token can do the job
Forbidden patterns
- Never:
$user->role === 'admin'(use policies) - Never:
Model::all()without awhereclause (always scope) - Never: leave
dd()orvar_dump()in code - Never: hard-code
.envvalues in code - Never: use JSON columns for data you need to filter on
- Never: UUID v4 as primary key (use
HasUlids) - Never: TypeScript
anytype (use proper types, generics, orunknownwith type guards) - Never: import axios directly in a component (use
src/lib/axios.tsvia a composable)
Frontend import boundaries (apps/app/)
apps/app/ enforces a layered import architecture via
eslint-plugin-boundaries. Ten zones (types → utils → lib →
plugins / composables / stores / navigation → components →
layouts → pages); each zone may only import from the zones below
it in the matrix. Vendored @core/ and @layouts/ are exempt.
Cross-zone violations are lint errors, not warnings.
Matrix details + rationale: dev-docs/WS-3-SESSION-1C-AUDIT.md.
Config: apps/app/.eslintrc.cjs.
When adding a new file: pick the zone first. If your file imports
from a zone the matrix forbids, the structural answer is usually to
hoist a type to types/ or extract a helper to utils/ /
composables/ — not to disable the rule. Per-line disables are
allowed only with a TODO TECH-* reference to a backlog item.
Order of work for each new module
- Create and run migration(s)
- Eloquent model with relationships, scopes, and
HasUlids - Factory for test data
- Policy for authorization
- Form request(s) for validation
- API resource for response shaping
- Resource controller
- Register routes in
api.php - Write and run PHPUnit feature tests
- TypeScript types in
src/types/[module].ts - API composable in
src/composables/api/use[Module].ts - Pinia store in
src/stores/use[Module]Store.ts(only if cross-component state is needed) - Vue page component in
src/pages/[module]/ - Add route in Vue Router
Framework note for steps 13–14 during F4 migration: new pages follow the PrimeVue + Tailwind conventions in
PRIMEVUE_COMPONENTS.md. If the new module is grafted onto a not-yet-migrated surface (rare), match the surrounding Vuetify style and let the surface's F4 sub-package migrate it later.
Diagnostic discipline: audit before assume
When debugging or fixing any bug, the first action is to verify the canonical model against the artifact in question — not to write a fix based on the symptom or hypothesis.
This applies to:
- Schema drift (verify the resource shape against the Zod schema, line by line)
- Filter logic (verify the data model in SCHEMA.md before assuming a controller is wrong)
- UX divergence (verify the prototype line-by-line, not via spot check)
- Test failures (verify the assertion matches the documented contract, not the current implementation)
Phase A of every fix prompt is STOP-and-report. No code is written before the audit is reviewed.
This principle was formalised after three consecutive incidents where initial hypotheses were wrong and the audit gate caught them: B1 (controller assumed buggy, seeder was wrong), B5 (enum-shape assumed drifted, decimals were wrong), and timetable UX (test-passing layer diverged from prototype, mechanical-vs-UX split surfaced via browser test).
User Documentation (VitePress)
End-user documentation lives in /docs/ as a VitePress site. Developer documentation (SCHEMA.md, API.md, etc.) lives in /dev-docs/.
When to write docs
When completing a feature that introduces or changes user-facing behaviour, create or update the corresponding documentation page under /docs/.
How to write docs
- Read /docs/.templates/style-guide.md for terminology, tone, and structure rules
- Use /docs/.templates/feature-page.md or concept-page.md as your starting template
- Every page MUST have frontmatter with: title, description, tags
- Use Dutch language for all content (informal "je/jij")
- Use Crewli terminology from the style guide — never English equivalents in user docs
What to include
- What the feature does (2-3 sentences)
- Step-by-step instructions from the user's perspective
- Which roles have access (table format)
- Screenshot placeholders where visual guidance helps:
 - Links to related pages
File placement
Match the existing structure under /docs/:
- Organizer features → /docs/organizer/[category]/
- Volunteer features → /docs/volunteer/
- Portal features → /docs/portal/
- General concepts → /docs/guide/
Git Commit Policy
After every successful task completion, commit the changes immediately.
Rules:
- After all tests pass and build succeeds, stage and commit the changed files
- Use conventional commit messages:
feat:,fix:,refactor:,docs:,test:,chore: - One commit per logical unit of work (one feature, one bugfix, one refactor)
- Never bundle unrelated changes in a single commit
- Never commit with failing tests
- Do NOT push automatically — only commit locally. The developer will push manually.
Commit message format:
feat: short description of the feature
fix: short description of the bug fix
refactor: short description of the refactoring
docs: short description of documentation changes
test: short description of test additions
- After a feature branch is merged into main, delete it locally
(
git branch -d <branch>) and remotely (git push origin --delete <branch>) immediately. Stale feature branches accumulate cognitive load and obscure SHA-level history when squash-merges leave commit hashes orphaned on the original branch.
Examples:
feat: person tags system with org-level skills and sync endpointfix: auth race condition on page refreshdocs: update SCHEMA.md with person_identity_matches table