Contributing to CRM
A short opinionated guide for anyone touching the CRM codebase. Pairs with quickstart (which covers the "how do I add an endpoint" flow) and troubleshooting (when something's wrong).
Coding standards
Backend (Laravel)
- Strict types not required, but type-hint every public method signature.
- Skinny controllers — validation, delegation, resource response. Anything else moves to a service or a sub-controller (see controller-split).
- Models own state machines —
LeaveRequest::TRANSITIONS,TaskHistory::ALLOWED_ACTIONS, etc. New states/types land here. - Use named permissions in routes — every
Route::*should carry->middleware('can:permission').php artisan crm:list-permissionsis the registry. - One assertion per migration intent — don't bundle unrelated schema changes.
- Form Requests for body validation when the request has more than ~5 rules; inline
$request->validate()for the rest.
Frontend (Vue 3 + Pinia)
- Stores own snackbar dispatch via
useApiError(error). Pages don'tnotify.success/notify.error. - List pages use the composable trio (
useApiErrorin the store,useDeleteDialog+usePaginationDeepLinkin the page). - Typed SDK only, never raw axios — call generated SDK methods with the matching
crmClient(the typed client). Regenerate vianpm run api:syncafter a backend change. - i18n every string — even the small ones.
useI18nis cheap. <script setup lang="ts">in every component; no Options API.
Adding a new feature
The walkthrough in quickstart is the canonical "add an endpoint" recipe. Don't skip:
- Route with explicit
can:middleware. - Controller method (thin).
- Resource (typed response).
npm run api:syncto regenerate the SDK.- Store action using
useApiError. - Component using the store.
- Test (Feature for the backend, vitest for the composable/store).
PR checklist
Before opening a PR:
# Backend
cd tenant/tenant-backend-crm
php artisan test
composer normalize
php -l app/**/*.php
# Frontend
cd tenant/tenant-frontend
npm run typecheck
npm run lint
npm run test # vitest unit tests
npm run api:check # CI gate for SDK staleness
CI runs these too; running locally just keeps the loop tight.
Commit messages
Short, imperative, no AI co-author tags. Example:
TaskController: extract assignment endpoints to TaskAssignmentController
F3 batch 15 split. 7 routes rebind, 4 methods move. Trait
EnsuresProjectMembership absorbs the shared guard. TaskController
shrinks 660 → 360 lines.
Skip "Co-Authored-By Claude" and similar — see project memory.
Migration safety
Forward-only by convention. If you need to roll back:
- For a schema change: write a new "fix-forward" migration.
- For an Eloquent change: revert in code and ship a patch release.
The 2026-05-12 tenant_id drop is the precedent — a coordinated
forward-only schema + code change touching 46 tables.
When to split a controller
If a controller hits ~400 lines, look for an extract:
- Sub-resource endpoints (
/parent/{id}/sub-resource/*) usually move cleanly to a per-sub-resource controller. - Read-aggregate endpoints (stats, timeline) can split into a
*StatsController. - Webhook / event-emit logic stays with the resource that owns the lifecycle event.
The Chat split (1 → 6 controllers) and Task split (1 → 4 controllers) are the references; see controller-split for the diagrams.
When to add a composable
If a Vue 3 pattern shows up in 3+ places:
- Extract into
src/composables/use<Name>.ts. - Co-locate a test (
use<Name>.test.ts). - Update the
src/composables/README.mdinventory.
Don't over-extract: a single-use helper stays in the calling component.
When to add an i18n key
- Every UI string — even short ones — gets a key in
hu.json+en.json.de.jsonis partial; CRM-namespace keys are filled, the broader Vuexy strings are deferred. - Notification keys live under
<module>.notifications.{action}/{actionFailed}. Pattern reference in composables README. - Don't reuse
common.*for module-specific copy — split when in doubt.
Audit-trail (ActivityLog)
When you add a compliance-relevant write endpoint:
ActivityLog::create([
'user_id' => (string) ($user['id'] ?? ''),
'action' => 'foo.event_verb',
'subject_type' => Foo::class,
'subject_id' => $foo->id,
'properties' => array_merge($this->auditActor($user), [
'previous_x' => $previous,
'new_x' => $new,
]),
'created_at' => now(),
]);
auditActor() on BaseController returns actor_user_id + the
optional impersonator_id. Use it for everything that an admin
might do on behalf of a user. Pattern reference: Blog/Page publish,
ChatBot token regen, Project::destroy.
Test conventions
| Test type | Tool | Location |
|---|---|---|
| Pure model contract | PHPUnit | tests/Unit/*Test.php |
| Container-dependent | PHPUnit + Laravel TestCase | tests/Feature/*Test.php |
| Composable | Vitest + happy-dom | src/composables/*.test.ts |
| Store | Vitest + Pinia | src/modules/*/stores/*.test.ts |
| E2E flow | Cypress | cypress/e2e/ |
Pinning structural contracts of new controllers/traits in Unit
tests catches the cheapest class of regressions — see
ConcernsContractTest, ChatReactionControllerContractTest etc.
Where to ask
- Architectural questions → architecture + the team channel
- "How do I X" → quickstart
- "Something's broken" → troubleshooting
- "Why does this exist" →
tenant/tenant-backend-crm/AUDIT_FINDINGS.md