Documentation menu
HomeDocsGetting StartedInstallation

Installation Guide

Add Martis to any existing Laravel application as a Composer package.

Requirements

RequirementMinimum Version
PHP8.3+
Laravel12.x or 13.x
Node.js20+ (contributors only)
npmthe version bundled with Node.js (contributors only; the repository ships package-lock.json and CI runs npm ci)
DatabaseAny driver Laravel supports — MySQL, PostgreSQL, SQLite, or SQL Server (no specific version required)
Redis7+ (optional, for cache/queue)

Martis is database-agnostic: it issues no driver-specific SQL on its query paths (metric period bucketing, for example, runs in PHP), so any database your Laravel app already uses works. The CI matrix runs Pest against PHP 8.3, 8.4 and 8.5 on Laravel 12 and 13. End-user apps consume the precompiled frontend assets — no Node toolchain required.

Quick Install

The fastest way to get started is the martis:install Artisan command. It handles everything in one step:

composer require martis/martis
php artisan martis:install

This is the complete end-user install flow. The consuming Laravel application does not need to run Vite, install Node dependencies, or build frontend assets for Martis.

This command performs the following steps automatically:

  1. Creates the directory structure — app/Martis/ for your resource definitions.
  2. Publishes the config file — config/martis.php with all customizable settings.
  3. Publishes the host MartisServiceProvider — app/Providers/MartisServiceProvider.php and wires it into bootstrap/providers.php in the file's own style: first in the array, with a use import when the file imports its providers (as the current Laravel 12 and 13 skeletons do). A file whose providers array the installer cannot find, or where the entry would land in a comment or a closure rather than in the array the file returns, gets a warning with the line to add by hand. This is where consumer code that cannot live in config/martis.php (closures, gate definitions, menu items, dashboards, runtime cache layers) is registered. See Host MartisServiceProvider below.
  4. Publishes frontend assets — precompiled React app to public/vendor/martis/.
  5. Publishes the core migrations — create_martis_action_events_table, the two action-events morph id conversions (alter_martis_action_events_morph_ids_to_string, fix_martis_action_events_morph_ids_string_v2), create_martis_user_preferences_table, drop_dashboards_layout_from_user_preferences_table, create_notifications_table and create_martis_cache_state_table. A migration already present (matched by its *_<name>.php suffix) is skipped. Each migration it publishes gets its own timestamp, later than the one before and than the newest migration already in database/migrations, so they run in the order listed.
  6. Publishes translation files — en, pt_BR, pt_PT to lang/vendor/martis/.
  7. Runs database migrations — php artisan migrate --force, which creates the martis_action_events, martis_user_preferences, notifications and martis_cache_state tables. Note that it applies every pending migration of the application, not only the Martis ones, without the production confirmation prompt. Pass --no-migrate to publish the migrations without running them: the command then lists php artisan migrate among the next steps.

Upgrading from pre-0.7.0: If you already have an action_events table, the new migration detects it and performs an in-place RENAME to martis_action_events. No data loss. The martis_ prefix keeps every package-owned table in one namespace so it never collides with an app's own tables.

After installation, create an admin user:

php artisan martis:user
# Visit http://your-app.test/martis

On a terminal the command prompts for the email, name and password it does not receive as options (--email, --name, --password or --password-stdin). Without one (CI, a container entrypoint, a pipe, --no-interaction) it asks nothing: --email and a password (--password-stdin, or the deprecated --password) are required (a missing one is named, the command exits 1 and no user is created or changed) and the name defaults to Martis Admin, since v2.0.0. By default it is create-only: when a user with that email already exists it prints an error and exits with a non-zero status, so a script cannot accidentally overwrite an account.

These flags change how the password reaches the command and what happens when the email already exists, which is what a container entrypoint or a provisioning script needs to bootstrap the first administrator on every boot:

FlagWhat it does
--password-stdin(v2.3.0) Reads the password from the first line of standard input, so it never appears in the process list, as docker login --password-stdin does. Cannot be combined with --password; an empty line exits 1. It refuses a terminal (leave the option out: the prompt hides what you type) and a line that is not text (invalid UTF-8 or a control character, which is what a closed descriptor can give), so neither ever becomes a password.
--passwordDeprecated since v2.4.0: still accepted, but it prints a warning on every run. The value is part of the command line, so every other local user reads it through the process list (ps, /proc/<pid>/cmdline) for as long as the command runs, and shell history and CI logs keep it. Use --password-stdin (or the prompt on a terminal). An empty value exits 1 without the warning.
--if-missingWhen the email already exists: exit 0 with an info line and change nothing. Safe to call unconditionally at boot.
--updateWhen the email already exists: re-hash the password from --password, --password-stdin (or the prompt) and, only when --name is given, replace the name. email_verified_at is left untouched. When the email does not exist yet the user is created, so the flag behaves like an upsert.
# Guarantee an administrator exists, never touch it afterwards
printf '%s\n' "$MARTIS_ADMIN_PASSWORD" | php artisan martis:user --no-interaction --email="$MARTIS_ADMIN_EMAIL" --name="Admin" --password-stdin --if-missing
 
# Converge the administrator to the current environment (rotated password)
printf '%s\n' "$MARTIS_ADMIN_PASSWORD" | php artisan martis:user --no-interaction --email="$MARTIS_ADMIN_EMAIL" --password-stdin --update

printf is a shell builtin, so the password reaches the command through a pipe and never appears in a process's argument list, where ps shows --password=... to every local user. The password is validated with your password rules, as nova:user validates it.

Without either flag the behaviour is unchanged: the second run of the same command fails with A user with email [...] already exists.

Install Options

FlagEffect
--forceOverwrite previously published migrations, translations and the extension scaffold (Vite config, both tsconfig files, index.ts, the shims and their declarations); config/martis.php and the host provider stay unless you add --force-config / --force-provider
--force-configRepublish config/martis.php, overwriting your changes to it
--force-providerRepublish app/Providers/MartisServiceProvider.php, overwriting your changes to it
--with-profileEnable profile support and publish the avatar column migration (*_add_martis_profile_picture_column_to_users_table.php); when the sessions profile section is active it also publishes the sessions table migration
--no-profileDisable profile support, even when running interactively; wins over --with-profile
--with-2faEnable two-factor support and publish the 2FA columns migration (*_add_martis_two_factor_columns_to_users_table.php); independent of --with-profile
--no-2faDisable two-factor support; wins over --with-2fa
--with-sessionsPublish the sessions table migration for the browser-sessions profile section (requires SESSION_DRIVER=database)
--no-sessionsSkip the sessions table migration; wins over --with-sessions
--avatar-column=<column>Customize which users table column Martis should use for avatar paths
--existing-avatar-columnUse an existing avatar column on users instead of publishing a migration
--no-migratePublish the migrations but do not run them; run php artisan migrate afterwards

Optional Profile Support

By default, martis:install always installs the core Martis package, the martis_action_events audit log, and the martis_user_preferences table (theme/accent/density/locale/reduced-motion persistence). Profile support is optional because some applications already have their own avatar column strategy.

To install Martis with profile and two-factor support:

php artisan martis:install --with-profile --with-2fa

The two flags are independent switches: --with-profile publishes the avatar column migration (*_add_martis_profile_picture_column_to_users_table.php), and --with-2fa publishes the two-factor columns migration (*_add_martis_two_factor_columns_to_users_table.php). Each stub is idempotent:

  • the avatar column is added only if it does not already exist
  • the 2FA columns are added only if they do not already exist
  • re-running martis:install with the same flags does not create duplicate migration files

If you want only the 2FA columns and not the avatar migration, pass --with-2fa without --with-profile.

If your application already stores avatar paths in a different users column, pass that column name when installing:

php artisan martis:install --with-profile --avatar-column=avatar_path

Then set the same column in your environment:

MARTIS_AVATAR_COLUMN=avatar_path

If the column already exists on users and you do not want a migration:

php artisan martis:install --with-profile --existing-avatar-column --avatar-column=avatar_path

The installer only prompts when it runs interactively and STDIN is a real TTY, the avatar column question included. In CI, Docker setup scripts (docker compose exec -T), deployment hooks, a piped stdin (yes | php artisan martis:install) or an AI agent's shell there is no prompt: every optional feature you do not pass a flag for resolves to disabled, the avatar column is profile_picture unless you pass --avatar-column, and --existing-avatar-column needs --avatar-column. Before v2.0 the avatar column question read a piped answer (yes | created a column named y) or waited forever. The resolved values are also written to .env (MARTIS_PROFILE_ENABLED, MARTIS_AVATAR_ENABLED, MARTIS_AVATAR_COLUMN, MARTIS_2FA_ENABLED, MARTIS_SHOW_PROFILE_MENU) on every run, and a disabled value in the config wins over --with-* on the next run. Pass the flags explicitly:

php artisan martis:install --force --no-interaction --with-profile --with-2fa

If a previous run already wrote false, set those keys back to true in .env (or remove them), run php artisan config:clear, then re-run the command with the flags.

Manual Install (Step by Step)

If you prefer granular control, you can run each publish step individually.

Step 1: Install via Composer

composer require martis/martis

Martis registers its service provider automatically via Laravel's package discovery.

Step 2: Publish Configuration

php artisan vendor:publish --tag=martis-config

This creates config/martis.php. The most commonly customized keys:

  • path — Admin panel URL prefix (default: martis)
  • guard — Authentication guard (default: null, falls back to config('auth.defaults.guard'))
  • resources_path — Directory scanned for resource classes (default: app_path('Martis')); resources_namespace pins its namespace when it cannot be derived from Composer's PSR-4 map (default: null, derived)
  • theme.default — Default theme: dark, light, or system
  • theme.allowToggle — Allow users to switch themes from the preferences panel
  • preferences.defaults.locale — Default panel language for users without a saved preference (MARTIS_DEFAULT_LOCALE, default en; it must be listed in preferences.locales). The martis.locale middleware applies it (or the user's saved preference) on every authenticated Martis route, so this, not APP_LOCALE, decides the panel language.
  • locale — Read by the blade shell only when preferences.enabled is false (MARTIS_LOCALE, falling back to APP_LOCALE, then en)
  • brand.name / brand.logo / brand.icon / brand.favicon — Brand block (see Configuration)
  • layout.preset — Layout preset (sidebar, topnav, minimal)
  • footer / search / auth / preferences / cache — Subsystem configuration

Locales: Martis ships translations for en, pt_BR, pt_PT. There is no available_locales config key — Laravel's own locale resolution applies, and any extra locales you add to lang/vendor/martis/<locale>/ are picked up automatically. The picker shown on the user preferences panel is built from meta.locales returned by the preferences resolver, which inspects what locales actually have files.

Step 3: Publish the Host MartisServiceProvider

php artisan vendor:publish --tag=martis-provider

Creates app/Providers/MartisServiceProvider.php. The martis:install command runs this and wires it into bootstrap/providers.php automatically. See Host MartisServiceProvider for what to put in it.

Step 4: Publish Frontend Assets

php artisan martis:publish-assets

This copies the precompiled React application to public/vendor/martis/. End users do not need to run Vite in the consuming Laravel app.

The command wipes public/vendor/martis/ first so stale Vite-hashed chunks from previous package versions never accumulate, then does a deterministic full-tree copy. Laravel's stock vendor:publish --tag=martis-assets --force is a merge-style copy and would otherwise pile up tens of thousands of orphan files across upgrades — enough on macOS Docker bind mounts to slow every PHP-FPM request to several seconds. Pass --no-wipe to opt back into the legacy merge behaviour if you have a specific reason to.

Since v1.29.1 the command also verifies completeness: after copying it checks that every file the published manifest.json references (the app entry bundle, its CSS, every chunk) exists in the destination, and exits non-zero with the missing count if any are absent — so a partial publish is caught here rather than surfacing as a black-screen admin at runtime.

Last, it publishes your custom themes: every resources/css/martis/<name>.css is copied to public/vendor/martis/themes/, where the panel loads it. Commit the sources; the published copies are written again on every run, so edit the source, never the copy. Before it replaces or removes a file there that it cannot write back, such as a copy edited in place, it backs it up to storage/app/martis/theme-backups/ and says so, and it stops, changing nothing, when the run would take away the theme martis.theme.name names or remove the copy of a theme source it cannot read. A public/vendor/martis/ or public/vendor/martis/themes/ that is a symlink is replaced by a real directory, never written through. php artisan martis:publish-assets --themes-only publishes the themes alone, and the stock vendor:publish --tag=martis-assets never publishes them. See Theming → Theme files.

Equivalent: php artisan martis:vendor-publish --assets performs the same wipe-then-publish flow, as does martis:install — all three share this single hardened path.

Important:

  • composer require, composer install, and composer update do not automatically refresh files already published into public/vendor/martis/
  • after installing or upgrading Martis, run martis:install or martis:publish-assets
  • this is standard behavior for Laravel packages that publish static files into the host application

Step 5: Publish and Run Migrations

The package ships its migration stubs under several publish tags so consumers can opt in to each subsystem.

TagPublishes
martis-migrationscreate_martis_action_events_table (audit log — required), create_martis_user_preferences_table, create_martis_cache_state_table and drop_dashboards_layout_from_user_preferences_table
martis-preferences-migrationcreate_martis_user_preferences_table (per-user theme / locale / density / accent / reduced-motion)
martis-preferences-drop-dashboards-layout-migrationdrop_dashboards_layout_from_user_preferences_table
martis-cache-state-migrationcreate_martis_cache_state_table (persisted cache versions and runtime kill-switches)
martis-2fa-migrationadd_two_factor_columns to users (TOTP secret + recovery codes)
martis-password-change-migrationadd_must_change_password_column to the Martis guard's users table (the forced password change flag, v2.3.0)
martis-avatar-migrationadd_profile_picture_column to users (filename of the uploaded avatar)
martis-sessions-migrationcreate_sessions_table (browser-sessions profile section, SESSION_DRIVER=database)
martis-invitations-migrationcreate_invitations_table

No tag publishes create_notifications_table or the two action-events morph id conversions (alter_martis_action_events_morph_ids_to_string, fix_martis_action_events_morph_ids_string_v2, needed for UUID / ULID keyed models): only martis:install publishes them. Tag-published files are named <date>_00000N_<name>.php; martis:install recognises an existing migration by its *_<name>.php suffix and skips it, but a tag publish does not look for a differently dated copy, so publishing a tag after martis:install can add a second copy of the same migration. Prefer martis:install (without --force) to add missing migrations.

Recommended sequence for a manual install:

php artisan vendor:publish --tag=martis-migrations
php artisan migrate

The action-events stub is idempotent — if you already have a legacy action_events table, it is renamed in place to martis_action_events.

If you also want avatar + 2FA support:

php artisan vendor:publish --tag=martis-2fa-migration
php artisan vendor:publish --tag=martis-avatar-migration
php artisan migrate

martis:install publishes the core migrations on every run, the avatar migration with --with-profile (as add_profile_picture_column) and the two-factor migration with --with-2fa (as *_add_martis_two_factor_columns_to_users_table.php). Both add their columns to the table of the Martis guard's users: users, unless MARTIS_GUARD names a guard whose model has its own table.

UUID / ULID / custom user PKs (v1.12.2+)

The published migrations adapt the user_id column (and the polymorphic notifiable_id on the notifications table) to the primary-key shape of the Martis guard's users. The adaptation happens at migration time: each stub introspects the model of the Martis guard's provider (auth.guards.{MARTIS_GUARD, else the default guard}.provider → auth.providers.{provider}.model, the users model on a default install) and picks the matching column helper. The foreign keys (martis_user_preferences.user_id, invitations.invited_by / accepted_user_id) reference that model's table and key, and the two-factor and avatar migrations add their columns to that table (v2.0.0+; before, always users):

User modeluser_id column
Default Laravel (auto-incrementing bigint)foreignId('user_id')->constrained($table, $key)
use Illuminate\Database\Eloquent\Concerns\HasUuids;foreignUuid('user_id')->constrained($table, $key)
use Illuminate\Database\Eloquent\Concerns\HasUlids;foreignUlid('user_id')->constrained($table, $key)
$keyType = 'string' without HasUuids / HasUlidsstring('user_id') + explicit foreign()

The shared sessions and notifications tables

sessions and notifications belong to the whole app, not to the panel: every session guard writes sessions.user_id (Laravel's database session handler stores the id of the request's guard), and the site's database notifications land in notifications. Their migrations (v2.0.1+) read the models of the Martis guard, the app's default guard and every session guard, and pick:

Those models' keyssessions.user_idnotifications.notifiable_*
All auto-incrementing bigintforeignId() (no constraint)morphs()
All HasUuids / all HasUlidsuuid() / ulid()uuidMorphs() / ulidMorphs()
$keyType = 'string', or keys that differ (an admins guard keyed by UUID beside the site's bigint users)string()string notifiable_type / notifiable_id and their index

A string column holds an integer, a UUID and a ULID id alike; a typed one rejects the other guard's ids (PostgreSQL and MySQL answer invalid input syntax for type bigint / Incorrect integer value on every request that writes the session). Both migrations are skipped when the table exists, which is the common case: Laravel 11+'s create_users_table migration creates sessions with a bigint user_id, and php artisan make:notifications-table creates morphs().

Symptom and fix when the table already exists. With a Martis guard whose users are keyed by UUID or ULID (or a string) beside the site's bigint users, or the other way round, every panel request that writes the session (PostgreSQL: SQLSTATE[22P02]: Invalid text representation: invalid input syntax for type bigint; MySQL: SQLSTATE[HY000]: General error: 1366 Incorrect integer value for user_id), or every notification to the other guard's users, fails. Widen the column once, in a migration of your own (doctrine/dbal is not needed on Laravel 11+):

Schema::table('sessions', function (Blueprint $table) {
$table->string('user_id')->nullable()->change();
});
Schema::table('notifications', function (Blueprint $table) {
$table->string('notifiable_id')->change();
});

Keep the existing indexes (change() keeps them). An app that queries these columns itself with an integer (Jetstream's browser sessions, for instance) keeps working on MySQL and SQLite; on PostgreSQL compare with a string ((string) $user->getAuthIdentifier()) if a query joins sessions.user_id to an integer column.

If your project uses a non-standard combination — for example, a custom string PK that does not register either canonical trait — set the MARTIS_USER_ID_COLUMN_TYPE env var to force the resolver. Accepted values: bigint, uuid, ulid, string.

MARTIS_USER_ID_COLUMN_TYPE=uuid php artisan martis:install

The env override always beats auto-detection. Mal-formed values (anything outside the four accepted options) are ignored and the auto-detection runs as if the variable were unset. Default null keeps the auto-detection on, which is the right behaviour for the overwhelming majority of host apps.

Step 6: Publish Translations (Optional)

php artisan vendor:publish --tag=martis-lang

Copies language files to lang/vendor/martis/ so you can override Martis-shipped strings (auth copy, dashboard greeting, validation messages, sidebar labels, …). Available locales out of the box: en, pt_BR, pt_PT. Laravel deep-merges the published files with the package shipped originals — only override the keys you need.

Step 7: Publish Views (Optional)

php artisan vendor:publish --tag=martis-views

Copies the Martis blade templates to resources/views/vendor/martis/. Rare — only required if you need to fork the SPA shell template itself. The component override system (see Override System) is the preferred extension path.

Step 8: Create Your First Resource

Create a resource file at app/Martis/UserResource.php:

<?php
 
namespace App\Martis;
 
use App\Models\User;
use Illuminate\Http\Request;
use Martis\Fields\Boolean;
use Martis\Fields\DateTime;
use Martis\Fields\Email;
use Martis\Fields\Id;
use Martis\Fields\Password;
use Martis\Fields\Text;
use Martis\Resource;
 
class UserResource extends Resource
{
public static function model(): string
{
return User::class;
}
 
public static function titleAttribute(): string
{
return 'name';
}
 
public function icon(): string
{
return 'users';
}
 
public function fields(Request $request): array
{
return [
Id::make('id'),
Text::make('name')->sortable()->searchable()->required(),
Email::make('email')->sortable()->searchable()->required()
->unique(['users', 'email'], 'Email already exists.'),
Password::make('password')->hideFromIndex()->hideFromDetail(),
Boolean::make('is_admin', 'Administrator'),
DateTime::make('created_at', 'Registered')->hideFromForms()->sortable(),
];
}
}

Resources are auto-discovered — no manual registration needed. Martis scans config('martis.resources_path') (default app/Martis/) recursively, mapping files to classes under the namespace Composer's PSR-4 map gives that directory (or config('martis.resources_namespace') when set). Subdirectories (Resources/, Lenses/, Filters/, …) are a convention for large projects — they are NOT created or required by the install. Place each class wherever its namespace puts it.

Step 9: Access the Admin Panel

Navigate to http://your-app.test/martis and log in with a user of your application. martis:install publishes app/Providers/MartisServiceProvider.php with the viewMartis gate active: it lets the local and testing environments in and, anywhere else, only the addresses listed in its $panelEmails property. Edit that list before you deploy. Without a viewMartis gate the panel answers 403 to everyone outside the local and testing environments (martis.panel_access.open_environments); the installer prints a notice about it. Set APP_URL to the URL the panel is served on: the links Martis emails (sign-in, password reset, invitation, email change) are built on it, never on the request's host. See Authorization → Panel access.

Host MartisServiceProvider

martis:install publishes app/Providers/MartisServiceProvider.php and wires it into bootstrap/providers.php (with a use import when the file imports its providers; when it finds no providers array, it warns with the line to add by hand). This file is where consumer code that cannot live in config/martis.php is registered:

  • The main menu (Martis::menu(...)) — closures cannot survive config:cache
  • Dashboards (Martis::dashboards([...])) — same reason
  • Tools (Martis::tools([...]))
  • Cache layer registrations
  • Authorization gates (Gate::define(...))
  • Custom event listeners

Re-publish the stub manually with:

php artisan vendor:publish --tag=martis-provider

Without this provider, you can still ship a working Martis install relying purely on config/martis.php and auto-discovered resources, but every closure-driven feature will be unavailable.

martis:install --force does not touch this file (v1.10.2+). The default --force flag rewrites the extension scaffold (Vite config, both tsconfig files, index.ts, the shims and their declarations), republishes lang/vendor/martis and rewrites the published Martis migrations, but never overwrites the host provider, where dashboards, menus, gates, and cache-layer registrations live. To republish the stub on top of your customisations, opt in explicitly with --force-provider:

php artisan martis:install --force-provider

The same split applies to config/martis.php (--force-config). Each flag is independent, so refreshing the extension scaffold never destroys host-app customisations by accident.

Custom React extensions (zero-config, v1.9.0+)

Custom React components — Tools, override components, field renderers, custom Cards — register at runtime through the runtime extension loader (v1.8.19+) wired up to a filesystem auto-discovery convention (v1.9.0+). The dev runs a generator and a build; everything else is plumbing the package handles.

What martis:install publishes

Once you run php artisan martis:install, the consumer app gets the entire extension scaffold for free:

your-app/
├── vite.extensions.config.ts # Vite library mode, react externalised
├── tsconfig.extensions.json # TS config for the extension sources: npx tsc -p tsconfig.extensions.json
├── package.json # gains: "build:extensions": "vite build --config vite.extensions.config.ts"
├── .env # gains: MARTIS_EXTENSIONS=/vendor/martis-user/extensions.js
└── resources/js/martis-extensions/
├── index.ts # auto-discovery entry — picks up everything below
├── .shims/ # the host modules the build imports (*.mjs) and their types (*.d.mts)
├── tsconfig.json # points editors at tsconfig.extensions.json
├── tools/ # martis:tool --with-component drops files here
├── fields/ # martis:field drops files here
├── cards/ # martis:card drops files here
└── overrides/ # martis:component drops files here

The published index.ts uses import.meta.glob to register every .tsx under the four buckets against window.Martis.componentRegistry. The component key is derived from the filename, in kebab case with an acronym kept whole:

File pathRegistered key
tools/Charts.tsxtool:charts
tools/SystemHealth.tsxtool:system-health
tools/SEOReport.tsxtool:seo-report
cards/RevenueGauge.tsxcard:revenue-gauge
fields/PriceTag.tsx (Display/Input named exports)the price-tag field type: field:display:price-tag and field:input:price-tag
overrides/Sidebar.tsxlayout:sidebar
overrides/LoginPage.tsxauth:login

The PHP classes the generators write bind to the same keys: a Tool with withComponent('tool:charts'), a card with componentKey('card:revenue-gauge'), a field whose type() returns price-tag. Filename and key stay in lock-step. No manual componentRegistry.register(...) calls: drop the file in the right bucket, run npm run build:extensions, and the component is live. A Tool bound to another key (martis:tool --component-key) is the exception: the command prints the register() call to add to index.ts.

Vite + @vitejs/plugin-react compatibility (v1.12.1+)

martis:install adds the npm devDependencies the extension build needs (@vitejs/plugin-react, vite, typescript, @types/*, @phosphor-icons/react). The vite + @vitejs/plugin-react pair is peer-dep-coupled: a new major of @vitejs/plugin-react typically requires a new major of vite, and a mismatch produces either npm install ERESOLVE errors or runtime build crashes.

To keep installs safe, the installer reads your host's existing devDependencies.vite and picks the matching @vitejs/plugin-react range from a known-compat table:

Host Vite major@vitejs/plugin-react range written
^4^4
^5^4 || ^5
^6^5
^7 (Laravel 12 default)^5
^8^6
^9^7

If your host runs a Vite major that is NOT in the table (e.g. you upgraded ahead of the installed Martis release), the installer skips writing @vitejs/plugin-react and prints a loud warning with a paste-ready unblock recipe. Existing @vitejs/plugin-react entries are never overwritten.

Overriding the table

If you know which @vitejs/plugin-react release is compatible with your Vite major but Martis does not, set the MARTIS_PLUGIN_REACT_RANGE env var before running the installer. The resolver writes the value verbatim and skips the table lookup entirely:

# Vite 10 + plugin-react 7 (hypothetical example)
MARTIS_PLUGIN_REACT_RANGE='^7' php artisan martis:install

The override beats every other code path including the unknown-Vite warning. Mal-formed values are rejected (with a clear message) and the installer falls back to "skip + warn" — it never writes a constraint it cannot parse.

The five-minute Tool path

php artisan martis:tool Charts --with-component
npm run build:extensions

That's it. The Tool is auto-registered (since v1.8.20), the React component is auto-registered, and the sidebar surfaces it under the default "Tools" header (or whatever you pass to withMenuSection('Operations') in the constructor; withSystemSection() docks it in the bundled "System" section instead, v1.35.0+, see Tools).

Collision detection

Each generator (martis:tool, martis:field, martis:card, martis:component) checks for both the destination PHP file AND the destination TSX file before writing. When either exists, the command lists the conflicting paths and asks [y/N] whether to overwrite. --force skips the prompt. Without a terminal (CI, a pipe, --no-interaction) nothing is asked: the command prints that the file already exists, writes nothing and exits 0 unless --force was passed, as Laravel's own make:* generators do. martis:theme behaves the same for an existing theme.

How the registry is exposed

At SPA boot, before it loads the extension bundles, the SPA fills window.Martis:

window.Martis = {
componentRegistry, // the registry the SPA resolves from (also `componentRegistry` on @martis/runtime)
react, // the React module instance bundled with Martis
reactJsxRuntime, // react/jsx-runtime, read by the JSX shim
reactDom, // react-dom's public API (createPortal, flushSync, unstable_batchedUpdates, version), read by the react-dom shim (v2.10.0)
reactDomClient, // react-dom/client (createRoot, hydrateRoot), read by the react-dom/client shim (v2.10.0)
runtime, // the @martis/runtime surface the shims re-export
version, // "1.9.0" etc.
shortcuts, // the keyboard-shortcut helpers as add, remove, list (addShortcut, disableShortcut, listShortcuts on @martis/runtime)
}

The shims and the scaffold's index.ts read this global, so a consumer extension registers components and shortcuts without bundling its own copy of componentRegistry, the shortcut registry or React. In your own code, import them from @martis/runtime, which is typed: window.Martis is typed in an extension only as far as the declare global in index.ts goes (componentRegistry.register).

Configuring multiple bundle URLs

From v2.20.0 the shell appends ?v=<content hash> to a built bundle's URL (martis.extensions_versioned_urls), and a heavy tool or card can be split into a chunk with componentRegistry.registerLazy (see Overrides); serve the folder with the headers in Extensions: caching.

The auto-published MARTIS_EXTENSIONS line points at a single bundle (/vendor/martis-user/extensions.js). That bundle is optional: the shell leaves it out while public/vendor/martis-user/extensions.js does not exist, so an app that never runs npm run build:extensions logs no 404 (v2.1.0). Any other URL is loaded as configured. To load additional bundles — e.g. a Composer-distributed package's prebuilt extensions, or a separate dev/staging override — comma-separate them:

MARTIS_EXTENSIONS=/vendor/martis-user/extensions.js,/vendor/another/lib.js

The blade view emits the resolved array as window.MartisConfig.extensions. The SPA loops over it and dynamic-imports each via import(url). Failures are isolated — one broken extension can't take down the whole panel; the error is logged with the URL.

Type-checking your extensions

tsconfig.extensions.json covers the sources under resources/js/martis-extensions/. Check them with:

npx tsc -p tsconfig.extensions.json

Your app installs none of @martis/runtime, react-router-dom, react-i18next or @tanstack/react-query: the Vite config sends each to a shim under .shims/ that re-exports the host's copy (since v2.0.1 it sends react-router to the react-router-dom shim as well, see Extensions and React Router 7). It sends react-dom to a shim too (v1.38.0), which serves the host's public React DOM 18 API (createPortal, flushSync since v1.38.2, unstable_batchedUpdates and version since v2.10.0), and react-dom/client to another (v2.10.0: createRoot and hydrateRoot), so a portal, a batched update or a root runs on the host's React DOM. The legacy root APIs React 19 removed (render, hydrate, findDOMNode, unmountComponentAtNode) are not exposed. Each of those shims has its TypeScript declarations next to it (runtime.d.mts, react-dom.d.mts, react-dom-client.d.mts, react-router-dom.d.mts, react-i18next.d.mts, tanstack-react-query.d.mts, since v1.38.0), and the tsconfig paths sends the same specifiers to them, the legacy paths included, so tsc checks your code against the modules the build uses: a name a shim does not export fails tsc as it fails the build. The declarations carry the Martis types and those of the host's copy of each library, and export each library's own types (its interfaces and type aliases, such as import type { UseQueryResult } from '@tanstack/react-query'), so a type your code took from a copy of the library in node_modules still resolves once the paths send the specifier to the declarations. A class or enum the shim does not export (QueryCache, NavigationType) is not declared, since the build has no value for it; the default export, the host's module, holds it (import type ReactRouterDom from 'react-router-dom', then ReactRouterDom.NavigationType). react and @phosphor-icons/react come from your own node_modules, where martis:install adds them.

react itself is typed by your own @types/react, but an extension runs on the host's React, which is React 18: the Vite config sends react to a shim of it. martis:install adds @types/react and @types/react-dom at ^18 (v1.38.0; before, ^18 || ^19 installed the React 19 types). With the React 19 types an app may keep for its own code, use, useActionState and useOptimistic type-check and build, then are undefined in the extension.

Editors type a file with the nearest tsconfig.json, so the scaffold also puts one in resources/js/martis-extensions/ that extends tsconfig.extensions.json (v1.38.0): VS Code and other tsserver clients resolve @martis/runtime the same way tsc does, and npx tsc -p resources/js/martis-extensions is equivalent to the command above.

The tsconfig is browser-only ("types": ["vite/client"], no @types/node) and does not cover vite.extensions.config.ts. If your app has its own tsconfig.json that includes resources/js (the Laravel React starter kit does), exclude resources/js/martis-extensions from it: that config does not know the aliases, so it reports Cannot find module '@martis/runtime'.

Extensions and React Router 7 (v2.0.1)

The panel runs on React Router 7 since v2.0.1 (on 6 before). In React Router 7 the library is the react-router package, and react-router-dom is a re-export of it plus the DOM RouterProvider. window.Martis.runtime.reactRouterDom is that module, so the react-router-dom shim keeps serving the same names (Link, NavLink, useNavigate, useParams, ...) and an extension built against an older Martis runs unchanged. The host's router behaves as React Router 7 does: a navigation runs in React.startTransition, and navigate() may return a promise.

An extension can import from either specifier. A scaffold published from v2.0.1 sends both to the same shim; in an older one, add the alias to vite.extensions.config.ts, next to the react-router-dom line:

{find: /^react-router$/, replacement: routerShim},

and the path to tsconfig.extensions.json:

"react-router": ["./resources/js/martis-extensions/.shims/react-router-dom.d.mts"],

Without them, import { useNavigate } from 'react-router' would bundle a second React Router that knows nothing of the host's router, and its hooks throw outside a router context. Republish the shims too (php artisan vendor:publish --tag=martis-extension-shims --force) for the React Router 7 types. See Upgrading → The panel runs on React Router 7.

Refreshing the extension scaffold after an upgrade

The scaffold files (vite.extensions.config.ts, tsconfig.extensions.json, resources/js/martis-extensions/index.ts, resources/js/martis-extensions/tsconfig.json and the shims under resources/js/martis-extensions/.shims/ with their declarations) are copied into your app once. composer update does not touch them, and martis:install skips every file that already exists unless you pass --force, which rewrites all of them (the files in the four buckets are never touched).

Your extension build resolves @martis/runtime to .shims/runtime.mjs, which re-exports the members of window.Martis.runtime by name. A name the runtime gains in a later Martis version can be imported by name only once your copy of that file exports it. Until then the build stops with:

"Dropdown" is not exported by "resources/js/martis-extensions/.shims/runtime.mjs"
Named exportIn the shim since
useAuth, useToast, useToastSafe, useIsMobile, TwoFactorRequiredError, EmailVerificationRequiredError, AuthProvider, api, ApiError, config, AuthFrame, Sidebar, Topbar, Footer, and the react-router-dom, react-i18next and @tanstack/react-query re-exports (Link, useNavigate, useTranslation, useQuery, …)v1.10.0
FieldInput, FieldDisplay, DrawerShell, Tooltipv1.19.0
useMartisForm, FieldsForm, useToolFieldsv1.20.0
martisEventBusv1.21.0
useRevalidateOnFocusv1.22.0
NestedParentProvider, Dropdown, MultiSelect, createPortal, the registries (componentRegistry, iconRegistry, layoutRegistry), usePageTitle, useModalHistoryLock, OverridePropsProvider, useOverrideProps, useOverridePropsOptional, useUnsavedChangesGuard, useError, cssVar, accentColor, mutedTextColor, chartPalette, resolveColor, avatarColorForSeed, Sparkline, ClearButton, MartisLoader, usePreferences, usePreferencesOptional, loadLocale, applyDocumentDirection, usePrefersReducedMotion, addShortcut, disableShortcut, listShortcutsv1.38.0
flushSync (also in the react-dom shim)v1.38.2
useEscapeLayerv2.0.0
routeRegistry, useDynamicCrumb, ForbiddenPage, NotFoundPagev2.2.0
PasswordChangeRequiredErrorv2.3.0
useAuthLinkParams, parseAuthLinkFragment, the AuthLinkParams typev2.6.0
useModalFocus, the ModalFocusOptions typev2.7.0
useDashboardFilters, the ActiveFilters, SetDashboardFilters, DashboardFiltersUpdate, DashboardFiltersOptions and DashboardFiltersContextValue typesv2.9.0
ResourceIcon, the ResourceIconProps typev2.10.0
useAuthChallenge, AuthChallenge, the AuthChallengeProps, AuthChallengeState and ChallengeSurface typesv2.21.0

v2.3.0 also publishes .shims/i18next.d.mts, the i18next types the shims share; republishing the shims adds it.

react-dom/client (v2.10.0). The react-dom shim now serves unstable_batchedUpdates and version next to createPortal and flushSync, and a new .shims/react-dom-client.mjs (with react-dom-client.d.mts) serves createRoot and hydrateRoot. A library such as @dnd-kit/core imports unstable_batchedUpdates from react-dom; before v2.10.0 npm run build:extensions stopped with "unstable_batchedUpdates" is not exported by ".shims/react-dom.mjs". Republishing the shims (option 1 below) writes the two shims and their declarations, but the alias that sends react-dom/client to the new shim lives in vite.extensions.config.ts, and the matching paths entry in tsconfig.extensions.json: refresh the whole scaffold (option 2) or add them by hand:

// vite.extensions.config.ts
const reactDomClientShim = path.join(shimsDir, 'react-dom-client.mjs')
// in resolve.alias, next to the react-dom entry:
{find: /^react-dom\/client$/, replacement: reactDomClientShim},
"react-dom/client": ["./resources/js/martis-extensions/.shims/react-dom-client.d.mts"]

An app that has not done either keeps building and running: the old shims read createPortal and flushSync off window.Martis.runtime, where they stay. A new shim on a host older than v2.10.0 throws [react-dom-shim] window.Martis.reactDom not available when the bundle loads.

Three ways to get a missing name, from the narrowest:

  1. Republish the shims. They hold no app code, so replacing them is safe. The martis-extension-shims tag rewrites every shim and its declarations together, and leaves the Vite config, both tsconfig files and index.ts alone:

    php artisan vendor:publish --tag=martis-extension-shims --force
    npm run build:extensions
  2. Refresh the whole scaffold with php artisan martis:install --force. It rewrites the Vite config, tsconfig.extensions.json, index.ts, resources/js/martis-extensions/tsconfig.json and every shim with its declarations, so review the diff if you edited any of them.

  3. Read the name off the default export, which every shim since v1.10.0 provides and which is the host's runtime object itself: import runtime from '@martis/runtime', then const { Dropdown } = runtime. A misspelt name is then undefined at render time instead of a build error.

Type declarations (v1.38.0). Scaffolds published before v1.38.0 have no declarations, so tsc -p tsconfig.extensions.json reports Cannot find module '@martis/runtime' for every runtime import, and TypeScript 6 (what martis:install installs today) stops earlier on the deprecated baseUrl (TS5101). Republish the shims (option 1), then bring tsconfig.extensions.json in line with vendor/martis/martis/stubs/extensions/tsconfig.extensions.json.stub: copy it over (re-applying your own edits), or remove baseUrl and the @ext/* path (the Vite config never resolved it), set "types": ["vite/client"], set "include" to ["resources/js/martis-extensions/**/*"], and use these paths:

"paths": {
"@martis/runtime": ["./resources/js/martis-extensions/.shims/runtime.d.mts"],
"react-dom": ["./resources/js/martis-extensions/.shims/react-dom.d.mts"],
"react-dom/client": ["./resources/js/martis-extensions/.shims/react-dom-client.d.mts"],
"react-router-dom": ["./resources/js/martis-extensions/.shims/react-router-dom.d.mts"],
"react-router": ["./resources/js/martis-extensions/.shims/react-router-dom.d.mts"],
"react-i18next": ["./resources/js/martis-extensions/.shims/react-i18next.d.mts"],
"@tanstack/react-query": ["./resources/js/martis-extensions/.shims/tanstack-react-query.d.mts"],
"@/contexts/*": ["./resources/js/martis-extensions/.shims/runtime.d.mts"],
"@/lib/*": ["./resources/js/martis-extensions/.shims/runtime.d.mts"],
"@/components/auth/*": ["./resources/js/martis-extensions/.shims/runtime.d.mts"],
"@martis/martis/*": ["./resources/js/martis-extensions/.shims/runtime.d.mts"],
"@/components/fields/types": ["./resources/js/martis-extensions/.shims/runtime.d.mts"]
}

For your editor, add resources/js/martis-extensions/tsconfig.json (or copy vendor/martis/martis/stubs/extensions/martis-extensions-tsconfig.json.stub there):

{
"extends": "../../../tsconfig.extensions.json",
"include": ["./**/*"]
}

react-dom (fixed in v1.38.0). Scaffolds published before v1.38.0 send react-dom to the React shim, which exports React core only: import { createPortal } from 'react-dom' passes tsc, which reads @types/react-dom, and then stops the build with "createPortal" is not exported by ".shims/react.mjs". Import createPortal from @martis/runtime instead (republishing the shims, option 1 above, is enough for that), or send react-dom to its own shim: republish the shims, then in vite.extensions.config.ts add const reactDomShim = path.join(shimsDir, 'react-dom.mjs') and point the /^react-dom$/ alias at reactDomShim, and add the react-dom line of the paths above to tsconfig.extensions.json (or copy both stubs over, re-applying your own edits). The react-dom shim of v1.38.0 and v1.38.1 carried createPortal only, so a library that imports flushSync from react-dom (@tanstack/react-virtual, the usual list virtualiser, does it at the top of its entry) stopped the build with "flushSync" is not exported by ".shims/react-dom.mjs"; since v1.38.2 the runtime carries the host's flushSync and the shim re-exports it, and since v2.10.0 it serves unstable_batchedUpdates and version too, with react-dom/client in a shim of its own (see above). Republish the shims (option 1) to take them.

Generated cards and fields (fixed in v1.38.0). A card martis:card wrote before v1.38.0 binds componentKey('revenue-gauge'), but the dashboard resolves a card by its exact key and the entry registers cards/RevenueGauge.tsx as card:revenue-gauge: change the call to componentKey('card:revenue-gauge'). The entry registered a fields/ file as field:price-tag, a key the field renderer never reads, so a field martis:field generated rendered as plain text: refresh index.ts (php artisan martis:install --force, then re-add any registration of your own) or replace its fields loop with the one in vendor/martis/martis/stubs/extensions/index.ts.stub. The generators also split an acronym letter by letter (SEOReport became s-e-o-report) where the entry keeps it whole (seo-report): fix the key in such a class by hand.

Legacy import paths (fixed in v1.38.0). The Vite config also sends the paths that override files published by older versions import to the runtime shim, so those files keep building: @/contexts/*, @/lib/*, @/components/auth/*, @martis/martis/* and @/components/fields/types (the type module the v1.9.3 field override imports its props from). From v1.10.0 to v1.37.x the config matched only the start of the first four, and the alias replaces only what it matches, so every import through them failed (Could not load .../.shims/runtime.mjsapi for @/lib/api). If your extension imports through them, copy vendor/martis/martis/stubs/extensions/vite.extensions.config.ts.stub over vite.extensions.config.ts (re-applying your own edits), or make each pattern match the whole path (/^@\/lib\/.*$/). These paths reach only the names the runtime shim exports; new code imports from @martis/runtime. tsc resolves them too, through the tsconfig paths above. On the sidebar override the v1.9.3 generator wrote, it then reports what that file does wrong: it draws a nested menu group (type: 'group') as a link. Regenerate it with php artisan martis:component --type=sidebar --force, whose output lists a nested group's items under its label.

Upgrading from v1.8.18 or earlier

If your app shipped a resources/js/martis/boot.ts from the legacy build-time mechanism, the file is silently ignored from v1.8.19 onwards. martis:install detects it and prints a one-time warning so you know the file is dead. To migrate:

  1. Move each componentRegistry.register('tool:foo', FooTool) call's component into resources/js/martis-extensions/tools/Foo.tsx (filename = key suffix in PascalCase).
  2. Drop the register(...) call entirely — the auto-discovery entry handles it.
  3. Delete resources/js/martis/boot.ts and any related Vite alias (@user/martis/boot).
  4. Run php artisan martis:install --force to publish the new scaffold (your existing files in the buckets are not touched).

The package no longer ships the @user/martis/boot build-time alias or the @user Vite alias resolution — those mechanisms were removed in v1.8.19 because they never actually worked on the published bundle.

Directory Structure After Installation

your-laravel-app/
├── app/
│ ├── Martis/ # Your resource definitions (flat by default)
│ │ ├── UserResource.php
│ │ ├── PostResource.php
│ │ └── ... # Subdirectories optional
│ └── Providers/
│ └── MartisServiceProvider.php # Host provider (closures, menu, gates)
├── bootstrap/
│ └── providers.php # Auto-wired by martis:install
├── config/
│ └── martis.php # Published configuration
├── database/
│ └── migrations/
│ ├── *_create_martis_action_events_table.php # Audit log (+ the two morph id conversions)
│ ├── *_create_martis_user_preferences_table.php # Per-user prefs
│ ├── *_create_notifications_table.php # In-app notifications
│ ├── *_create_martis_cache_state_table.php # Cache versions and kill-switches
│ ├── *_add_martis_two_factor_columns_to_users_table.php # 2FA (with --with-2fa)
│ └── *_add_martis_profile_picture_column_to_users_table.php # Avatar (with --with-profile)
├── lang/
│ └── vendor/
│ └── martis/ # Published translations (martis-lang)
│ ├── en/
│ ├── pt_BR/
│ └── pt_PT/
├── public/
│ └── vendor/
│ └── martis/ # Published frontend assets (martis-assets)
└── resources/
└── js/
└── martis-extensions/ # Consumer React extensions (v1.9+)
├── index.ts # Auto-discovery entry — ships with martis:install
├── tsconfig.json # Editor tsconfig, extends tsconfig.extensions.json (v1.38.0)
├── .shims/ # Vite alias shims (react, runtime, etc.) and their declarations
├── tools/ # `martis:tool --with-component` outputs land here
├── fields/ # `martis:field` outputs
├── cards/ # `martis:card` outputs
└── overrides/ # `martis:component` outputs (shell, sidebar, auth pages, etc.)

Upgrading

When upgrading Martis, always re-publish the frontend assets. Recommended command:

composer update martis/martis
php artisan martis:install --force

Equivalent manual asset-only upgrade:

composer update martis/martis
php artisan martis:publish-assets

Why this second step exists:

  • Composer updates package files inside vendor/martis/martis
  • Martis serves published files from public/vendor/martis
  • The host app keeps using the old published files until you publish again

If your app has a custom theme and you are coming from 1.x, follow Theming → Upgrading from 1.x before you publish: the publish now writes public/vendor/martis/themes/ from resources/css/martis/, so edits made to the published copy have to move to the source first.

Coming from 1.x, also check:

  • profile.resource must be null or name a class that implements Martis\Contracts\ProfileResourceContract. Any other value, such as a misspelt class, now throws an InvalidArgumentException naming the key; 1.x fell back to the default resource without a word. When you set it, /martis/api/auth/user and the login response take the Topbar avatar from your resource's toArray(), as the profile page does. See Authentication → Custom Profile Resource.
  • Avatar colours come from the theme's --martis-avatar-1..16 tokens. The Topbar shows two initials on a palette colour instead of one letter on the accent colour, the profile avatar is no longer indigo, and the initials of an Avatar or UiAvatar field can change colour. To keep a fixed colour per record, use colorFrom(); to change the colours, redefine the tokens in your theme. A subclass of Avatar or UiAvatar that calls or overrides the trait's resolveInitialsColor(), deterministicInitialsColor() or $initialsPalette has to move to Martis\Support\Initials (paletteSlot(), defaultColor()), or override customInitialsColor() to give its own colour.
  • Metric results are cached per user. No action is needed, but the metric cache holds one entry per user and metric where 1.x held one per metric. See Cache → The four built-in layers.
  • A custom MARTIS_GUARD now signs in the panel's user everywhere, policies and gates included: see Upgrading → A custom Martis guard before you upgrade an app that sets one.

Use the asset-only command if you only want to refresh static files. Use the install command with --force if you want the full recommended refresh:

php artisan martis:install --force

--force also rewrites the extension scaffold (Vite config, tsconfig.extensions.json, shims and their declarations, index.ts, resources/js/martis-extensions/tsconfig.json), which is how an existing extension picks up the runtime names added since it was scaffolded. See Refreshing the extension scaffold after an upgrade for the narrower options.

Know what else the installer changes before you use it on an app with customisations. With --force:

  • it republishes lang/vendor/martis, overwriting customised strings
  • it rewrites the migrations Martis published in place; an application's own *_create_notifications_table.php or *_create_sessions_table.php (from make:notifications-table / make:session-table) is left alone: a file counts as Martis's only when it holds a sentence of the Martis stub's header, not because it mentions Martis
  • it rewrites resources/js/martis-extensions/index.ts, manual register() calls included

On every run, with or without --force:

  • it rewrites the profile and 2FA flags in .env from the flags you pass (see Optional Profile Support) and resets MARTIS_EXTENSIONS to /vendor/martis-user/extensions.js
  • the asset publish wipes and re-copies public/vendor/martis/ (see Step 4)
  • it ends with php artisan migrate --force, which applies all pending migrations of the app, unless you pass --no-migrate

Commit first and review git diff afterwards. If your application uses the optional profile and two-factor migrations, re-run the install command with the same options after upgrading:

composer update martis/martis
php artisan martis:install --force --with-profile --with-2fa --avatar-column=avatar_path

Since v2.0 every Martis cache key carries the installed version, so an upgrade rebuilds the caches on its own (on a path repository, whose version does not change, run php artisan martis:cache:clear). On the database or file cache store, run php artisan martis:cache:prune after an upgrade to delete the entries the previous version left behind; see Cache → Invalidation.

Vendor Publish Tags Reference

The package exposes the following --tag values for vendor:publish:

TagWhat it publishesDestination
martis-configconfig/martis.php (all knobs)config/martis.php
martis-providerHost service provider stubapp/Providers/MartisServiceProvider.php
martis-assetsPrecompiled React frontendpublic/vendor/martis/
martis-viewsBlade SPA shell templateresources/views/vendor/martis/
martis-langTranslation files (en, pt_BR, pt_PT)lang/vendor/martis/
martis-extension-shimsConsumer-extension shims and their TypeScript declarations (v1.38.0)resources/js/martis-extensions/.shims/
martis-migrationsAction-events audit log, user preferences, cache state and drop-dashboards-layout migrationsdatabase/migrations/*_create_martis_action_events_table.php, *_create_martis_user_preferences_table.php, *_create_martis_cache_state_table.php, *_drop_dashboards_layout_from_user_preferences_table.php
martis-preferences-migrationUser preferences tabledatabase/migrations/*_create_martis_user_preferences_table.php
martis-preferences-drop-dashboards-layout-migrationDrops the legacy dashboards_layout preferences columndatabase/migrations/*_drop_dashboards_layout_from_user_preferences_table.php
martis-cache-state-migrationCache state tabledatabase/migrations/*_create_martis_cache_state_table.php
martis-2fa-migration2FA columns on usersdatabase/migrations/*_add_two_factor_columns.php
martis-password-change-migrationForced password change columndatabase/migrations/*_add_must_change_password_column.php
martis-avatar-migrationProfile picture column on usersdatabase/migrations/*_add_profile_picture_column.php
martis-sessions-migrationSessions table (browser-sessions profile section)database/migrations/*_create_sessions_table.php
martis-invitations-migrationInvitations tabledatabase/migrations/*_create_invitations_table.php

martis:install runs the appropriate combination based on its flags, and is the only way to publish the notifications table and the action-events morph id conversions. Direct vendor:publish calls are for advanced manual workflows; see Step 5 for the duplicate-migration caveat.

Available Artisan Commands

The package ships 35 commands (plus the aliases martis:override → martis:component and martis:make-policy → martis:policy). The full list:

Setup & maintenance

CommandDescription
martis:installFull installation (directories, config, provider, assets, core migrations, translations, auto-migrate)
martis:userCreate an admin user (--if-missing / --update for idempotent bootstrap scripts)
martis:publish-assetsRepublish the frontend assets: wipes public/vendor/martis/, copies the package build, verifies it against the manifest (--no-wipe for the legacy merge copy), then publishes the app themes from resources/css/martis/
martis:vendor-publishPublish Martis package files by flag (--config, --assets, --views, --lang, --force); --assets goes through martis:publish-assets
martis:stubsPublish all generator stubs into stubs/martis/ for customisation (--force overwrites existing ones)
martis:list-overridesPrint the component keys the PHP layer declares (Tools, Actions with a custom component, resources); --frontend checks that your extension registers them
martis:list-env-varsList every MARTIS_* env var the published config reads, with its default and config key (--json for machine output)
martis:attachments:pruneDelete the Trix / Markdown uploads no record references any more (--dry-run, --hours, --disk; v2.4.0)
martis:agentsGenerate guidelines for AI coding agents (AGENTS.md, CLAUDE.md, ...) and optionally wire the Martis MCP server. With --no-interaction it overwrites existing files without asking; the docs MCP itself runs with php artisan mcp:start martis-docs (stdio) or as an app route (HTTP)
martis:invitationsScaffold the consumer-owned invitations admin UI (resource, actions, policy, notification); --no-migrate / --no-publish skip the migration steps

Cache control

CommandDescription
martis:cache:statusShow enabled / disabled state for each Martis cache subsystem
martis:cache:clearFlush every Martis cache subsystem
martis:cache:pruneDelete the cache entries earlier versions and clears left behind (database and file stores; v2.0)
martis:cache:enableEnable a Martis cache subsystem at runtime (survives until disabled)
martis:cache:disableDisable a Martis cache subsystem at runtime

Resource scaffolding

CommandDescription
martis:resourceScaffold a new resource class
martis:fieldScaffold a custom field class
martis:actionScaffold an action class
martis:filterScaffold a filter class
martis:lensScaffold a lens class
martis:policyScaffold an authorization policy
martis:toolScaffold a Tool (free-form sidebar page)
martis:rolesScaffold the Spatie roles + permissions admin (resources, policies, seeder)

Metrics & dashboards

CommandDescription
martis:dashboardScaffold a dashboard class
martis:valueScaffold a Value metric
martis:trendScaffold a Trend metric
martis:partitionScaffold a Partition metric
martis:progressScaffold a Progress metric
martis:activity-feedScaffold an Activity Feed metric
martis:endpoint-tableScaffold an Endpoint Table metric
martis:cardScaffold a custom dashboard card

Frontend & branding

CommandDescription
martis:componentScaffold a React override (TSX) under resources/js/martis-extensions/overrides/. Every --type auto-registers on the next npm run build:extensions. See Overrides for the filename → key table
martis:themeScaffold a custom theme in resources/css/martis/<name>.css, publish it and set theme.name in a published config/martis.php
martis:theme:diffCompare a consumer theme against the bundled package tokens (exit 0 aligned, 2 drift)
martis:ssoScaffold an SSO provider end to end (composer deps, config, env, listener, migrations); pass --no-composer --no-migrate in CI or agent shells. --driver=signed-token scaffolds a signed-token consumer and --issuer an audience of the issuing panel (v2.21.0)
martis:sso:keysPrint a new Ed25519 key pair for the signed-token SSO (v2.21.0); it writes nothing
martis:sso:checkValidate the SSO issuer block, each enabled signed-token provider block and the SSO cache store as the runtime reads them (v2.23.0). Read-only, prints no key, exit 1 when a block fails. See SSO

Next Steps