Module Development — Developer Guide
Canonical checklist for building a business module. Copy the Leads module structure; do not invent a second pattern.
Registration recipe
- Catalog (production) — Ship an idempotent data migration that calls
App\Support\Catalog\DefaultModuleRegistrar(slug, pricing defaults,status=published,is_default_included/is_billable, optionalversion). Also keepCatalogSeederin sync for local/CI fresh DBs only — never rely ondb:seedin production. Optional: Central Modules API for non-default / commercial catalog edits. - Permissions (production) — Add
{slug} => [view, create, update, delete, …]inconfig/tenant-permissions.phpand default grants inconfig/tenant-default-role-permissions.php. Ship a data migration that callsTenantPermissionSynchronizer::grantMissingDefaultRolePermissions([...])so existing workspaces receive grants additively. Modules never auto-grant permissions; roles do. Login never syncs RBAC. - Routes — Tenant API:
Route::middleware(['auth:tenant-api', 'tenant.user', 'verified', 'module:{slug}', 'can:{slug}.view'])->group(function () {
// …
});- Domain code — Flat under existing namespaces (no
Modules/package):
| Layer | Location |
|---|---|
| Models | app/Models/ + BelongsToTenant |
| Migrations | database/migrations/ |
| Factories | database/factories/ |
| Seeders | database/seeders/Tenant/ for local/demo only; production catalog/permission rows use data migrations |
| Controllers | app/Http/Controllers/Tenant/Api/V1/ |
| Form requests | app/Http/Requests/Tenant/Api/V1/{Module}/ |
| Resources | app/Http/Resources/Tenant/Api/V1/{Module}/ |
| Policies | app/Policies/ |
| Services | app/Services/Tenant/ |
| Events / Listeners | app/Events/, app/Listeners/ |
| Notifications | app/Notifications/Tenant/ |
- Frontend —
src/pages/{slug}/, API service, types,PERMISSIONS/QUERY_KEYS, nav item withpermissionandmodule, route underTenantProtectedRoute. - Tests — Pest feature suite + Playwright
test:e2e:{slug}. - Docs — User / developer / production guides, API, database, CHANGELOG.
Logging (both required)
| Layer | Mechanism | Purpose |
|---|---|---|
| Audit | PlatformAuditService → activity('platform') | Actor, workspace, IP, UA, action for create/update/delete/assign/status changes |
| Activity | Spatie LogsActivity on primary model | Attribute-level change history |
| Timeline | Domain *_activities table (when UX needs it) | User-facing history (notes, stage moves, assignments) |
Domain notes and activities relationships on show payloads default to newest-first (->latest('created_at')->latest('id')), matching dedicated GET …/timeline endpoints. This is a stable API contract — document it on the module’s Tenant v1 page; clients must not assume ASC. Detail UIs map API order as-is. Do not leave these HasMany relations unordered. Prefer composite indexes (tenant_id, parent_id, created_at) on *_notes (and similar) so DESC order stays cheap; do not put a hard limit() on eager-loaded HasMany (Laravel limits the whole query, not per parent).
Events
Dispatch domain events from the service layer. Listeners handle audit side-effects and notifications. Do not build per-module notification stacks outside Laravel notifications.
Automation hooks (required when the module emits lifecycle events)
When a module has create / status / assign (or similar) domain events that operators should automate:
- Register trigger(s) in
AutomationTriggerRegistrywithmodule=>'{slug}'andwired=>true(or document why not in the module overview deferred list). - Fan out only via
IntegrationEventDispatcher+IntegrationEventPayloadBuilder(payloads must includeentity_type/entity_idand assignee fields when applicable). Do not add a parallel Automation event bridge. - Gate create-style actions that depend on another module with that module’s slug (for example
create_task→tasks). - Optionally add a starter template in
WorkflowTemplateRegistrywithrequired_modules. - Pest: entitlement gate + happy-path run; Playwright when the builder catalog changes.
- Catalog version bump + Docs / CHANGELOG in the same milestone.
Catalog APIs mark triggers/actions available from EntitlementService::hasModule. The SPA builder disables unavailable items; WorkflowService::activate remains the hard gate.
See Automation developer guide.
Dashboard widgets
When a module contributes dashboard cards:
- Extend
App\Services\Tenant\DashboardWidgetService(same registry pattern as Leads/Tasks). - Gate each widget on
EntitlementService::hasModule+ the user’s Spatie permission. - Apply assignee scoping with
ScopesToAssigneewhen the module uses{slug}.assign. - Return
{ id, module, permission, scope, data }objects only — the SPA renders byid. - Do not invent a parallel dashboard API; extend
DashboardWidgetService(Leads/Tasks/Calendar pattern).
In-app notifications
- Use Laravel notification channels required by the contract for that event (
databaseplusbroadcastfor realtime in-app delivery; addmailonly when product behavior requires it). - Implement
ShouldQueueand useApp\Notifications\Concerns\QueuesOnEmailsso jobs land on the dedicatedemailsqueue (php artisan queue:work --queue=emails). - Persist via the standard
notificationstable; expose tenant APIs under/notifications*(tenant-v1-notifications.md). - Register realtime in-app types in the SPA Notification Registry; Echo updates Query caches and polling remains a recovery path.
- Schedule due/overdue fan-out through
crm:send-due-notificationsrather than ad-hoc cron per module. - Realtime conversation rooms (Team Chat): in addition to the existing private user notification channel
tenant.{tenantId}.user.{userId}, subscribe members totenant.{tenantId}.conversation.{conversationId}(authorized byTenantConversationChannel— same tenant + conversation membership). Message / reaction / pin / membership broadcasts use that conversation channel; mention and DM alerts still fan out on the user notification channel.
Settings
If the module needs settings, register keys in SystemSettingDefinitions / TenantSettingDefinitions and resolve Central → Tenant → system. Do not invent a parallel settings store.
Date and time (required for every module)
Applies to all current and future tenant modules — same rule as Leads, Tasks, Meetings, Calendar, Attendance, and CRM reminders.
- Single timezone — Settings → General → Timezone (e.g.
Asia/Karachi). Never add{module}_timezoneor assume serverAPP_TIMEZONE/ UTC for user-facing clocks. - Absolute datetimes (
due_at,starts_at,ends_at,remind_at, …) — cast withApp\Casts\UtcDateTime; serialize withApp\Support\UtcIso; SQL vs those columns viaApp\Support\UtcInstant; SPA edit/display viasrc/lib/datetime.ts(appLocalInputToIso/isoToAppLocalInput) +useSettingsStore. - Wall-clock settings (
H:ioffice hours, digests, cutoffs) — interpret only in the workspace timezone; document that in the module’s user guide. - Schedulers / “today” / late gates — use
now($workspaceTimezone)orCarbon::now($timezone)after resolving timezone fromTenantSettingService; do not rely on barenow()in long-lived workers. - Docs — link Workspace timezone convention from the module developer guide when the module has any date/time fields.
Anti-patterns (date/time)
- Per-module timezone picker that diverges from Settings → General
- Default Eloquent
datetimecast on absolute scheduling columns whenapp.timezonemay be non-UTC - Bare
now()/today()in scheduled commands without an explicit workspace timezone - Binding workspace
now()/today()in SQL againstUtcDateTimecolumns (compare withUtcInstantinstead) - Showing browser-local times or slicing UTC ISO into
datetime-localwhile storing “naive” workspace wall clocks withoutappLocalInputToIso
Catalog versioning (modules.version)
Marketplace detail shows the catalog version string (semver: MAJOR.MINOR.PATCH). It is display / release metadata for the central catalog row — not a per-workspace entitlement version, and not a per-workspace code pin.
SemVer policy
EloSync modules ship backward-compatible updates. Do not introduce breaking changes to module APIs, permissions contracts, or tenant data semantics. Prefer additive schema, additive API fields, and migrate-forward data migrations.
| Change | Bump | Example |
|---|---|---|
| First ship of a new module | Start at 1.0.0 | Pass version => 1.0.0 into ensureModule |
| Bug fix, copy, polish, small non-breaking fix | PATCH | 1.0.0 → 1.0.1 |
| Additive feature, schema, API, or UX (backward compatible) | MINOR | 1.0.1 → 1.1.0 |
| Large milestone that remains backward compatible (major product surface) | MAJOR | 1.1.0 → 2.0.0 |
| Docs-only / no catalog-visible product change | No bump | — |
Ship every product bump as a data migration that calls DefaultModuleRegistrar::bumpVersion('{slug}', '{new}').
Reserve breaking removals / incompatible contract changes — they are out of policy. If an exceptional break is ever approved, document it explicitly in the CHANGELOG and treat it as a MAJOR bump with a migration / upgrade path.
Who receives the update?
| Layer | Behavior |
|---|---|
| Code + schema | One Backend/Frontend deploy. Every workspace runs the same app — there is no per-tenant module code version. |
Catalog version string | One central modules row. Marketplace shows the same version to every workspace. |
| Entitlement (install / enable) | Unchanged by a version bump. Workspaces that already have the module entitled get the new behavior immediately. Workspaces that never installed (or cancelled / suspended) stay off until they install or re-enable. |
A version bump does not auto-install a module for workspaces that have not enabled it.
Rules agents and developers must follow:
ensureModuleusesfirstOrCreateby slug — it does not updateversion(or commercial flags) on existing rows. Never expect a re-run of the registrar orCatalogSeederto bump versions in production.- Always bump with an explicit idempotent migration:
app(DefaultModuleRegistrar::class)->bumpVersion('calendar', '1.1.0'); - Note user-visible bumps in the Docs CHANGELOG (include old → new catalog version).
- Do not invent a parallel versioning system outside
modules.version.
Billing
Paid modules: catalog is_billable, marketplace install, ModuleSubscriptionService, consolidated billing. Never implement independent payment flows.
Frontend checklist
- Pages, forms, tables, filters, dedicated create/view/edit record pages (canonical deep links / complex work)
- Hybrid list sheets: Platform-wide list-side
EntityRecordSheet/ModuleRecordSheet(Leads blueprint) for quick create, peek view, and light edit. New / shortcutnopen the sheet on simple modules; Open full page and/{slug}/:idstay available. Heavy billing/line-item modules (invoices, quotations, estimates, credit notes, payments, purchase orders, journals) use view peek only — create/edit stay on dedicated builder pages. Do not replace dedicated routes or put full document builders only in a sheet. When usingModuleRecordSheet, always passrenderOverviewwith the key fields users need in the peek (status, dates, assignee, amounts, related labels). Omitting it falls back to an empty “Quick peek → open full page” stub — do not ship that for production modules.ModuleRecordSheetmust cache the sameApiSuccessResponseshape as dedicated view pages (do not unwrap.datainto the React Query key) so peek → Open full page does not hang on Loading. OptionalrenderViewActionsadds status workflow buttons (e.g. Approve) beside Edit in the peek header. - Shared design system (
PageHeader,RecordPage,RecordSection,EntityRecordSheet,FormSubmitSplit— separate Create / Create & View buttons, no dropdown —DataTable,PermissionGate, empty/error/loading states) - Dialogs only for secondary flows (confirm, import, tags/categories, related-record inline create)
- Related inline create: When a create/edit form picks an FK from another module, show a gated New button (
hasModule(related)+related.create) that opens a minimal dialog, creates the record, and auto-selects it. Hide New (and the picker) when the related module is not entitled. Shared dialogs live insrc/components/common/create-*-dialog.tsxwithRelatedEntityPicker. Dialog formonSubmitmustpreventDefaultandstopPropagation— React portals bubble through the React tree, so withoutstopPropagationthe parent create/edit form would also submit. Do not inline-create full billing documents (invoice, quotation, PO, payment, etc.). - On create/edit mutation
onError, callapplyServerValidationErrorsfromsrc/lib/form-validation.tsso Laravel 422 field errors toast and map onto react-hook-form. Forassigned_tofields that useEligible*Assignee/EligibleTaskAssignee, filter pickers withfilterTaskAssigneeOptions(omit suspended only; keep current assignee so they can be cleared) and rendererrors.assigned_to. Leads only usefilterLeadAssigneeOptions/EligibleLeadAssignee(also omit workspace owners and users flagged exclude-from-lead). List assignee filters use sharedAssigneeFilterSelect(searchable). Do not auto-fill opportunity assignees who are not in the picker. - Old list deep links (
?entity=) redirect to/{slug}/:id; list filters such as/payments?invoice=stay on the list - Nav + breadcrumbs respect installed modules and user permissions. Place the item in the catalog-aligned sidebar group (
CRM,Communication,Sales,Billing,Purchasing,Inventory,Operations,Finance,HR). Groups collapse; the section for the current route stays open. - Auth payload includes active module slugs for SPA gating
- Module list shortcuts via
useModuleShortcuts: baren(create, permission-gated; Chromium blocksCtrl/⌘N) andmod+f(focus moduleSearchInputwithref+shortcutHint). Do not bind create/search on the app shell — keep them route-scoped like Leads.
Testing checklist
Pest: unit where useful; feature CRUD; authorization; validation; tenant isolation; module middleware denial.
Playwright: dedicated suite; script test:e2e:{slug}; independently runnable.
Manual QA: Cursor browser — CRUD, search, filters, pagination, validation, authz, responsive layout, console, network.
Anti-patterns
- Laravel Modules / nwidart / plugin discovery
- Repositories layer
- Skipping
module:orcan:middleware - Static nav that ignores entitlements
- Custom audit/notification systems outside platform services
- Production
db:seed/CatalogSeederto register default modules - Login-time or dashboard-time permission “repair”
syncPermissions()on existing customized roles during deploy- Per-module timezone or server-UTC user clocks (see Date and time above)