Projects — Developer Guide
Lean Operations module mirroring Tasks / Opportunities patterns (board, stats, assignee, notes, domain timeline) with members, date-only schedule fields, and soft CRM / Task links. Prefer copying those patterns over inventing new ones. Field is title (not name).
Backend layout
| Piece | Path |
|---|---|
| Models | app/Models/Project.php, ProjectMember, ProjectNote, ProjectActivity |
| Enums | ProjectStatusEnum, ProjectActivityTypeEnum |
| Service | app/Services/Tenant/ProjectService.php |
| Controller | app/Http/Controllers/Tenant/Api/V1/ProjectController.php |
| Requests | app/Http/Requests/Tenant/Api/V1/Project/* |
| Resources | app/Http/Resources/Tenant/Api/V1/Project/* |
| Policy | app/Policies/ProjectPolicy.php |
| Events | app/Events/Project*.php |
| Subscriber | app/Listeners/ProjectEventSubscriber.php (audit + assignee / member notifications) |
| Notifications | ProjectAssignedNotification, ProjectMemberAddedNotification |
| Link rules | LinkableContact, LinkableCompanyForOpportunity, LinkableOpportunityForProject, EligibleProjectAssignee; Tasks uses LinkableProject |
| Dashboard | DashboardWidgetService — active_projects, overdue_projects |
| Factories | ProjectFactory, ProjectNoteFactory, ProjectActivityFactory |
| Tests | tests/Feature/Tenant/Project/ProjectTest.php |
| Migrations | 2026_08_14_100000…100007 — projects tables, permissions, catalog register 1.0.0, tasks.project_id, Tasks bump 1.1.2 → 1.2.0 |
Domain notes
- No hard module dependency: Projects has no
module_dependenciesrow — installable standalone.contact_id/company_id/opportunity_idare nullable soft links. - Status machine on
ProjectStatusEnum::allowedTransitions():planned → active|cancelled,active → on_hold|completed|cancelled,on_hold → active|cancelled,completed/cancelledterminal.changeStatus()throwsValidationException(422,status) for disallowed transitions. - New projects always start as
planned. Default assignee is the creator whenassigned_tois omitted (or when the actor lacksprojects.assign). - Visibility without
projects.assign(and not superadmin):assigned_toORcreated_byORproject_members(Project::isVisibleTo()/ProjectService::applyVisibilityScope()). Withprojects.assign, org-wide. - Assignee is not stored as a member —
normalizeMemberIds()strips the assignee id frommember_ids. PUT /projects/{project}/membersacceptsmember_ids: [](present|array) to clear all members.- Assignee/member pickers:
GET /usersexcludes the authenticated user (Users admin list). The Projects form and record page merge the signed-in user into picker options (same pattern as Departments) so a solo owner can assign themselves. starts_on/ends_onaredatecasts. Overdue = open status + non-nullends_on+ends_onbefore workspace “today” (TenantSettingService::applyRuntimeConfigsonow()->toDateString()is workspace TZ).- Soft Task link: nullable
tasks.project_idFK →projects(nullOnDelete). Validated byLinkableProject(Projects entitled + project visible to actor). Catalog bump Tasks 1.2.0. projects.force.deleteis not granted to any default role — owner/superadmin only.
Permissions
projects.view | create | update | delete | restore | force.delete | assignRoutes use module:projects then can:projects.* / policies.
Catalog: slug projects, category operations, is_default_included = false, is_billable = false, sort_order = 10, version 1.0.0. Registered via DefaultModuleRegistrar migration (migrate-only) — no module_dependencies row.
API (tenant)
Base: /api/tenant/v1 — full reference tenant-v1-projects.md.
| Method | Path | Permission |
|---|---|---|
| GET | /projects | view |
| GET | /projects/stats | view |
| GET | /projects/board | view |
| POST | /projects | create |
| GET | /projects/{project} | view |
| PUT | /projects/{project} | update |
| DELETE | /projects/{project} | delete |
| POST | /projects/{project}/restore | restore |
| DELETE | /projects/{project}/force | force.delete |
| POST | /projects/{project}/assign | assign |
| PUT | /projects/{project}/members | assign |
| POST | /projects/{project}/status | update |
| POST | /projects/{project}/notes | update |
| GET | /projects/{project}/timeline | view |
Frontend
SPA should mirror Tasks (board default + list, create/edit page, record page) under AppLayout — do not invent a parallel shell. Nav: Operations.
| Piece | Path (expected) |
|---|---|
| Page | src/pages/projects/ (board + list) |
| Shared board | src/components/crm/kanban-board.tsx (per-column vertical scroll + contained horizontal scroll; titles stay fixed) |
| Form / detail | create/edit page + record page (overview, members, notes, timeline) |
| Service | projectService in src/api/services.ts |
| Types | Project* in src/types/api.ts; Task gains optional project_id / project |
| Query keys | QUERY_KEYS.projects / project(id) / projectTimeline(id) / projectStats / projectBoard |
| Permissions | PERMISSIONS.projects.* |
| Nav | permission: projects.view, module: 'projects' — Operations sidebar group |
| Route | tenantRoutes.projects = '/projects', RequireAccess module="projects" |
| Notifications | project.assigned, project.member_added → /projects?project={id} |
| Playwright | e2e/pages/projects.page.ts, e2e/tests/projects/, npm run test:e2e:projects |
Tests
php artisan test --compact tests/Feature/Tenant/Project/ProjectTest.php
npm run typecheck && npm run lint && npm run build
npm run test:e2e:projectsLogging
- Spatie
LogsActivityonProject(log nameprojects) - Domain
project_activitiestimeline (created,updated,assigned,members_synced,status_changed,note_added,deleted,restored) PlatformAuditServiceviaProjectEventSubscriber
Ask EloSync
Ask EloSync Project tools (existing get_project / search / overdue plus confirmed status/assign/note writes) are registered in AIToolRegistry and confirmed via PendingAiActionService. Status uses ProjectService::changeStatus (same as HTTP POST …/status). Get payload includes assigned_to (user id) and assignee_name. See AI tools and AI Project triage production readiness.
Deferred
- Gantt, milestones, task dependencies, workload heatmaps
- Calendar projection
- Automation
create_project - Project tags,
PRJ-numbers