B5 of TEST-INFRA-001 (RFC-WS-FRONTEND-PRIMEVUE Amendment A-1).
- Add dev-docs/ARCH-TESTING.md (~13 KB):
§1 Five-tier pyramid (Unit / Component / Integration / Visual /
E2E) with environment, cost, and purpose per tier
§2 Decision tree — pick by what is being verified, not by speed
§3 Mock-vs-real-backend rules + the self-confirming-bias anti-
pattern that motivated TEST-CONTRACT-001
§4 Visual baseline workflow including the composite-over-isolated
strategy used in B3
§5 CI strategy stub — deferred to TEST-INFRA-002
§6 Conventions + 5 anti-patterns
§7 Vuetify-during-PrimeVue-migration: explicit doc that the
Vuetify plugin in playwright/index.ts is INTENTIONAL TEMPORARY
STATE replaced in F3 by PrimeVue. Forbids the "abstract the UI
framework provider" deferred-cost trap.
§8 Host setup — Node, pnpm, Chromium, Git LFS, MySQL 8, PHP, .env;
known risks (unpkg.com flakiness, shared crewli_test DB)
§9 Deferred work cross-references to BACKLOG entries
- Update CLAUDE.md ### Testing section to reference ARCH-TESTING.md
- Add ARCH-TESTING.md to .claude-sync.conf so the dev-docs sync
pipeline picks it up; sync script run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
19 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), Vuexy/Vuetify, 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)
Vuexy reference source (mandatory)
When referencing Vuexy demo pages, components, or patterns, ALWAYS use the TypeScript Vue version at:
resources/vuexy-admin-v10.11.1/vue-version/typescript-version/full-version/
This is the ONLY valid reference path. Never use:
javascript-version/— wrong languagestarter-kit/— incomplete, missing components- Any other variant or version
Before implementing any Vuexy-based page or component, read the reference implementation from this path first:
# Example: find auth page references
find resources/vuexy-admin-v10.11.1/vue-version/typescript-version/full-version/src/pages -name "*.vue" | grep -i "login\|auth"
Vuexy-first strategy
Before writing ANY frontend component, consult /dev-docs/VUEXY_COMPONENTS.md and follow this decision tree:
- Can a standard Vuetify component do this? → Use it with default props. Do not wrap it in a custom component.
- Does Vuexy provide an @core component for this? → Use it. Check
/dev-docs/VUEXY_COMPONENTS.mdsection 1 for the full registry. - Does an existing Crewli page already solve a similar UI pattern? →
Copy that pattern exactly. Check
/dev-docs/VUEXY_COMPONENTS.mdsection 3 for established patterns and their reference implementations. - None of the above? → Only then write custom code. Add
<style scoped>with a comment explaining why Vuexy/Vuetify couldn't handle it.
Concrete component rules:
- Tables:
v-data-table-serverwith server-side pagination — never client-side for API data - Cards:
v-carddirectly, orAppCardActionswhen collapse/refresh/remove is needed - Forms in dialogs:
v-dialog+v-card+v-form— follow the established dialog pattern - Detail panels:
v-navigation-drawerwithtemporaryandlocation="end"— follow ShiftDetailPanel pattern - Date/time pickers:
AppDateTimePickerfrom @core — never raw input[type=date] - Status indicators:
v-chipwith color prop — never custom styled spans - Loading states:
v-skeleton-loader— never custom spinners - Error states:
v-alertwith retry button — never custom error divs - Empty states:
v-cardwith icon + message + action button - Notifications:
v-snackbar— never custom toast components - Page layout:
v-row+v-colwith Vuetify breakpoint props — never CSS grid or custom flexbox
Before ANY frontend task: read /dev-docs/VUEXY_COMPONENTS.md to verify
you are using available components rather than building custom ones.
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
Canonical form pattern (used everywhere in the SPA):
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) — Zod schemas mirror backend Form Requests (field names, required/optional, types) and are the canonical contract - No inline validation logic in components
VeeValidate is NOT the form library here. It was previously listed
but never actually adopted in any page; it was removed in commit
<sha> (Session 4 follow-up). Reference forms: 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
- Always use Vuexy/Vuetify for layout, forms, tables, dialogs
- Do not write custom CSS when a Vuetify utility class exists
- Responsive: mobile-first, usable from 375px width
- Three states per page: every data-driven view must handle loading (skeleton/spinner), error (
v-alertwith retry button), and empty (helpful message with action button) - Use Vuetify responsive props (
cols,sm,md,lg) — no fixed pixel widths - Custom CSS via
<style scoped>only as last resort when no Vuetify utility exists
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
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