After today's WS-6 feat-branch audit revealed ten stale branches that had been merged via squash/cherry-pick paths but never deleted, codify the cleanup expectation directly in CLAUDE.md. Each feature branch is expected to be deleted locally and on origin immediately after merge — not "eventually" — to prevent SHA-illusion confusion in future audits. Co-Authored-By: Claude <noreply@anthropic.com>
16 KiB
Crewli — Claude Code Instructions
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 both SPAs.
See
/dev-docs/FRONTEND-TOOLING.md. New TypeScript code adheres to ts-reset's stricter types automatically. - Vitest —
apps/portalhas 113+ tests;apps/appcurrently has no Vitest setup (tracked as TECH-APP-VITEST, must close before S3b lands).
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/— Organizer SPA (main product app + Platform Admin for super admins)apps/portal/— External portal (volunteers, artists, suppliers, etc.)
Apps and portal architecture
apps/app/— Organizer: event management per organisation. Includes Platform Admin section (/platform/*) for super_admin users (organisation management, user management, impersonation, activity log).apps/portal/— External users: one app, two access modes:- Login-based (
auth:sanctum): volunteers, crew — persons withuser_id - Token-based (
portal.tokenmiddleware): artists, suppliers, press — persons withoutuser_id
- Login-based (
CORS
Configure two frontend origins in both Laravel (config/cors.php via env) and the Vite dev server proxy:
- app:
localhost:5174 - portal:
localhost:5175
Production (crewli.app): API https://api.crewli.app, SPAs https://crewli.app, https://portal.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, SPAs, 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 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
- VeeValidate for form state + Zod for schema validation — always together
- Zod schemas must mirror the backend Form Request rules (field names, required/optional, types)
- No inline validation logic in components
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
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