Installation Guide
Add Martis to any existing Laravel application as a Composer package.
Requirements
| Requirement | Minimum Version |
|---|---|
| PHP | 8.3+ |
| Laravel | 12.x or 13.x |
| Node.js | 20+ (contributors only) |
| npm | the version bundled with Node.js (contributors only; the repository ships package-lock.json and CI runs npm ci) |
| Database | Any driver Laravel supports — MySQL, PostgreSQL, SQLite, or SQL Server (no specific version required) |
| Redis | 7+ (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/martisphp artisan martis:installThis 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:
- Creates the directory structure —
app/Martis/for your resource definitions. - Publishes the config file —
config/martis.phpwith all customizable settings. - Publishes the host MartisServiceProvider —
app/Providers/MartisServiceProvider.phpand wires it intobootstrap/providers.phpin the file's own style: first in the array, with auseimport 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 inconfig/martis.php(closures, gate definitions, menu items, dashboards, runtime cache layers) is registered. See Host MartisServiceProvider below. - Publishes frontend assets — precompiled React app to
public/vendor/martis/. - 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_tableandcreate_martis_cache_state_table. A migration already present (matched by its*_<name>.phpsuffix) is skipped. Each migration it publishes gets its own timestamp, later than the one before and than the newest migration already indatabase/migrations, so they run in the order listed. - Publishes translation files —
en,pt_BR,pt_PTtolang/vendor/martis/. - Runs database migrations —
php artisan migrate --force, which creates themartis_action_events,martis_user_preferences,notificationsandmartis_cache_statetables. Note that it applies every pending migration of the application, not only the Martis ones, without the production confirmation prompt. Pass--no-migrateto publish the migrations without running them: the command then listsphp artisan migrateamong the next steps.
Upgrading from pre-0.7.0: If you already have an
action_eventstable, the new migration detects it and performs an in-placeRENAMEtomartis_action_events. No data loss. Themartis_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/martisOn 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:
| Flag | What 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. |
--password | Deprecated 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-missing | When the email already exists: exit 0 with an info line and change nothing. Safe to call unconditionally at boot. |
--update | When 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 afterwardsprintf '%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 --updateprintf 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
| Flag | Effect |
|---|---|
--force | Overwrite 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-config | Republish config/martis.php, overwriting your changes to it |
--force-provider | Republish app/Providers/MartisServiceProvider.php, overwriting your changes to it |
--with-profile | Enable 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-profile | Disable profile support, even when running interactively; wins over --with-profile |
--with-2fa | Enable two-factor support and publish the 2FA columns migration (*_add_martis_two_factor_columns_to_users_table.php); independent of --with-profile |
--no-2fa | Disable two-factor support; wins over --with-2fa |
--with-sessions | Publish the sessions table migration for the browser-sessions profile section (requires SESSION_DRIVER=database) |
--no-sessions | Skip the sessions table migration; wins over --with-sessions |
--avatar-column=<column> | Customize which users table column Martis should use for avatar paths |
--existing-avatar-column | Use an existing avatar column on users instead of publishing a migration |
--no-migrate | Publish 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-2faThe 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:installwith 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_pathThen set the same column in your environment:
MARTIS_AVATAR_COLUMN=avatar_pathIf 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_pathThe 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-2faIf 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/martisMartis registers its service provider automatically via Laravel's package discovery.
Step 2: Publish Configuration
php artisan vendor:publish --tag=martis-configThis creates config/martis.php. The most commonly customized keys:
path— Admin panel URL prefix (default:martis)guard— Authentication guard (default:null, falls back toconfig('auth.defaults.guard'))resources_path— Directory scanned for resource classes (default:app_path('Martis'));resources_namespacepins its namespace when it cannot be derived from Composer's PSR-4 map (default:null, derived)theme.default— Default theme:dark,light, orsystemtheme.allowToggle— Allow users to switch themes from the preferences panelpreferences.defaults.locale— Default panel language for users without a saved preference (MARTIS_DEFAULT_LOCALE, defaulten; it must be listed inpreferences.locales). Themartis.localemiddleware applies it (or the user's saved preference) on every authenticated Martis route, so this, notAPP_LOCALE, decides the panel language.locale— Read by the blade shell only whenpreferences.enabledisfalse(MARTIS_LOCALE, falling back toAPP_LOCALE, thenen)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 noavailable_localesconfig key — Laravel's own locale resolution applies, and any extra locales you add tolang/vendor/martis/<locale>/are picked up automatically. The picker shown on the user preferences panel is built frommeta.localesreturned by the preferences resolver, which inspects what locales actually have files.
Step 3: Publish the Host MartisServiceProvider
php artisan vendor:publish --tag=martis-providerCreates 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-assetsThis 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, andcomposer updatedo not automatically refresh files already published intopublic/vendor/martis/- after installing or upgrading Martis, run
martis:installormartis: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.
| Tag | Publishes |
|---|---|
martis-migrations | create_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-migration | create_martis_user_preferences_table (per-user theme / locale / density / accent / reduced-motion) |
martis-preferences-drop-dashboards-layout-migration | drop_dashboards_layout_from_user_preferences_table |
martis-cache-state-migration | create_martis_cache_state_table (persisted cache versions and runtime kill-switches) |
martis-2fa-migration | add_two_factor_columns to users (TOTP secret + recovery codes) |
martis-password-change-migration | add_must_change_password_column to the Martis guard's users table (the forced password change flag, v2.3.0) |
martis-avatar-migration | add_profile_picture_column to users (filename of the uploaded avatar) |
martis-sessions-migration | create_sessions_table (browser-sessions profile section, SESSION_DRIVER=database) |
martis-invitations-migration | create_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-migrationsphp artisan migrateThe 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-migrationphp artisan vendor:publish --tag=martis-avatar-migrationphp artisan migratemartis: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 model | user_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 / HasUlids | string('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' keys | sessions.user_id | notifications.notifiable_* |
|---|---|---|
All auto-incrementing bigint | foreignId() (no constraint) | morphs() |
All HasUuids / all HasUlids | uuid() / 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:installThe 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-langCopies 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-viewsCopies 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 surviveconfig: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-providerWithout 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 --forcedoes not touch this file (v1.10.2+). The default--forceflag rewrites the extension scaffold (Vite config, both tsconfig files,index.ts, the shims and their declarations), republisheslang/vendor/martisand 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-providerThe 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 hereThe 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 path | Registered key |
|---|---|
tools/Charts.tsx | tool:charts |
tools/SystemHealth.tsx | tool:system-health |
tools/SEOReport.tsx | tool:seo-report |
cards/RevenueGauge.tsx | card: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.tsx | layout:sidebar |
overrides/LoginPage.tsx | auth: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:installThe 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-componentnpm run build:extensionsThat'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.jsThe 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.jsonYour 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 export | In 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, Tooltip | v1.19.0 |
useMartisForm, FieldsForm, useToolFields | v1.20.0 |
martisEventBus | v1.21.0 |
useRevalidateOnFocus | v1.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, listShortcuts | v1.38.0 |
flushSync (also in the react-dom shim) | v1.38.2 |
useEscapeLayer | v2.0.0 |
routeRegistry, useDynamicCrumb, ForbiddenPage, NotFoundPage | v2.2.0 |
PasswordChangeRequiredError | v2.3.0 |
useAuthLinkParams, parseAuthLinkFragment, the AuthLinkParams type | v2.6.0 |
useModalFocus, the ModalFocusOptions type | v2.7.0 |
useDashboardFilters, the ActiveFilters, SetDashboardFilters, DashboardFiltersUpdate, DashboardFiltersOptions and DashboardFiltersContextValue types | v2.9.0 |
ResourceIcon, the ResourceIconProps type | v2.10.0 |
useAuthChallenge, AuthChallenge, the AuthChallengeProps, AuthChallengeState and ChallengeSurface types | v2.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.tsconst 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:
-
Republish the shims. They hold no app code, so replacing them is safe. The
martis-extension-shimstag rewrites every shim and its declarations together, and leaves the Vite config, both tsconfig files andindex.tsalone:php artisan vendor:publish --tag=martis-extension-shims --forcenpm run build:extensions -
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.jsonand every shim with its declarations, so review the diff if you edited any of them. -
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', thenconst { Dropdown } = runtime. A misspelt name is thenundefinedat 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:
- Move each
componentRegistry.register('tool:foo', FooTool)call's component intoresources/js/martis-extensions/tools/Foo.tsx(filename = key suffix in PascalCase). - Drop the
register(...)call entirely — the auto-discovery entry handles it. - Delete
resources/js/martis/boot.tsand any related Vite alias (@user/martis/boot). - Run
php artisan martis:install --forceto 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/martisphp artisan martis:install --forceEquivalent manual asset-only upgrade:
composer update martis/martisphp artisan martis:publish-assetsWhy 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.resourcemust benullor name a class that implementsMartis\Contracts\ProfileResourceContract. Any other value, such as a misspelt class, now throws anInvalidArgumentExceptionnaming the key; 1.x fell back to the default resource without a word. When you set it,/martis/api/auth/userand the login response take the Topbar avatar from your resource'stoArray(), as the profile page does. See Authentication → Custom Profile Resource.- Avatar colours come from the theme's
--martis-avatar-1..16tokens. 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 anAvatarorUiAvatarfield can change colour. To keep a fixed colour per record, usecolorFrom(); to change the colours, redefine the tokens in your theme. A subclass ofAvatarorUiAvatarthat calls or overrides the trait'sresolveInitialsColor(),deterministicInitialsColor()or$initialsPalettehas to move toMartis\Support\Initials(paletteSlot(),defaultColor()), or overridecustomInitialsColor()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_GUARDnow 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.phpor*_create_sessions_table.php(frommake: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, manualregister()calls included
On every run, with or without --force:
- it rewrites the profile and 2FA flags in
.envfrom the flags you pass (see Optional Profile Support) and resetsMARTIS_EXTENSIONSto/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/martisphp artisan martis:install --force --with-profile --with-2fa --avatar-column=avatar_pathSince 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:
| Tag | What it publishes | Destination |
|---|---|---|
martis-config | config/martis.php (all knobs) | config/martis.php |
martis-provider | Host service provider stub | app/Providers/MartisServiceProvider.php |
martis-assets | Precompiled React frontend | public/vendor/martis/ |
martis-views | Blade SPA shell template | resources/views/vendor/martis/ |
martis-lang | Translation files (en, pt_BR, pt_PT) | lang/vendor/martis/ |
martis-extension-shims | Consumer-extension shims and their TypeScript declarations (v1.38.0) | resources/js/martis-extensions/.shims/ |
martis-migrations | Action-events audit log, user preferences, cache state and drop-dashboards-layout migrations | database/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-migration | User preferences table | database/migrations/*_create_martis_user_preferences_table.php |
martis-preferences-drop-dashboards-layout-migration | Drops the legacy dashboards_layout preferences column | database/migrations/*_drop_dashboards_layout_from_user_preferences_table.php |
martis-cache-state-migration | Cache state table | database/migrations/*_create_martis_cache_state_table.php |
martis-2fa-migration | 2FA columns on users | database/migrations/*_add_two_factor_columns.php |
martis-password-change-migration | Forced password change column | database/migrations/*_add_must_change_password_column.php |
martis-avatar-migration | Profile picture column on users | database/migrations/*_add_profile_picture_column.php |
martis-sessions-migration | Sessions table (browser-sessions profile section) | database/migrations/*_create_sessions_table.php |
martis-invitations-migration | Invitations table | database/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
| Command | Description |
|---|---|
martis:install | Full installation (directories, config, provider, assets, core migrations, translations, auto-migrate) |
martis:user | Create an admin user (--if-missing / --update for idempotent bootstrap scripts) |
martis:publish-assets | Republish 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-publish | Publish Martis package files by flag (--config, --assets, --views, --lang, --force); --assets goes through martis:publish-assets |
martis:stubs | Publish all generator stubs into stubs/martis/ for customisation (--force overwrites existing ones) |
martis:list-overrides | Print the component keys the PHP layer declares (Tools, Actions with a custom component, resources); --frontend checks that your extension registers them |
martis:list-env-vars | List every MARTIS_* env var the published config reads, with its default and config key (--json for machine output) |
martis:attachments:prune | Delete the Trix / Markdown uploads no record references any more (--dry-run, --hours, --disk; v2.4.0) |
martis:agents | Generate 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:invitations | Scaffold the consumer-owned invitations admin UI (resource, actions, policy, notification); --no-migrate / --no-publish skip the migration steps |
Cache control
| Command | Description |
|---|---|
martis:cache:status | Show enabled / disabled state for each Martis cache subsystem |
martis:cache:clear | Flush every Martis cache subsystem |
martis:cache:prune | Delete the cache entries earlier versions and clears left behind (database and file stores; v2.0) |
martis:cache:enable | Enable a Martis cache subsystem at runtime (survives until disabled) |
martis:cache:disable | Disable a Martis cache subsystem at runtime |
Resource scaffolding
| Command | Description |
|---|---|
martis:resource | Scaffold a new resource class |
martis:field | Scaffold a custom field class |
martis:action | Scaffold an action class |
martis:filter | Scaffold a filter class |
martis:lens | Scaffold a lens class |
martis:policy | Scaffold an authorization policy |
martis:tool | Scaffold a Tool (free-form sidebar page) |
martis:roles | Scaffold the Spatie roles + permissions admin (resources, policies, seeder) |
Metrics & dashboards
| Command | Description |
|---|---|
martis:dashboard | Scaffold a dashboard class |
martis:value | Scaffold a Value metric |
martis:trend | Scaffold a Trend metric |
martis:partition | Scaffold a Partition metric |
martis:progress | Scaffold a Progress metric |
martis:activity-feed | Scaffold an Activity Feed metric |
martis:endpoint-table | Scaffold an Endpoint Table metric |
martis:card | Scaffold a custom dashboard card |
Frontend & branding
| Command | Description |
|---|---|
martis:component | Scaffold 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:theme | Scaffold a custom theme in resources/css/martis/<name>.css, publish it and set theme.name in a published config/martis.php |
martis:theme:diff | Compare a consumer theme against the bundled package tokens (exit 0 aligned, 2 drift) |
martis:sso | Scaffold 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:keys | Print a new Ed25519 key pair for the signed-token SSO (v2.21.0); it writes nothing |
martis:sso:check | Validate 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
- Resources — Learn how to define and configure resources
- Fields Reference — Explore the 50+ field types Martis ships
- Override System — Customize the UI without forking