Module Development
#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, scheduled tasks and payment gateways, 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 from modules-dev/), then run php 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 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.
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.
#Payment gateways
A module can add a client payment gateway (firms pick it on Settings → Payments) and a subscription billing
gateway (the Super Admin picks it on Settings → Payments). Both need Matterly 1.4 or later
("requires": {"product": "^1.4"}). Register them in bootModule() and pass your slug as module, so the gateway
disappears while your module is off and, if plan_feature is true, for firms whose plan doesn't include it.
#Client payments
use App\Services\Payment\GatewayDefinition;
use App\Services\Payment\GatewayField;
use App\Services\Payment\GatewayRegistry;
app(GatewayRegistry::class)->register(new GatewayDefinition(
slug: 'acmepay',
name: 'AcmePay',
fields: [
GatewayField::text('public_key', 'matterly-acmepay::messages.public_key', required: true),
GatewayField::secret('secret_key', 'matterly-acmepay::messages.secret_key', required: true),
GatewayField::select('mode', 'matterly-acmepay::messages.mode', ['live' => 'Live', 'test' => 'Test']),
],
factory: fn (Firm $firm, array $values) => new AcmePayGateway($values['secret_key'], $values['mode']),
note: 'matterly-acmepay::messages.region',
currencies: ['NGN', 'GHS'],
guide: 'matterly-acmepay::messages.guide',
), module: $this->slug());
The gateway class implements App\Services\Payment\PaymentGatewayInterface:
| Method |
Does |
provider() |
Returns your slug |
createCheckout($matter, $successUrl, $cancelUrl) |
Starts a hosted checkout and returns a CheckoutSession (redirect URL plus a reference Matterly saves on the matter as payment_reference) |
verifyReturn($matter, $request) |
Called when the client comes back. Returns a PaymentOutcome: paid, PaymentOutcome::pending() or PaymentOutcome::unpaid() |
verifyWebhook($request) |
Called for POST /api/v1/webhooks/payments/{firm}/{slug}. Check the signature; return null to ignore the request |
- Never trust the return or the webhook on their own. Ask the provider's API whether the payment went through
before returning
paid: true. Matterly settles each matter once, under a lock, so a return and a webhook arriving
together are fine.
- Charge
$matter->payment_currency / $matter->payment_amount (what the client picked on the public site), falling
back to the firm's currency and total_amount.
GatewayDefinition options: currencies (a warning shows when the firm's currency isn't listed), notice
(fn (Firm $firm) => ?string, any other warning), webhook: false and webhookNote (when the provider needs no
webhook set up), returnQuery (appended to the return URL, e.g. Stripe's session_id={CHECKOUT_SESSION_ID}).
- Field values reach your
factory as strings. Secret fields are stored encrypted, never sent to the browser, and
keep their saved value when the form leaves them blank.
- Labels, hints, notes and the guide can be translation keys;
guide may be a key whose value is a list of steps.
#Subscription billing
use App\Services\Billing\Platform\ChargerDefinition;
use App\Services\Billing\Platform\ChargerRegistry;
app(ChargerRegistry::class)->register(new ChargerDefinition(
slug: 'acmepay',
name: 'AcmePay',
fields: [GatewayField::secret('secret_key', 'matterly-acmepay::messages.secret_key', required: true)],
factory: fn (array $values) => new AcmePayPlatformCharger($values['secret_key']),
currencies: ['NGN', 'GHS'],
), module: $this->slug());
The charger implements App\Services\Billing\Platform\PlatformChargerInterface: provider(), isConfigured(),
startCardSetup() (returns the hosted page URL), referenceFrom($request) (picks your reference out of the request
your page sends the firm admin back with), finishCardSetup() (returns a SavedCard) and charge() (off-session,
returns a ChargeResult; the idempotency key makes a repeated call for the same period a no-op).
- Matterly saves the
SavedCard on the subscription with your slug. A card saved through another gateway is never
passed to your charge(); the firm is asked to add its card again.
- Return
ChargeResult::declined($message, retryable: false) for anything retrying won't fix, such as a currency
you can't charge in. Retryable declines are tried again after 1, 3 and 5 days.
- Anything else the provider needs (an email the card was authorised with, say) goes in your own table, keyed by
subscription_id.
#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()) |
| Payments (1.4+) |
GatewayRegistry, GatewayDefinition, GatewayField, PaymentGatewayInterface, CheckoutSession, PaymentOutcome in App\Services\Payment; ChargerRegistry, ChargerDefinition, PlatformChargerInterface, SavedCard, ChargeResult in App\Services\Billing\Platform |
| 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 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.
-
Publish the release in the Licentra admin. Every licensed Matterly shows it under Available add-ons (or as an
update).