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, and scheduled tasks, all without editing LawDesk.
This page is the contract between LawDesk and modules. Only what's listed here is stable. Anything else in
LawDesk is internal and may change in any release. Changing an item on this list is a breaking change: it bumps
LawDesk's major version, and modules declare which versions they support in module.json
("requires": {"product": "^1.3"}).
#Quick start
On a development copy of LawDesk:
-
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 lawdesk-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, lawdesk-hello:
lawdesk-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 /lawdesk-hello/...
├── public/build/pages.js the React pages (published to /modules/lawdesk-hello/build/)
├── public/build/pages.css their styles, prefixed
├── resources/views/public.blade.php view('lawdesk-hello::public')
├── lang/en/messages.php __('lawdesk-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 /lawdesk-hello, gated per plan
ModuleNav::firm('lawdesk-hello', 'Greetings', '/lawdesk-hello/greetings', icon: 'sparkles', permission: 'matters');
ModuleNav::superAdmin('lawdesk-hello', 'Greetings overview', '/lawdesk-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 []; // LawDesk has no Filament panels: pages are React (Inertia)
}
}
#module.json
{
"manifest": 1,
"slug": "lawdesk-hello",
"name": "Hello (example module)",
"description": "Shown on the Modules page.",
"version": "1.0.0",
"product": "lawdesk",
"requires": { "product": "^1.3", "licentra": "^1.5" },
"namespace": "Modules\\LawDeskHello\\",
"provider": "Modules\\LawDeskHello\\ModuleServiceProvider",
"envato_item_id": null,
"plan_feature": true,
"capabilities": {
"tables": ["lawdesk_hello_greetings"],
"outbound": [],
"routes": ["GET /lawdesk-hello/firm/{firm}"],
"schedule": true
}
}
| Key |
Notes |
slug |
lawdesk-{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 |
LawDesk 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 LawDesk 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/lawdesk-hello/Greetings', ['greetings' => $greetings]);
LawDesk loads /modules/{slug}/build/pages.js the first time one of the module's pages is opened. That script
registers its components:
window.__lawdesk.registerPages('lawdesk-hello', { Greetings, Overview });
and uses LawDesk's own copies of everything on window.__lawdesk (module API v1):
window.__lawdesk. |
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 LawDesk'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.__lawdesk, and write it to public/build/pages.js.
- Styles: LawDesk's Tailwind classes are compiled when LawDesk 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 LawDesk's CSS variables
(--brand-primary, --brand-secondary); support dark mode with .dark .mh-….
- Text: pass your module's translations as a prop (
't' => trans('lawdesk-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 LawDesk safely
React to LawDesk only through these helpers. If your callback throws, LawDesk'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
LawDesk'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 (
lawdesk_hello_…). Never add columns to LawDesk's tables.
- Migrations run when the module is installed or updated, never during LawDesk'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.__lawdesk (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 LawDesk already requires (Laravel 12, Inertia, React 19). Modules can't ship a vendor/ or node_modules/ folder. |
#Testing
Run your module's tests through LawDesk's test runner, loading the module from a scratch folder so your real
modules-dev/ is never touched. LawDesk's own tests/Feature/ModulesSmokeTest.php shows the setup with the example
module.
#Releasing
-
In the Licentra admin, add a product: slug lawdesk-{name}, Add-on for: LawDesk, pricing Envato (with
its CodeCanyon item ID) or Free.
-
Bump version in module.json, then:
vendor/bin/licentra-release module modules-dev/lawdesk-{name} --upload
This signs the module with your release key, uploads it, and also writes
lawdesk-{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 LawDesk shows it under Available add-ons (or as an
update).