Isha Labs Support

Module Development

Updated Oct 18, 2026

Overview

A module is a folder with a module.json, a Laravel service provider and, usually, a few React pages. It can add pages to the firm panel and the Super Admin area, public pages per firm, its own tables, reactions to new matters or clients, and scheduled tasks, all without editing Matterly.

This page is the contract between Matterly and modules. Only what's listed here is stable. Anything else in Matterly is internal and may change in any release. Changing an item on this list is a breaking change: it bumps Matterly's major version, and modules declare which versions they support in module.json ("requires": {"product": "^1.3"}).

Quick start

On a development copy of Matterly:

  1. Turn on developer mode in .env (loads unsigned modules from modules-dev/), then run php artisan config:clear:

    LICENTRA_MODULES_DEV=true
    
  2. Get the example module: Super Admin → Modules → More → Download example module, then Upload module with the zip (or unzip it into modules-dev/).

  3. Create its table and publish its scripts and styles:

    php artisan module:migrate matterly-hello
    
  4. Give it to a plan (Plans & Pricing → Modules included), sign in as a firm admin and open Add-ons → Greetings.

Never on a live site. Developer mode loads code that isn't checked.

Anatomy of a module

The example module, matterly-hello:

matterly-hello/
├── module.json                         identity, versions, what it touches
├── src/
│   ├── ModuleServiceProvider.php       entry point: routes, sidebar links, hooks, tasks
│   ├── Http/GreetingsController.php    renders the React pages with Inertia
│   └── Models/Greeting.php             its own model (BelongsToFirm)
├── routes/web.php                      everything under /matterly-hello/...
├── public/build/pages.js               the React pages (published to /modules/matterly-hello/build/)
├── public/build/pages.css              their styles, prefixed
├── resources/views/public.blade.php    view('matterly-hello::public')
├── lang/en/messages.php                __('matterly-hello::messages.title')
└── database/migrations/                its own tables

The provider:

class ModuleServiceProvider extends \Pacific\Licentra\Modules\Laravel\ModuleServiceProvider
{
    public function bootModule(): void
    {
        $this->loadModuleResources();                         // views + translations
        $this->loadModuleRoutes('routes/web.php', ['web']);   // under /matterly-hello, gated per plan

        ModuleNav::firm('matterly-hello', 'Greetings', '/matterly-hello/greetings', icon: 'sparkles', permission: 'matters');
        ModuleNav::superAdmin('matterly-hello', 'Greetings overview', '/matterly-hello/admin');

        $this->observe(Matter::class, 'created', function (Matter $matter) {
            Greeting::withoutGlobalScope('firm')->create(['firm_id' => $matter->firm_id, 'message' => 'New matter!']);
        });

        $this->schedule(fn ($schedule) => $schedule->call(fn () => /* clean up */ null)->daily());
    }

    public function filamentPlugins(string $panel): array
    {
        return []; // Matterly has no Filament panels: pages are React (Inertia)
    }
}

module.json

{
    "manifest": 1,
    "slug": "matterly-hello",
    "name": "Hello (example module)",
    "description": "Shown on the Modules page.",
    "version": "1.0.0",
    "product": "matterly",
    "requires": { "product": "^1.3", "licentra": "^1.5" },
    "namespace": "Modules\\MatterlyHello\\",
    "provider": "Modules\\MatterlyHello\\ModuleServiceProvider",
    "envato_item_id": null,
    "plan_feature": true,
    "capabilities": {
        "tables": ["matterly_hello_greetings"],
        "outbound": [],
        "routes": ["GET /matterly-hello/firm/{firm}"],
        "schedule": true
    }
}
Key Notes
slug matterly-{name}, lowercase. The same slug is used everywhere: folder, URLs, view namespace, license.
version x.y.z. An older version can never replace a newer one.
requires.product Matterly versions you support (^1.3, >=1.3 <2.0). On other versions the module is switched off, not broken.
namespace, provider PSR-4 root for src/; the provider must be inside it.
plan_feature true: sold per plan (a switch on plans, a checkbox on firms). false: every firm gets it.
capabilities What the module adds, shown to the admin before install. Be accurate; it isn't enforced.

Routes

Put routes in routes/web.php and load them with loadModuleRoutes(). Everything is placed under /{slug}/ and named {slug}.*, so a module can never replace a Matterly page. While the module is off its routes answer 404; for firms whose plan doesn't include it they answer 403.

Page for Middleware
Firm panel ['auth', 'firm.admin', 'firm.scope', 'firm.permission:{permission}']
Super Admin ['auth', 'super.admin']
Public, per firm none; give the route a {firm:slug} parameter so the plan check uses that firm

firm.permission:{permission} checks a staff permission (matters, clients, services, case-tasks, invoices, reports, website, ai, settings, staff). Firm admins always pass.

React pages

A controller renders a module page with Inertia:

return Inertia::render('modules/matterly-hello/Greetings', ['greetings' => $greetings]);

Matterly loads /modules/{slug}/build/pages.js the first time one of the module's pages is opened. That script registers its components:

window.__matterly.registerPages('matterly-hello', { Greetings, Overview });

and uses Matterly's own copies of everything on window.__matterly (module API v1):

window.__matterly. What
apiVersion 1. Check it before registering.
React, ReactDOM React 19
inertia router, Link, Head, usePage, useForm
layouts FirmLayout, SuperAdminLayout ({ title, children })
ui Button, Card, CardHeader, CardTitle, CardContent, Badge, Input
i18n __(key, replacements) for Matterly's own translations
registerPages(slug, pages) Register your page components

The example is plain JavaScript (React.createElement) so it needs no build step. To write JSX or TypeScript, compile your source to a single IIFE file with React and @inertiajs/react as externals read from window.__matterly, and write it to public/build/pages.js.

  • Styles: Matterly's Tailwind classes are compiled when Matterly is built, so a module can't add new ones. Use the shared components and your own CSS file with prefixed class names (.mh-…) and Matterly's CSS variables (--brand-primary, --brand-secondary); support dark mode with .dark .mh-….
  • Text: pass your module's translations as a prop ('t' => trans('matterly-hello::messages')) so pages can be translated with lang/{locale}/messages.php.
  • Dates: send them as ISO 8601 (->toIso8601String()) so the browser shows them in the viewer's time zone.
  • Errors: every module page is wrapped in an error boundary. If it throws, only that page shows an error.
  • Caching: the script URL carries the module version (pages.js?v=1.0.1), so bump version with every release.

Sidebar links

use App\Support\ModuleNav;

ModuleNav::firm($slug, $label, $href, icon: 'sparkles', permission: 'matters');
ModuleNav::superAdmin($slug, $label, $href, icon: 'sparkles');

Links appear in an Add-ons group, only while the module runs and only for firms that may use it. $href must start with /{slug}. Icons: puzzle, sparkles, chart, table, file, calendar, users, mail, star, shield, book, clock.

Hooking into Matterly safely

React to Matterly only through these helpers. If your callback throws, Matterly's own action (saving the matter, rendering the page) still completes; the error is logged and charged to your module.

Helper Example
observe($model, $events, $fn) $this->observe(Matter::class, 'created', fn ($matter) => …)
listen($events, $fn) $this->listen(SomeEvent::class, fn ($event) => …)
composer($views, $fn) $this->composer('app', fn ($view) => $view->with(…))
schedule($fn) $this->schedule(fn ($s) => $s->call(fn () => …)->daily())

A module is switched off when it throws while starting, causes 3 PHP crashes in 10 minutes, causes 3 errors on Matterly's pages (code hooked in some other way), or causes 10 errors in 10 minutes on its own pages, hooks or tasks. 404s, validation errors and redirects to login don't count.

Data and firms

  • Everything a module stores belongs to one firm: a firm_id column (foreignId('firm_id')->constrained('firms')) and the App\Traits\BelongsToFirm trait on the model. In firm pages (firm.scope) the trait scopes every query to the signed-in firm and fills firm_id on create.
  • In hooks, scheduled tasks and public pages there's no signed-in firm: use withoutGlobalScope('firm') and filter by firm_id yourself.
  • Never trust an id from the browser on its own: look it up through the scoped model.
  • Prefix your tables with the slug in snake case (matterly_hello_…). Never add columns to Matterly's tables.
  • Migrations run when the module is installed or updated, never during Matterly's own updates. Uninstalling keeps the tables unless the admin chooses to delete the data.

The module API (v1)

Area Stable API
Provider Extend Pacific\Licentra\Modules\Laravel\ModuleServiceProvider: register(), bootModule(), loadModuleRoutes(), loadModuleResources(), observe(), listen(), composer(), schedule(), modulePath()
Routes Middleware auth, firm.admin, firm.scope, firm.permission:{permission}, super.admin; route parameter {firm:slug} for public pages
Pages Inertia::render('modules/{slug}/{Page}'), window.__matterly (above), App\Support\ModuleNav
Plan gating plan_feature in module.json; app(\Pacific\Licentra\Modules\Laravel\TenantGate::class)->allows($slug, request())
Models App\Models\Firm, User, Client, Matter, PracticeArea, CaseTask, Invoice, Plan: read, relate, and observe created / updated / deleted. App\Traits\BelongsToFirm.
Packages Only what Matterly already requires (Laravel 12, Inertia, React 19). Modules can't ship a vendor/ or node_modules/ folder.

Testing

Run your module's tests through Matterly's test runner, loading the module from a scratch folder so your real modules-dev/ is never touched. Matterly's own tests/Feature/ModulesSmokeTest.php shows the setup with the example module.

Releasing

  1. In the Licentra admin, add a product: slug matterly-{name}, Add-on for: Matterly, pricing Envato (with its CodeCanyon item ID) or Free.

  2. Bump version in module.json, then:

    vendor/bin/licentra-release module modules-dev/matterly-{name} --upload
    

    This signs the module with your release key, uploads it, and also writes matterly-{name}-x.y.z.licentra-module.zip, a signed package admins can install with Upload module.

  3. Publish the release in the Licentra admin. Every licensed Matterly shows it under Available add-ons (or as an update).