Custom Tools
Free-form sidebar pages — non-resource, non-dashboard, non-lens. Use Tools when you need an admin page that isn't backed by a model: import wizards, system status, ad-hoc reports, third-party embeds, generated docs, etc.
Status: shipped in v0.10. Includes a
boot()per-tool lifecycle hook, aToolServiceProviderfor Composer-package distribution, asset/file publishing helpers, and amartis:toolartisan generator.
When to use a Tool
Pick a Tool when your page does not naturally map to a list of records.
| Surface | Backed by | Picks the renderer | Typical example |
|---|---|---|---|
| Resource | An Eloquent model | Built-in ResourceIndex / ResourceDetail / ResourceCreate / ResourceUpdate | "Posts" CRUD |
| Lens | An Eloquent model + a custom query | Built-in lens shell with summary / cards / actions | "Posts published in the last 30 days" |
| Dashboard | Metric cards + filters | Built-in dashboard grid | "Operations KPIs" |
| Tool | Anything else | Your own React component (registered via componentRegistry) | "Import wizard", "System status", "On-call rotation viewer" |
Architecture: the four layers
A Tool spans four layers. You write code at each, glued together by the framework:
┌───────────────────────────────────────────────────────────────────────────┐│ 1. PHP Tool class Subclass Martis\Tools\Tool — declares ││ (App\Martis\Tools\X.php) name, uriKey, icon, component key, menu ││ section, canSee(), and the lifecycle hooks ││ (boot, publishes, ...). │├───────────────────────────────────────────────────────────────────────────┤│ 2. Registration Auto-discovered from `app/Martis/Tools/` ││ (ServiceProvider) (since v1.8.20). Manual registration via ││ `Martis::tools([X::class])` is still ││ supported and merges with discovery (dedup ││ by class-string). Composer-distributed ││ tools may also self-register through their ││ own `ToolServiceProvider`. │├───────────────────────────────────────────────────────────────────────────┤│ 3. HTTP / Backend Auto-mounted REST endpoints ││ (Martis package) (`/martis/api/tools` + `/martis/api/tools/ ││ {uriKey}`) + the SPA catch-all that serves ││ `/martis/tools/{uriKey}` URLs. ││ Per-tool `boot()` hook lets your tool ││ register its own routes / listeners / gates. │├───────────────────────────────────────────────────────────────────────────┤│ 4. React component A `.tsx` you drop under `resources/js/martis-extensions/` under ││ (resources/js/martis-extensions/tools/) the same component key the PHP tool ││ binds via `withComponent('tool:foo')`. ││ The Martis ToolPage shell renders it inside ││ the standard layout. │└───────────────────────────────────────────────────────────────────────────┘The five-minute path
The fastest way from "I want a System Status page" to a working tool is the artisan generator with --use-bundled (binds to a pre-built React component the package ships):
php artisan martis:tool SystemStatus --use-bundled --menu-section="Operations" --icon=pulseThis produces app/Martis/Tools/SystemStatus.php. No registration step required: any concrete Martis\Tools\Tool subclass under app/Martis/Tools/ is auto-discovered at boot (v1.8.20+) and added to Martis::tools(...) with dedup by class-string.
The Tool also surfaces in the sidebar by default — auto-grouped under the localised "Tools" header, or under whatever you pass to withMenuSection('Operations') inside the constructor. Call withSystemSection() instead to dock it in the bundled System section, next to the audit log and the Cache admin link (v1.35.0+, see Place a Tool under "System"). Manual placement via MenuItem::tool(SystemStatus::class) is still available when you build a fully custom main menu via Martis::mainMenu(...).
Done. Navigate to /martis/tools/system-status. The bundled martis:tool:system-status-demo React component renders the page inside the standard layout.
Disabling auto-discovery
Set martis.discovery.tools = false (or MARTIS_DISCOVERY_TOOLS=false) when you prefer the pre-v1.8.20 behaviour and want to register every Tool by hand from your MartisServiceProvider:
Martis::tools([ SystemStatus::class, ImportWizard::class,]);Override the discovery path via martis.tools_path if your app keeps Tools outside the conventional location. The namespace is derived from Composer's PSR-4 map for that directory (since v1.36.0; App\Martis\Tools for the default layout); set martis.tools_namespace only to pin it or when the directory is not autoloaded.
Anatomy of a Tool class
Identity & display
parent::__construct( name: __('Finance Imports'), // Shown in menu + page header. Localisable. uriKey: 'finance-imports', // URL slug. Stable across releases.); $this->withIcon('upload') // Phosphor icon name, surfaced in the menu. ->withComponent('tool:imports') // React componentRegistry key. ->withMenuSection('Operations'); // Optional menu section label.If you omit uriKey:, Martis derives it from Str::kebab($name). The Tool::make($name, ?$uriKey) static factory is a one-line alternative when you want to inline the construction:
Tool::make(__('Finance Imports')) ->withIcon('upload') ->withComponent('tool:imports') ->withMenuSection('Operations');| Method | Purpose |
|---|---|
name() | Human-readable label getter. Wrap the construct-time argument with __() to localise. |
uriKey() | URL slug getter — /martis/tools/{uriKey}. Defaults to kebab-case of name. |
icon() | Currently-set Phosphor icon name, or null. Read by MenuItem::tool() and toArray(). |
component() | Registered React component key, or null (config-only tools). |
menuSection() | Active menu section label, or null. |
belongsToSystemSection() | true when the Tool is docked in the bundled "System" sidebar section. Defaults to false. v1.35.0+. |
systemSectionOrder() | Weight of the Tool inside the "System" section, lowest first. Defaults to 100. v1.38.0+. |
menuCount(Request) | Sidebar count badge for this tool. Returns null (no badge) by default. Override to return a count. v1.29.0+. |
menuCountCache() | Caches the count on its own: null (default) or ['scope' => 'shared'|'user', 'ttl' => <seconds>]. Same rules as the resource hook, see Cache. v2.20.0+. |
showMenuCount() | Whether the count badge renders. Defaults to true; the badge only appears when menuCount() also returns non-null. v1.29.0+. |
meta() | The accumulated metadata array (mirrors what withMeta() set). |
withIcon(string) | Phosphor icon for the menu entry. Chainable. |
withComponent(string) | React component key. The frontend looks this up in componentRegistry. Chainable. |
withMenuSection(?string) | Optional menu section label. Chainable. |
withSystemSection(bool $value = true, ?int $order = null) | Dock the Tool in the bundled "System" section (with the audit log, the System-section resources and the Cache admin link). Wins over withMenuSection(). order: sets systemSectionOrder() (v1.38.0+); null keeps the current weight. Chainable. v1.35.0+. |
withMeta(array) | Merge arbitrary descriptor data; surfaced verbatim to the React component. Chainable. |
breadcrumb() | Breadcrumb override getter. Returns null when the breadcrumb tracks name() (default). v1.10.3+. |
withBreadcrumb(?string) | Override the breadcrumb label without touching name(). Pass null to clear. Chainable. v1.10.3+. |
Place a Tool under "System" — withSystemSection() (v1.35.0+)
Resources can opt into the package's own System sidebar section (the one that holds the audit log, martis:roles / martis:invitations resources and the "System cache" link) via belongsToSystemSection(). Tools have the same opt-in:
class Settings extends Tool{ public function __construct() { parent::__construct(name: __('Settings'), uriKey: 'settings'); $this->withIcon('sliders') ->withComponent('tool:settings') ->withSystemSection(); }}Subclasses may override the getter instead (public function belongsToSystemSection(): bool { return true; }), exactly like a Resource.
What changes for an opted-in Tool:
- It renders inside the single bundled System section, after the System-section resources and before the "System cache" link. No section of its own is created, and
/api/navigationcarries one section labelled "System". - Its place in that section is a weight:
withSystemSection(order: 10)lists it before every entry that keeps the default100(the resources included), a weight above1000after the cache link. Equal weights keep the natural order. See Menus → Order inside the System section (v1.38.0+). menuSection()/withMenuSection()are ignored for it (the opt-in wins).- The command palette (⌘K) tags it "System", the same label the sidebar uses.
- Its
menuCount()badge keeps working, including the/api/navigation/badgespoll (tool:{uriKey}key). - Per-tool authorisation is unchanged: a user who fails
authorizedToSee()does not get the entry; the section still renders for the other items that user can see. - If you also build a custom
Martis::mainMenu(...)and place the same Tool by hand withMenuItem::tool(...), the System section does not repeat it (dedup by tooluriKey, mirroring the rule for System-section resources).
The opt-in is explicit on purpose. A Tool that merely returns the translated "System" label from menuSection() is not merged: it still produces its own section with that label, next to the bundled one. Switch it to withSystemSection().
The generator scaffolds the call: php artisan martis:tool Settings --system-section.
Customising the breadcrumb (v1.10.3+)
By default the breadcrumb on /martis/tools/{uriKey} reads Tool::name() — the page heading and the deepest crumb stay in lock-step. Set a dedicated label when you want them to diverge (compact crumb + verbose heading, branded crumb + neutral heading, etc.):
class Charts extends Tool{ public function __construct() { parent::__construct(name: 'Charts', uriKey: 'charts'); $this->withIcon('chart-line') ->withBreadcrumb('EdgeFlow · Charts') ->withComponent('tool:charts'); }}The breadcrumb is the only thing that changes — name() still feeds the page heading, the sidebar entry, the document.title, and the menu shortcut from MenuItem::tool().
For a translation-friendly variant, override breadcrumb() so the label re-resolves on every request and honours locale switches:
public function breadcrumb(): ?string{ return (string) __('edgeflow.tools.charts.breadcrumb');}Return null to fall back to name().
Authorisation
$this->canSee(fn (Request $request) => $request->user()?->can('admin.system'));canSee() controls both menu visibility and route access. Tools that fail authorizedToSee() are silently dropped from the menu, and GET /martis/api/tools/{uriKey} returns 404 (not 403), intentionally indistinguishable from "tool does not exist" so an unauthorised user cannot probe which tools the app ships. The tool's own routes answer the same 404 to that user when they run ToolRoutes::middleware(), which loadRoutes() applies by default (v2.0). To show a user a permission state instead of hiding the tool, keep it visible and lock it with a soft gate, lockedFor() with lockModal() or lockPreset() (Soft-gates): the menu shows it with a lock, and a direct visit renders the lock modal as a full page. The tool's data is withheld too (v2.4.0): its field schema, its option search and every route it loads itself answer 403 with the lock, as What a lock stops on the server lists.
| Method | Purpose |
|---|---|
canSee(Closure) | Fluent setter. Closure receives a Request and returns bool. |
authorizedToSee(Request) | Resolved boolean used by the controller and the menu builder. Useful in tests / dynamic menus that need to pre-filter. Defaults to true when no canSee() callback is set. |
public static ?string $policy | Declarative Laravel Policy (v1.11.0+): its view($user) decides before canSee() is consulted. Resolved like a Resource's policy (explicit $policy, then {policy_namespace}\{BaseName}Policy with the Tool suffix stripped, then a policy registered with the Gate for the Tool class) and registered with the Gate on the first check, so no Gate::policy() call is needed (v1.36.0). See Soft-gates → Policy binding. |
Menu count badge (v1.29.0+)
A Tool can publish a numeric count badge on its sidebar entry — the same badge a Resource shows next to its label (Users 1,284). Override menuCount() to return the number; return null to hide it. This mirrors the Resource contract, so a full-canvas Tool that owns list data can surface a live "pending" / "unread" count without a Resource behind it.
class FinanceImports extends Tool{ // Count of imports waiting for review — shown as the sidebar badge. public function menuCount(Request $request): ?int { return Import::query()->where('status', 'pending')->count(); } // Optional: hide the badge without dropping the count logic. public function showMenuCount(): bool { return true; // default }}Behaviour:
- The value serialises on the tool's nav item under the
countkey. - It is also included in the lightweight
GET /api/navigation/badgespayload, keyed by the tool'suriKey, so tool counts live-poll on the same interval as resource counts (MARTIS_NAV_BADGES_POLL_MS, default 5 min). - The badge respects
authorizedToSee()— a user who can't see the Tool never receives its count, exactly like a resource count. menuCount()runs per request; keep the query cheap (it also fires on every badge poll). Cache it if it's expensive: overridemenuCountCache()(v2.20.0) to have Martis cache it, shared or per user, for a number of seconds.
Lifecycle: boot()
The most powerful per-tool hook. Runs once per request lifecycle, lazily on the first hit that needs the tools registry, after Martis has loaded its own routes / views / config — so tool-owned routes register on top of an initialised package.
When to put setup in
Tool::boot()vsAppServiceProvider::boot()? Read tool-boot-patterns.md — it has a decision rubric and 4 worked examples (routes, gates, schedules, event listeners) with the trade-offs spelt out.
public function boot(): void{ // Tool-owned routes, mounted alongside the standard Martis SPA, // behind the middleware of the Martis API and this tool's gate // (use Martis\Tools\ToolRoutes). Route::middleware(ToolRoutes::middleware($this)) ->prefix(ToolRoutes::prefix($this)) ->group(function () { Route::post('/upload', [FinanceImportsController::class, 'upload']); Route::get('/status/{job}', [FinanceImportsController::class, 'status']); }); // Listen for an app-wide event. Event::listen(InvoicePaid::class, RecordImportSuccess::class); // Define a per-tool gate the React UI can read via _authorization. Gate::define('finance-imports.start', fn ($user) => $user?->is_admin); // Schedule a recurring task. app(Schedule::class)->command('finance:reconcile')->dailyAt('02:00'); // Bind a Blade view namespace (rare for Tools, useful for tools // shipping email templates / PDFs alongside the React UI). view()->addNamespace('finance-imports', __DIR__.'/../resources/views');}Guarantees:
- Default implementation is a no-op. Override only when needed.
- Runs exactly once per request lifecycle. Reset only when
Martis::tools([...])is called again (e.g. in tests). - Exceptions thrown from
boot()are logged and swallowed — a single broken tool cannot bring down the whole admin panel. Watch yourlaravel.logfor[martis] Tool boot() threwentries. - The hook receives no arguments. Resolve dependencies via
app(...)or facades.
Tool routes and their middleware
A tool's routes run ToolRoutes::middleware($tool) (v2.0; Martis\Tools\ToolRoutes): the stack of every protected Martis API route, then the tool's own gate.
| Middleware | From | Refuses |
|---|---|---|
martis.middleware | config, ['web'] | (the session, CSRF and cookies every Martis route runs) |
martis.auth_middleware | config, ['martis.auth'] | a guest: 401 (JSON) or a redirect to the login page |
martis.impersonation.duration | package | (stops an impersonation that ran past MARTIS_IMPERSONATION_MAX_DURATION minutes) |
martis.2fa | package | a user who signed in but has not passed the 2FA challenge: 423 {"two_factor_required": true} (JSON) or a redirect to the challenge |
martis.locale | package | (applies the user's locale, so __() and validation messages follow it) |
martis.verified | package | when MARTIS_AUTH_EMAIL_VERIFICATION_ENABLED=true, an unverified user: 409 (JSON) or a redirect to the notice |
martis.authorize | package | when the app defines the viewMartis gate, a user it refuses: 403 (see Authorization → Panel access) |
throttle:{max},{decay},martis-api:{guard}: | config, martis.throttle.* | past MARTIS_THROTTLE_MAX requests per MARTIS_THROTTLE_DECAY minutes per user, shared with the Martis API: 429. Left out when MARTIS_THROTTLE_ENABLED=false |
martis.password.changed | package | when MARTIS_AUTH_PASSWORD_CHANGE_ENABLED=true, a user the app flags: 409 {"password_change_required": true} (JSON) or a redirect to the change page (see Authentication → Forced password change) |
martis.tool:{uriKey} | package | a user this tool is hidden from (canSee(), its policy): 404 {"message": "Tool not found."}, as GET /api/tools/{uriKey} answers them; then a user this tool is soft-locked for (lockedFor(), requirePlan(), v2.4.0): 403 with {"locked": true, "lock": {...}} (a plain 403 for a request that does not expect JSON) |
The first nine are built in one place, Martis\Http\RouteMiddleware::api(), which the package's own routes use too, so a tool's route answers a request exactly as GET /api/tools does. They are also a middleware group, martis.api, built when the application boots, for a route of your own: Route::middleware(['martis.api', 'can:viewReports']).
The routes live under ToolRoutes::prefix($tool), {martis.path}/api/tools/{uriKey} (martis/api/tools/{uriKey} by default): the SPA's api client prefixes every path with the panel's base path (window.MartisConfig.basePath, /{martis.path}), so api.post('/api/tools/finance-imports/upload') reaches them whatever MARTIS_PATH is.
This follows Nova's model. A Nova tool's routes are guarded by the tool's Authorize middleware, which checks that the authenticated user can see the tool before any request runs (Nova → Tools → Routing Authorization), and Laravel's own Pennant tool for Nova runs its API routes behind Nova's API middleware, config('nova.api_middleware') (laravel/nova-pennant), the stack where Nova 5 enables email verification (Nova → Authentication → Enabling Email Verification). Two differences: Nova's tool gate answers 403 where Martis answers 404, as Martis does for the tool itself (see Highlights → Tool routes); and the API routes of a tool Nova's generator scaffolds run the tool's Authorize but not Nova's authentication (nova-issues#5495, "the current behavior" per discussion #5496), which Martis does not copy.
Before v2.0 the default was ['web', 'martis.auth'] under martis/api/tools/{uriKey}: a user who had signed in with a password but not passed the 2FA challenge, or not verified an email the app requires, reached every tool route; the locale, the impersonation expiry and the throttle did not apply; a user the tool was hidden from reached its routes; and with a MARTIS_PATH other than martis the SPA could not reach them. See Upgrading.
Loading routes from a sibling file
For tools with more than two or three endpoints the inline Route::middleware(...)->group(function () { ... }) block in boot() gets noisy. loadRoutes() is a thin wrapper that pre-applies ToolRoutes::prefix() and ToolRoutes::middleware() and requires a sibling file:
public function boot(): void{ // Loads `app/Martis/Tools/routes/finance-imports.php` under // `{martis.path}/api/tools/finance-imports`, behind // ToolRoutes::middleware($this). $this->loadRoutes(__DIR__.'/routes/finance-imports.php');}Inside the routes file, write plain Route::* calls — the prefix and middleware are applied by the surrounding group:
// app/Martis/Tools/routes/finance-imports.phpuse Illuminate\Support\Facades\Route;use App\Http\Controllers\FinanceImportsController; Route::post('/upload', [FinanceImportsController::class, 'upload']);Route::get('/status/{job}', [FinanceImportsController::class, 'status']);Signature:
$this->loadRoutes( string $path, // path to the routes file array $middleware = Tool::DEFAULT_ROUTE_MIDDLEWARE, // the default: ToolRoutes::middleware($this) ?string $prefix = null, // defaults to ToolRoutes::prefix($this));Do not pass a middleware list unless the routes need another stack. Leave the argument out (skip it with a named argument, prefix: ..., to set only the prefix) and the routes run ToolRoutes::middleware($this). A list is used exactly as given, as before v2.0: [...ToolRoutes::middleware($this), 'can:imports.run'] adds an ability, Martis\Http\RouteMiddleware::api() keeps the API guard without the tool gate, a list without martis.auth keeps a route public (a webhook).
A list that leaves out a guard which is on logs a warning (Log::warning, the default channel) naming the tool and telling it not to pass middleware: the 2FA challenge, martis.2fa, while MARTIS_2FA_ENABLED is true (the default), and martis.verified while email verification is on, the panel gate, martis.authorize, while the app defines viewMartis, and martis.password.changed while the forced password change is on, read through the router's aliases and groups, so martis.api counts. The v1.x default ['web', 'martis.auth'] always warns: the v1.x docs showed it, and a tool that passes it, or overrides loadRoutes() with the v1.x signature and forwards its default, keeps that weaker stack. The warning is logged once per tool and PHP process: once per worker on Octane or a queue worker, on every request that boots the application on PHP-FPM.
Routes a tool registers in boot()
A route a tool registers itself, Route::middleware([...])->prefix(...)->group(...) in boot(), gets exactly the middleware it names. The v1.x docs showed Route::middleware(['web', 'martis.auth'])->prefix('martis/api/tools/{uriKey}'): such a route skips the 2FA challenge, email verification, the locale, the impersonation expiry, the API throttle and the tool's canSee(), and stays under /martis whatever MARTIS_PATH is. Since v2.0.1, after the tools boot, Martis reads the routes under each tool's path (ToolRoutes::prefix($tool) and the v1.x martis/api/tools/{uriKey}) and logs the same kind of warning, naming the tool and the route, when a route's middleware is ['web', 'martis.auth'] or leaves out a guard that is on. It reads the route's own middleware and its groups', not a controller's.
Move such a route in one of two ways:
// Before (v1.x docs): no 2FA challenge, no tool gate, fixed /martis path.public function boot(): void{ Route::middleware(['web', 'martis.auth']) ->prefix('martis/api/tools/finance-imports') ->group(function () { Route::post('/upload', [FinanceImportsController::class, 'upload']); });} // After, inline: the Martis API stack, then the tool gate, under {martis.path}.use Martis\Tools\ToolRoutes; public function boot(): void{ Route::middleware(ToolRoutes::middleware($this)) ->prefix(ToolRoutes::prefix($this)) ->group(function () { Route::post('/upload', [FinanceImportsController::class, 'upload']); });} // After, from a file: the same stack and prefix, applied by loadRoutes().public function boot(): void{ $this->loadRoutes(__DIR__.'/routes/finance-imports.php');}A route that must answer before the 2FA challenge, or to a guest, on purpose (a webhook, a signed download) belongs outside the tool's path, where Martis does not look: Route::middleware(['web'])->prefix('webhooks/finance-imports'). Keep the SPA's calls on the tool path: the api client reaches ToolRoutes::prefix().
The parameter keeps its v1.x type, array, so a subclass that overrides loadRoutes() with the v1.x signature, array $middleware = ['web', 'martis.auth'], still loads. Tool::DEFAULT_ROUTE_MIDDLEWARE is ['martis.api'], the group above, so an override that passes it to Route::middleware() itself still guards its routes (without the tool gate).
Missing files are skipped silently — convenient when shipping a Composer-package tool whose routes file is optional, or when a consumer publishes a stub they have not edited yet.
Asset & file publishing
Two helpers proxy to the standard ServiceProvider::publishes map so consumers can run php artisan vendor:publish --tag=...:
public function boot(): void{ // Standard publish-with-tag pattern. Composer-package tools // typically ship config + lang + migrations from their own // package directory. $this->publishes([ __DIR__.'/../config/finance-imports.php' => config_path('finance-imports.php'), ], 'finance-imports-config'); $this->publishes([ __DIR__.'/../database/migrations' => database_path('migrations'), ], 'finance-imports-migrations'); // Convenience helper — publishes compiled JS/CSS to // `public/vendor/martis-tools/{uriKey}/`. Tools that bind a // custom React component via `withComponent()` typically pair // this with a build step in their own package. $this->publishesAssets(__DIR__.'/../public', 'finance-imports-assets');}The publishables register through Laravel's standard ServiceProvider::$publishes + $publishGroups static maps. vendor:publish --provider="App\Martis\Tools\FinanceImports" works the same way it does for any Laravel package.
REST surface (built-in)
Two endpoints the package mounts automatically:
| Method | Path | Returns |
|---|---|---|
GET | /martis/api/tools | Array of every authorised tool — [{ type, name, uriKey, icon, component, menuSection, belongsToSystemSection, meta }, ...] |
GET | /martis/api/tools/{uriKey} | Single tool metadata, or 404 |
GET | /api/navigation/badges | Lightweight badge poll — includes each tool's count keyed by uriKey, alongside resource counts. |
The frontend hits these to build navigation entries and validate deep links. The actual page render is the SPA's job — the catch-all route delegates to React.
Menu integration
Martis::mainMenu(function ($request, $menu) { return [ MenuSection::make(__('Operations'), [ // Class-string OR instance both work — class-strings // are instantiated lazily per-request. MenuItem::tool(SystemStatus::class), MenuItem::tool(new FinanceImports()), ])->icon('wrench'), ];});MenuItem::tool($class|$instance) produces a menu entry with:
label—tool->name()(override via->label('...'))url—/tools/{uriKey}(override via->path('...'))icon—tool->icon()(override via->icon('...'))uriKeyandcomponent— passed through so the frontend can short-circuit the API round-trip
React side
Bind a React component to the key your PHP tool declared:
// resources/js/martis-extensions/index.tsimport { componentRegistry } from '@martis/runtime'import { FinanceImportsTool } from './tools/FinanceImportsTool' componentRegistry.register('tool:imports', FinanceImportsTool)The component receives the resolved Tool descriptor as a prop:
interface ToolDescriptor { name: string uriKey: string icon: string | null component: string | null menuSection: string | null belongsToSystemSection?: boolean // docked in the bundled "System" section (v1.35.0+) count?: number | null // sidebar badge from menuCount(), live-polled meta: Record<string, unknown> // values you set via withMeta(...)} export function FinanceImportsTool({ tool }: { tool: ToolDescriptor }) { // Access withMeta() values via tool.meta const docsUrl = tool.meta.docs_url // Render whatever you want inside the standard layout shell. return <div>...</div>}The Martis ToolPage shell (route /tools/:uriKey) fetches /api/tools/{uriKey}, looks up the registered component by key, and mounts it inside the standard layout (topbar, sidebar, breadcrumbs, theme — all wired automatically).
Reusing Martis Fields inside a Tool. A Tool can host real Martis fields (
Slug,Text,BelongsTo, …) with the exact behaviour they have in a Resource form — slug-from-source,dependsOn, validation display — via the shareduseMartisForm+FieldsFormharness, optionally declaring the fields in PHP withTool::fields(). See tool-fields.md.
If your tool's component() returns a key that has no registration in componentRegistry, ToolPage renders a developer-friendly error UI (the tool name as the heading, then a translated message pointing you at the missing componentRegistry.register(...) call). The error is rendered to the DOM, not the console — it is hard to miss during local dev and is harmless in production beyond the visible message. To ship a config-only tool without a React body, simply do not call withComponent(); ToolPage then renders just the standard header and no content panel.
Navigating between tools. Switching from one Tool to another (
/tools/:uriKey→/tools/:otherUriKey) fully unmounts and remountsToolPage, so the outgoing tool's state and in-flight fetch are torn down cleanly instead of flashing stale data from the previous tool. A top progress bar (mounted once in the shell layout) reflects any in-flight navigation or query across the whole app, tool pages included.
Loading a heavy Tool on demand (v2.20.0+)
A Tool's component is imported with the rest of extensions.js by default. For a heavy one (charts, an editor), register it with componentRegistry.registerLazy('tool:reports', () => import('./heavy/Reports'), { preload: 'idle' }): its chunk is fetched when the Tool opens (or when the browser is idle after boot), ToolPage shows the Martis loader meanwhile, and a stale chunk after a deploy reloads the tab once. See Overrides → Lazy registration and Extensions: caching.
Pages at URLs of your own (v2.2.0+)
A Tool's page lives at /tools/{uriKey}. For a product URL (/findings, /findings/{finding}), register the page with routeRegistry and bind it to the Tool with tool: '{uriKey}': the page renders inside the shell, and the Tool's canSee() and soft lock guard it exactly as they guard /tools/{uriKey}. See Custom pages.
The martis:tool generator
php artisan martis:tool SystemStatus [flags]| Flag | Effect |
|---|---|
--with-component | Also drop a TSX stub at resources/js/martis-extensions/tools/{Name}.tsx (no Tool filename suffix). The auto-discovery entry registers it against the derived key automatically; no manual componentRegistry.register call required. |
--component-key=foo | Use foo as the React component key instead of the auto-generated tool:{kebab-name} (kebab case with an acronym kept whole: SEOReport is tool:seo-report). With --with-component, the entry still registers the TSX under tool:{kebab-name}, so the command prints the register() call that binds it to foo in resources/js/martis-extensions/index.ts. |
--use-bundled | Bind to the package-bundled martis:tool:system-status-demo component so the Tool renders out of the box without writing TSX. |
--menu-section="Operations" | Embed withMenuSection('Operations') in the generated stub. |
--system-section | Embed withSystemSection() in the generated stub so the Tool docks in the bundled "System" section. Wins over --menu-section (a warning is printed when both are given). v1.35.0+. |
--icon=wrench | Phosphor icon for the menu entry (default wrench). |
--force | Overwrite the file if it already exists. |
After the command finishes, the CLI prints the next step: with --with-component, the build reminder (npm run build:extensions), preceded by the componentRegistry.register(...) call to add to resources/js/martis-extensions/index.ts when --component-key names a key the file name does not derive; with --use-bundled, a note that no build is needed; otherwise the two ways to bind a component to the key. The Tool class itself is auto-registered (see the five-minute Tool path).
Distribution patterns
There are two ways to ship Tools.
Pattern A — In-app tool (most common)
The tool lives in your application's own codebase. Subclass Tool, register it in your MartisServiceProvider, write the React component in your resources/js/. Everything lives in the host app.
// app/Providers/MartisServiceProvider.phpuse App\Martis\Tools\SystemStatus; Martis::tools([SystemStatus::class]);This is what the martis:tool generator scaffolds.
Pattern B — Composer-package tool
The tool ships as a standalone Composer package with its own service provider, view publishing, asset publishing, and migrations. Consumers composer require your-vendor/your-tool and the tool registers itself — they never touch their own service provider.
Subclass Martis\Tools\ToolServiceProvider:
namespace YourVendor\YourTool; use Martis\Tools\ToolServiceProvider; class YourToolServiceProvider extends ToolServiceProvider{ /** * Tools to register with Martis. * * @return list<class-string<\Martis\Contracts\ToolContract>> */ protected function tools(): array { return [new YourTool()]; } public function boot(): void { parent::boot(); // registers the tools with Martis $this->loadViewsFrom(__DIR__.'/../resources/views', 'your-tool'); $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); }}Then ship the provider in your package's composer.json:
{ "extra": { "laravel": { "providers": ["YourVendor\\YourTool\\YourToolServiceProvider"] } }}Laravel auto-discovers the provider on composer require. The per-tool boot() lifecycle still fires on top of ToolServiceProvider::boot(), so the tool can register routes / listeners / publishables exactly like an in-app tool.
Cookbook
Recipe 1 — Internal status page (read-only)
Goal: render a page with the master cache health, queue depth, and Telescope link.
php artisan martis:tool SystemStatus --with-component --system-section --icon=pulseThe generator drops:
app/Martis/Tools/SystemStatus.phpresources/js/martis-extensions/tools/SystemStatus.tsx
In the PHP class, restrict to admins:
$this->canSee(fn ($request) => $request->user()?->is_admin);In the React component, fetch /martis/api/cache/status and /horizon/api/stats and render them.
Register (or rely on auto-discovery under app/Martis/Tools/):
Martis::tools([SystemStatus::class]);That is all: --system-section scaffolds withSystemSection(), so the Tool renders inside the bundled System section between the System-section resources and the "System cache" link, with no Martis::mainMenu(...) needed. (Before v1.35.0 this recipe used --menu-section="System" plus a hand-built MenuSection::make('System', [...]), which produced a second section with the same header.)
Recipe 2 — Import wizard with custom routes
Goal: a multi-step CSV import wizard with file upload, preview, commit.
php artisan martis:tool DataImport --with-component --menu-section="Imports"In the Tool's boot(), register the upload + preview + commit endpoints:
public function boot(): void{ Route::middleware(ToolRoutes::middleware($this)) ->prefix(ToolRoutes::prefix($this)) ->group(function () { Route::post('/upload', [ImportController::class, 'upload']); Route::post('/preview/{batch}', [ImportController::class, 'preview']); Route::post('/commit/{batch}', [ImportController::class, 'commit']); });}The React component drives the wizard UI and POSTs to those routes.
Recipe 3 — Composer-package tool
Goal: ship a reusable "Backups" tool as your-vendor/martis-backups.
In the package:
// src/Backups.phpclass Backups extends Tool{ public function __construct() { parent::__construct(name: __('Backups'), uriKey: 'backups'); $this->withIcon('archive') ->withComponent('martis-backups:page') ->canSee(fn ($r) => $r->user()?->can('backups.view')); } public function boot(): void { $this->publishes([ __DIR__.'/../config/martis-backups.php' => config_path('martis-backups.php'), ], 'martis-backups-config'); // Behind the Martis API middleware and this tool's gate, so a // user without backups.view gets a 404 on /run too. Route::middleware(ToolRoutes::middleware($this)) ->prefix(ToolRoutes::prefix($this)) ->group(function () { Route::get('/', [BackupsController::class, 'index']); Route::post('/run', [BackupsController::class, 'run']); }); }} // src/BackupsServiceProvider.phpclass BackupsServiceProvider extends ToolServiceProvider{ protected function tools(): array { return [new Backups()]; } public function boot(): void { parent::boot(); $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); }}In composer.json:
{ "extra": { "laravel": { "providers": ["YourVendor\\MartisBackups\\BackupsServiceProvider"] } }}Consumer: composer require your-vendor/martis-backups and they get a working Backups page in their sidebar.
Making a headless resource findable through its Tool
Pattern B in Distribution patterns aside, the common in-app shape is: "a Tool owns the domain, and a Resource is kept around headless (routable() === false) purely as a relation target / data source" (see routable() in the Resources reference).
That headless Resource can still be findable in global search, and every link to one of its records can still deep-link to the Tool, by declaring recordUrl():
class ProjectResource extends Resource{ public static function routable(): bool { return false; } public static function recordUrl(): ?string { return '/tools/project-knowledge?id={id}'; }}With that in place, projects surface in the ⌘K palette and any BelongsTo/breadcrumb/search-result link to a project opens /tools/project-knowledge?id={id} instead of a 404. See recordUrl() in the Resources reference for the full contract (the dual role, the routable()/globallySearchable() interaction table, and the authorization guarantee).
A registered page is a valid target too: once a page is registered at projects/:projectId, recordUrl() can return '/projects/{id}'.
Anti-patterns
- Don't use a Tool to render a Resource. If you need a custom CRUD UI for a model, override the resource page or define a Lens.
- Don't put behaviour-critical state in
meta(). Use it for presentation hints; pull authoritative data from your own endpoint inside the React component. - Don't bypass
canSee(). A Tool that hides itself from the menu but still answers/api/tools/{uriKey}to anyone is a real exfiltration vector. - Don't register routes outside
boot(). Routes registered in the constructor run at object-construction time (before the application has fully booted) and can leak across tests. - Don't depend on
boot()running multiple times. The hook is idempotent per registration. If you need re-runnable setup (e.g. resetting state in tests), trigger it explicitly.
Differences from Nova 5
If you arrive at Martis Tools from Nova 5, the surface looks familiar but a few things diverge by design. None of these are "missing features" we plan to add; they are deliberate choices.
| Topic | Nova 5 | Martis | Why |
|---|---|---|---|
| Custom menu rendering | Tool::menu(Request) returns a View so the tool can render its own menu item HTML. | The menu entry is built from name() / icon() / uriKey() only. | Uniform menu styling. Custom menu HTML is the surface most likely to drift visually away from the rest of the admin. |
| In-page sub-navigation | Tool::renderNavigation() slot for tabs / sections. | Delegated to your React component. Build the sub-nav inline; the standard layout already provides breadcrumbs + page header. | React side has more flexibility to react to client state than a server-rendered fragment. |
| Route file auto-discovery | Tools may ship a routes/tool.php that Nova auto-loads. | Tool::loadRoutes($path) (since v1.8.8) — explicit one-liner from boot(). | Auto-discovery hides where routes come from. Keeping the call site explicit makes route registration trivially grep-able. |
| Tool route gate | The tool's Authorize middleware answers 403 to a user the tool is hidden from. | martis.tool:{uriKey} in ToolRoutes::middleware() answers 404 (v2.0). | The tool's page and metadata already answer 404 to that user, so the app does not reveal which tools it ships. |
| Per-tool resource isolation | Tools can register their own Resources so two tools can ship overlapping URI keys. | Martis::resources() is global; tools should use their own controllers + endpoints rather than registering Resources. | Resource registration is global anyway in Laravel routing; per-tool isolation is rarely needed and adds significant indirection. |
| Bundled webpack scaffolding | Nova ships a webpack config + nova:tool builds a tool package's JS. | publishesAssets() proxies the standard vendor:publish mechanism; the tool author owns the build pipeline (Vite, Webpack, anything). | Decouples tool packaging from the package's own build choices. |
Tests
Behaviour-level coverage lives in tests/Feature/ToolsControllerTest.php (15 Feature tests covering registration, authorisation, the boot lifecycle, exception swallowing, publishing, route loading, and menu integration) and tests/Feature/ToolRouteMiddlewareTest.php (the middleware and prefix of a tool's routes: the same stack as the API routes, the 2FA challenge, email verification, the throttle, the tool gate, an explicit list and its warnings, a v1.x override, martis.path). The ParitySurface tripwire (tests/Feature/ParitySurfaceTest.php) asserts that the public surface (Tool, Tool::boot(), Tool::publishes(), Tool::publishesAssets(), Tool::loadRoutes(), ToolServiceProvider, Martis::tools(), Martis::resolveTools(), MartisManager::bootTools()) keeps its contract.
Run the focused suites:
vendor/bin/pest tests/Feature/ToolsControllerTest.phpvendor/bin/pest tests/Feature/ParitySurfaceTest.php