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:
-
Turn on developer mode in
.env(loads unsigned modules frommodules-dev/), then runphp artisan config:clear:LICENTRA_MODULES_DEV=true -
Get the example module: Super Admin → Modules → More → Download example module, then Upload module with the zip (or unzip it into
modules-dev/). -
Create its table and publish its scripts and styles:
php artisan module:migrate matterly-hello -
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 withlang/{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 bumpversionwith 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_idcolumn (foreignId('firm_id')->constrained('firms')) and theApp\Traits\BelongsToFirmtrait on the model. In firm pages (firm.scope) the trait scopes every query to the signed-in firm and fillsfirm_idon create. - In hooks, scheduled tasks and public pages there's no signed-in firm: use
withoutGlobalScope('firm')and filter byfirm_idyourself. - 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
-
In the Licentra admin, add a product: slug
matterly-{name}, Add-on for: Matterly, pricing Envato (with its CodeCanyon item ID) or Free. -
Bump
versioninmodule.json, then:vendor/bin/licentra-release module modules-dev/matterly-{name} --uploadThis 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. -
Publish the release in the Licentra admin. Every licensed Matterly shows it under Available add-ons (or as an update).