Skip to content

Module Development — Developer Guide

Canonical checklist for building a business module. Copy the Leads module structure; do not invent a second pattern.

Registration recipe

  1. Catalog (production) — Ship an idempotent data migration that calls App\Support\Catalog\DefaultModuleRegistrar (slug, pricing defaults, status=published, is_default_included / is_billable, optional version). Also keep CatalogSeeder in sync for local/CI fresh DBs only — never rely on db:seed in production. Optional: Central Modules API for non-default / commercial catalog edits.
  2. Permissions (production) — Add {slug} => [view, create, update, delete, …] in config/tenant-permissions.php and default grants in config/tenant-default-role-permissions.php. Ship a data migration that calls TenantPermissionSynchronizer::grantMissingDefaultRolePermissions([...]) so existing workspaces receive grants additively. Modules never auto-grant permissions; roles do. Login never syncs RBAC.
  3. Routes — Tenant API:
php
Route::middleware(['auth:tenant-api', 'tenant.user', 'verified', 'module:{slug}', 'can:{slug}.view'])->group(function () {
    // …
});
  1. Domain code — Flat under existing namespaces (no Modules/ package):
LayerLocation
Modelsapp/Models/ + BelongsToTenant
Migrationsdatabase/migrations/
Factoriesdatabase/factories/
Seedersdatabase/seeders/Tenant/ for local/demo only; production catalog/permission rows use data migrations
Controllersapp/Http/Controllers/Tenant/Api/V1/
Form requestsapp/Http/Requests/Tenant/Api/V1/{Module}/
Resourcesapp/Http/Resources/Tenant/Api/V1/{Module}/
Policiesapp/Policies/
Servicesapp/Services/Tenant/
Events / Listenersapp/Events/, app/Listeners/
Notificationsapp/Notifications/Tenant/
  1. Frontendsrc/pages/{slug}/, API service, types, PERMISSIONS / QUERY_KEYS, nav item with permission and module, route under TenantProtectedRoute.
  2. Tests — Pest feature suite + Playwright test:e2e:{slug}.
  3. Docs — User / developer / production guides, API, database, CHANGELOG.

Logging (both required)

LayerMechanismPurpose
AuditPlatformAuditServiceactivity('platform')Actor, workspace, IP, UA, action for create/update/delete/assign/status changes
ActivitySpatie LogsActivity on primary modelAttribute-level change history
TimelineDomain *_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:

  1. Register trigger(s) in AutomationTriggerRegistry with module => '{slug}' and wired => true (or document why not in the module overview deferred list).
  2. Fan out only via IntegrationEventDispatcher + IntegrationEventPayloadBuilder (payloads must include entity_type / entity_id and assignee fields when applicable). Do not add a parallel Automation event bridge.
  3. Gate create-style actions that depend on another module with that module’s slug (for example create_tasktasks).
  4. Optionally add a starter template in WorkflowTemplateRegistry with required_modules.
  5. Pest: entitlement gate + happy-path run; Playwright when the builder catalog changes.
  6. 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:

  1. Extend App\Services\Tenant\DashboardWidgetService (same registry pattern as Leads/Tasks).
  2. Gate each widget on EntitlementService::hasModule + the user’s Spatie permission.
  3. Apply assignee scoping with ScopesToAssignee when the module uses {slug}.assign.
  4. Return { id, module, permission, scope, data } objects only — the SPA renders by id.
  5. Do not invent a parallel dashboard API; extend DashboardWidgetService (Leads/Tasks/Calendar pattern).

See tenant-v1-dashboard.md.

In-app notifications

  • Use Laravel notification channels required by the contract for that event (database plus broadcast for realtime in-app delivery; add mail only when product behavior requires it).
  • Implement ShouldQueue and use App\Notifications\Concerns\QueuesOnEmails so jobs land on the dedicated emails queue (php artisan queue:work --queue=emails).
  • Persist via the standard notifications table; 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-notifications rather 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 to tenant.{tenantId}.conversation.{conversationId} (authorized by TenantConversationChannel — 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.

  1. Single timezone — Settings → General → Timezone (e.g. Asia/Karachi). Never add {module}_timezone or assume server APP_TIMEZONE / UTC for user-facing clocks.
  2. Absolute datetimes (due_at, starts_at, ends_at, remind_at, …) — cast with App\Casts\UtcDateTime; serialize with App\Support\UtcIso; SQL vs those columns via App\Support\UtcInstant; SPA edit/display via src/lib/datetime.ts (appLocalInputToIso / isoToAppLocalInput) + useSettingsStore.
  3. Wall-clock settings (H:i office hours, digests, cutoffs) — interpret only in the workspace timezone; document that in the module’s user guide.
  4. Schedulers / “today” / late gates — use now($workspaceTimezone) or Carbon::now($timezone) after resolving timezone from TenantSettingService; do not rely on bare now() in long-lived workers.
  5. 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 datetime cast on absolute scheduling columns when app.timezone may be non-UTC
  • Bare now() / today() in scheduled commands without an explicit workspace timezone
  • Binding workspace now() / today() in SQL against UtcDateTime columns (compare with UtcInstant instead)
  • Showing browser-local times or slicing UTC ISO into datetime-local while storing “naive” workspace wall clocks without appLocalInputToIso

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.

ChangeBumpExample
First ship of a new moduleStart at 1.0.0Pass version => 1.0.0 into ensureModule
Bug fix, copy, polish, small non-breaking fixPATCH1.0.01.0.1
Additive feature, schema, API, or UX (backward compatible)MINOR1.0.11.1.0
Large milestone that remains backward compatible (major product surface)MAJOR1.1.02.0.0
Docs-only / no catalog-visible product changeNo 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?

LayerBehavior
Code + schemaOne Backend/Frontend deploy. Every workspace runs the same app — there is no per-tenant module code version.
Catalog version stringOne 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:

  • ensureModule uses firstOrCreate by slug — it does not update version (or commercial flags) on existing rows. Never expect a re-run of the registrar or CatalogSeeder to 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 / shortcut n open the sheet on simple modules; Open full page and /{slug}/:id stay 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 using ModuleRecordSheet, always pass renderOverview with 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. ModuleRecordSheet must cache the same ApiSuccessResponse shape as dedicated view pages (do not unwrap .data into the React Query key) so peek → Open full page does not hang on Loading. Optional renderViewActions adds 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 in src/components/common/create-*-dialog.tsx with RelatedEntityPicker. Dialog form onSubmit must preventDefault and stopPropagation — React portals bubble through the React tree, so without stopPropagation the parent create/edit form would also submit. Do not inline-create full billing documents (invoice, quotation, PO, payment, etc.).
  • On create/edit mutation onError, call applyServerValidationErrors from src/lib/form-validation.ts so Laravel 422 field errors toast and map onto react-hook-form. For assigned_to fields that use Eligible*Assignee / EligibleTaskAssignee, filter pickers with filterTaskAssigneeOptions (omit suspended only; keep current assignee so they can be cleared) and render errors.assigned_to. Leads only use filterLeadAssigneeOptions / EligibleLeadAssignee (also omit workspace owners and users flagged exclude-from-lead). List assignee filters use shared AssigneeFilterSelect (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: bare n (create, permission-gated; Chromium blocks Ctrl/⌘N) and mod+f (focus module SearchInput with ref + 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: or can: middleware
  • Static nav that ignores entitlements
  • Custom audit/notification systems outside platform services
  • Production db:seed / CatalogSeeder to 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)

Official documentation for the EloSync SaaS Platform.