Mod is pre-1.0. Minor releases may change the API until 1.0.
Skip to content

Frontend Files ​

Generate Inertia pages, Blade views and frontend files beside their module’s PHP classes. Page identities and imports follow the active layout.

The ddd preset has no frontend paths or view namespaces. Extend it with ->frontend(...) before generating frontend files. Its markdown mail and notification views stay in Laravel's resources/views. In mod:list --json, the frontend object keeps all seven keys with null values until paths are declared.

Generating a Page ​

bash
php artisan mod:page Inventory:Widget/Index
# -> app/Modules/Inventory/resources/js/pages/Widget/Index.vue

In a Vue app, render the page as Inventory::Widget/Index. In a React app, the same command writes widget/index.tsx and its identity is Inventory::widget/index. The default page imports Head. An app with no Inertia dependency in package.json has no page file type.

Generator Templates ​

A generator template’s filename sets its command and extension. Put this template in stubs/mod/@module/resources/js/components/card.vue.stub:

stubs/mod/@module/resources/views/components/card.blade.php.stub
vue
@props(['title'])

<div {{ $attributes->class('rounded border p-4') }}>
    <h3>{{ $title }}</h3>
    {{ $slot }}
</div>

Vue files use StudlyCase; React, Blade, Markdown and CSS use kebab-case. JavaScript and TypeScript retain the typed name. Custom file types can set case:. An app’s stubs/mod.page.vue.stub or .tsx.stub replaces the minimal page. Quote template paths containing [slot].

Markdown Templates ​

Plain templates can also generate prompts. Save this template at stubs/mod/@module/resources/prompts/prompt.md.stub:

stubs/mod/@module/resources/prompts/prompt.md.stub
markdown
# {{ name.headline }}

You are the {{ module }} assistant. Answer using only the documents provided.

mod:prompt Agents:AnswerQuestion writes resources/prompts/answer-question.md inside the Agents module.

Placeholders ​

Only known placeholder names are replaced. Unknown Vue and Blade expressions pass through. Use explicit name forms for known names; ambiguous bare forms produce a warning with the template and line. @{{ name }} emits literal {{ name }}.

stubs/mod/@module/resources/js/components/badge.vue.stub
vue
<template>
    <span>{{ name }}</span>
    <span>@{{ name.kebab }}</span>
    <span>{{ count }}</span>
</template>

Lists support .json and .array; .headline formats a value for prose. Artifact aliases expose .name (framework identity), .import, .tag and .path. Imports under the group root use @modules/; files below resources/js/ use @/. An alias import never traverses its root with ../.

Generating Pages with Their Controller ​

This recipe adds four pages and replaces the controller in the CRUD scaffold. Register it after that scaffold in AppServiceProvider::boot():

app/Providers/AppServiceProvider.php
php
use Tey\Mod\Facades\Mod;
use Tey\Mod\Scaffolds\Scaffold;

Mod::scaffold('crud-pages', fn (Scaffold $s) => $s
    ->include('crud')
    ->makes('page', name: '{name}/Index', as: 'indexPage', stub: 'crud-index')
    ->makes('page', name: '{name}/Create', as: 'createPage', stub: 'crud-form')
    ->makes('page', name: '{name}/Edit', as: 'editPage', stub: 'crud-form')
    ->makes('page', name: '{name}/Show', as: 'showPage', stub: 'crud-show')
    ->makes('controller', name: '{name}Controller', stub: 'inertia-crud'));

Create the templates below, then generate the twelve files:

bash
php artisan mod:crud-pages Inventory:Widget --no-interaction
# -> app/Modules/Inventory/Http/Controllers/WidgetController.php

The four pages sit in the same plan as the model, migration, factory, requests, resource, policy and controller. {{ indexPage.name }} supplies the controller’s Inertia identity for either stack.

The Controller Template ​

Save this complete generator template as stubs/mod.controller.inertia-crud.stub:

stubs/mod.controller.inertia-crud.stub
php
<?php

namespace {{ namespace }};

use {{ resource.fqcn }};
use {{ model.fqcn }};
use Inertia\Inertia;

class {{ class }}
{
    public function index()
    {
        return Inertia::render('{{ indexPage.name }}', [
            '{{ name.plural.camel }}' => {{ resource }}::collection({{ model }}::paginate()),
        ]);
    }

    public function edit({{ model }} ${{ model.camel }})
    {
        return Inertia::render('{{ editPage.name }}', [
            '{{ model.camel }}' => new {{ resource }}(${{ model.camel }}),
        ]);
    }
}

The Vue Index Template ​

Save this variant in a Vue app:

stubs/mod.page.crud-index.vue.stub
vue
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';

defineProps<{
    {{ name.plural.camel }}: { data: Array<{ id: number }> };
}>();
</script>

<template>
    <Head title="{{ name.plural }}" />
    <ul>
        <li v-for="{{ name.camel }} in {{ name.plural.camel }}.data" :key="{{ name.camel }}.id">{{ {{ name.camel }}.id }}</li>
    </ul>
</template>

The React Index Template ​

Save this variant in a React app:

stubs/mod.page.crud-index.tsx.stub
tsx
import { Head } from '@inertiajs/react';

type Props = { {{ name.plural.camel }}: { data: Array<{ id: number }> } };

export default function Index({ {{ name.plural.camel }} }: Props) {
    return <>
        <Head title="{{ name.plural }}" />
        <ul>{ {{ name.plural.camel }}.data.map(({{ name.camel }}) => <li key={ {{ name.camel }}.id }>{ {{ name.camel }}.id }</li>)}</ul>
    </>;
}

The Remaining Pages ​

For each of crud-form and crud-show, save the matching variant below. These minimal pages provide a starting point for your form and detail screen:

stubs/mod.page.crud-form.vue.stub
vue
<template><p>{{ name.studly }}</p></template>
stubs/mod.page.crud-form.tsx.stub
tsx
export default function {{ name.studly }}() { return <p>{{ name.studly }}</p>; }

Loading the Widgets Route ​

After generating the files, add this route to routes/web.php and run php artisan migrate:

routes/web.php
php
<?php

use App\Modules\Inventory\Http\Controllers\WidgetController;
use Illuminate\Support\Facades\Route;

Route::get('/widgets', [WidgetController::class, 'index']);

GET /widgets returns the generated index page with its paginated resource data. To keep routes beside the module, use module routes.

Pages in Scaffold Trees ​

A page member can accompany each repeated tab. The same aliases supply framework identities, imports and anchored inserts.

app/Providers/AppServiceProvider.php
php
use Tey\Mod\Facades\Mod;
use Tey\Mod\Scaffolds\Part;
use Tey\Mod\Scaffolds\Scaffold;

Mod::scaffold('tab-page', fn (Scaffold $s) => $s
    ->asks('base', type: 'class')
    ->asks('layout', type: 'file')
    ->asks('tab', type: 'text')
    ->makes('view-model', name: '{name}{tab}ViewModel', as: 'page', stub: 'tab-page')
    ->makes('page', name: '{name}/{tab}', as: 'view', stub: 'tab-page'));

Mod::scaffold('resource-tabs', fn (Scaffold $s) => $s
    ->asks('model', type: 'model', default: '{name}')
    ->asks('tabs', type: 'list', default: ['Overview', 'Details', 'Notes'])
    ->makes('view-model', name: 'Manage{name}ViewModel', as: 'base', stub: 'tabs-layout')
    ->makes('tabs-layout', name: '{name}Layout', as: 'layout')
    ->makes('controller', name: '{name}Controller', stub: 'tabs')
    ->part('tab', uses: 'tab-page', with: ['base' => '{{ base.fqcn }}', 'layout' => '{{ layout.path }}'], configure: fn (Part $p) => $p
        ->inserts(into: 'base', at: 'tabs', stub: 'tabs-entry')
        ->inserts(into: 'controller', at: 'actions', stub: 'tabs-action'))
    ->each('tabs', part: 'tab'));

Create the recipe’s variants and insert templates before running it. A file question accepts an existing frontend path or a planned plain artifact and exposes its identity forms. Child commands can add a tab later; see scaffold trees.

Frontend Tree Templates ​

Use the PHP view-model variants and entry insert from Templates for the Tree. Add these frontend variants:

stubs/mod/@module/resources/js/components/tabs-layout.vue.stub
vue
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';

defineProps<{ title: string }>();

const page = usePage<{ layout: { tabs: Array<{ label: string; href: string; active: boolean }> } }>();
</script>

<template>
    <nav>
        <Link v-for="item in page.props.layout.tabs" :key="item.href" :href="item.href" :class="{ active: item.active }">{{ item.label }}</Link>
    </nav>
    <h1>{{ title }}</h1>
    <slot />
</template>
stubs/mod.page.tab-page.vue.stub
vue
<script setup lang="ts">
import {{ layout }} from '{{ layout.import }}';
</script>

<template>
    <{{ layout }} title="{{ tab }}">
        <!-- {{ tab }} -->
    </{{ layout }}>
</template>
stubs/mod.insert.tabs-action.stub
php
    public function {{ tab.camel }}({{ model }} ${{ model.camel }})
    {
        return Inertia::render('{{ tab.view.name }}', new \{{ tab.page.fqcn }}(${{ model.camel }}));
    }

Artifact Identities ​

FormExampleUsed By
{{ indexPage.name }}Inventory::Widget/IndexInertia rendering
{{ layout.import }}@modules/Inventory/resources/js/components/WidgetLayout.vueJavaScript imports
{{ layout }}WidgetLayoutComponent tags
{{ mail.view.name }}inventory::mail.widget-restockedBlade views and mail
{{ badge.tag }}x-inventory::stock-badgeAnonymous Blade tags
{{ card.path }}app/Modules/Inventory/resources/js/components/LowStockCard.vueProject-relative files

A file question supplies the same forms from its selected artifact. Moving a frontend root changes the forms with it.

Dashboard Widgets ​

Repeated dashboard parts can write a metric class and card while inserting their registration into the parent. The part’s question value names the child; {name} stays the cluster name.

app/Providers/AppServiceProvider.php
php
use Tey\Mod\Facades\Mod;
use Tey\Mod\Scaffolds\Part;
use Tey\Mod\Scaffolds\Scaffold;

Mod::scaffold('metric', fn (Scaffold $s) => $s
    ->makes('class', name: 'Metrics/{widget}Metric', as: 'metric', stub: 'metric')
    ->makes('metric-card', name: '{widget}Card', as: 'card'));

Mod::scaffold('dashboard', fn (Scaffold $s) => $s
    ->makes('view-model', name: '{name}DashboardViewModel', as: 'dashboard', stub: 'dashboard')
    ->makes('page', name: '{name}/Dashboard', as: 'view', stub: 'dashboard')
    ->part('widget', uses: 'metric', configure: fn (Part $p) => $p
        ->inserts(into: 'dashboard', at: 'widgets', stub: 'dashboard-metric')
        ->inserts(into: 'view', at: 'card-imports', stub: 'dashboard-card-import')
        ->inserts(into: 'view', at: 'widgets', stub: 'dashboard-card')));

Registration comes from the recipe’s anchored inserts. There is no folder-scanning widget registry.

Stack Variants ​

Use an extension-specific variant for each stack:

text
stubs/mod.page.crud-index.vue.stub
stubs/mod.page.crud-index.tsx.stub
stubs/mod.page.crud-form.vue.stub
stubs/mod.page.crud-form.tsx.stub

When a variant is missing, a terminal offers to create its minimal source. A non-interactive run exits with the filename to supply. Vue pages omit lang="ts" without tsconfig.json; React uses .jsx without it.

Copying a Frontend File ​

bash
php artisan mod:template --from=app/Modules/Inventory/resources/js/pages/Widget/Index.vue --into=@module/resources/js/pages/list-page
# -> stubs/mod/@module/resources/js/pages/list-page.vue.stub

The source bytes are copied unchanged. The output identifies name and group mentions to generalize manually. --dry-run --json includes mentions without writing.

Views and Blade Components ​

bash
php artisan mod:view Inventory:widgets.show
# -> app/Modules/Inventory/resources/views/widgets/show.blade.php
php artisan mod:component Inventory:StockBadge --view
# -> app/Modules/Inventory/resources/views/components/stock-badge.blade.php

Use view('inventory::widgets.show') and <x-inventory::stock-badge />. Without --view, the component command creates a class and view; --inline creates an inline class component. Grouped mail and notification commands qualify their companion views too.

Namespaces register after providers boot. Generate a folder, then boot the app again to use it. Reserved or existing namespaces are skipped with a warning. Type-first grouped views use resources/views/inventory; ungrouped app views have no namespace.

Installing for Inertia ​

bash
php artisan mod:install inertia --dry-run --json
php artisan mod:install inertia --no-interaction
php artisan mod:list --json

The installer previews edits to the app entry, Vite, TypeScript and Tailwind configuration. It imports the resolver from vendor/tey/mod/resources/js/inertia; no npm package is required. A second run prints Already wired.

The resolver uses literal app and module glob maps. Missing or ambiguous pages throw an error; module names never fall back to app pages. Mirrored pages already matched by the app glob keep the existing resolver. Custom resolvers and unrecognized configuration receive manual instructions without edits.

For manual wiring, add the reported @modules root to Vite and TypeScript paths, include module scripts in TypeScript, append module views to Vite refresh paths, and include the module resources in Tailwind scanning. Inspect frontend.import_alias for the intended mapping and wiring.inertia, wiring.vite_alias, and wiring.tailwind for detected configuration. Commands lists installer options.

Checking Generated Frontend Code ​

The docs harness creates Vue and React starter-kit apps for Laravel 12 and 13. It runs this recipe, builds with Vite, checks Vue/React types, and requests /widgets through Laravel’s HTTP kernel. Run it from the docs repository:

bash
verify/setup.sh /tmp/mod-docs-verify /path/to/mod 12 13
verify/run.sh /tmp/mod-docs-verify 12 13 -- frontend

Inertia Page Checks ​

Since 0.5 (unreleased), Mod registers every existing qualified page folder with Inertia’s page and testing finders when the Laravel adapter is installed. Names such as Inventory::Widget/Index work with inertia.ensure_pages_exist (Inertia 2), inertia.pages.ensure_pages_exist (Inertia 3) and assertInertia()->component(...); a missing page still fails. Registration follows frontend(pages:, pageName:), the app’s pages/Pages casing and nested group names, independently of class discovery. Unqualified mirrored pages continue to use Inertia’s app page paths. Existing finder namespaces keep their ownership; a clash warns.

Full-page Inertia Loads ​

Since 0.5 (unreleased).

Use Mod::pageEntry() in the root Blade view's Vite entries to map the current page to its real folder:

blade
@vite(['resources/js/app.ts', \Tey\Mod\Facades\Mod::pageEntry($page['component'], 'vue')])

For React use the app's entry (such as resources/js/app.tsx) and tsx or jsx as its page extension. Ordinary app pages keep the app's pages/Pages folder; qualified pages follow the layout's frontend paths. The helper returns a project-relative manifest key and refuses unknown namespaces or traversal. Build the frontend after adding pages. The module page glob in the app entry includes these files in Vite's manifest. This supports direct loads as well as client navigation.

Customized Inertia Bootstraps ​

Since 0.5 (unreleased).

Run php artisan mod:install inertia --dry-run first. Known starter-kit anchors can be wired automatically. If the bootstrap has a custom resolver, the installer prints the import and exact page lookup for the configured paths, names the entry file and insertion point, and prints the Tailwind sources. JSON previews include the same instructions in warnings. It applies no edits while manual wiring is required.

For a modal adapter or persistent default layout, keep the existing adapter import, plugins and setup callback. Add the resolver import beside the other imports in resources/js/app.ts:

ts
import { resolveModulePage } from '../../vendor/tey/mod/resources/js/inertia';

Replace only the page lookup inside the existing resolve callback. For the default Vue module paths:

ts
type PageModule = {
    default: import('vue').DefineComponent & { layout?: typeof DefaultLayout };
};

// Inside the existing createInertiaApp options:
resolve: async (name) => {
    const page = await resolveModulePage(
        name,
        import.meta.glob<PageModule>('./pages/**/*.vue'),
        import.meta.glob<PageModule>('../../app/Modules/*/resources/js/pages/**/*.vue'),
    );
    page.default.layout ??= DefaultLayout;
    return page;
},

Keep the application's existing DefaultLayout import. A modal package's createInertiaApp wrapper can use this same lookup; retain its setup code. React uses the same resolver with .tsx/.jsx globs and the application's existing layout wrapper. Use the exact globs the installer prints for custom roots, Pages casing and JavaScript entries.

For Tailwind v4, add the printed @source after the Tailwind import in resources/css/app.css:

css
@source '../../app/Modules/**/resources/**/*.{vue,ts,blade.php}';

For Tailwind v3, add the printed project-relative glob to the content array in the app's Tailwind config. Apply the Vite alias and TypeScript additions printed for their own files too. Then build the app and check a direct module page load.

Read the frontend stack ​

Packages can call Mod::stack() from their provider. Its inertia() returns vue, react, svelte or null; typescript() checks for tsconfig.json, pagesPath() preserves the app's pages/Pages casing, and livewire() detects livewire/livewire or livewire/flux in Composer declarations. toArray() is the same stack record returned by mod:list --json. This reads declarations without installing dependencies or changing files. Svelte pages use .svelte; an app's stubs/mod.page.svelte.stub overrides the minimal default.

Package pages and wiring ​

Register package pages from a provider:

php
use Tey\Mod\Facades\Mod;

Mod::pages('billing', __DIR__.'/../resources/js/pages');
Mod::wiring()
    ->alias('@acme/billing', 'vendor/acme/billing/resources/js')
    ->source('vendor/acme/billing/resources/js');

Render billing::Invoices normally. Both Inertia page finders and Mod::pageEntry('billing::Invoices', 'vue') use the declared directory. Namespaces must be unique. Since 0.6.1, a package installed through a Composer path repository works too: its vendor/ directory is a symlink, and mod keeps the lexical vendor/... paths in page entries, globs and planned edits. External finder hints retain precedence.

A declaration mod can't use never stops the application from booting. Paths outside the application, traversal, symlink escapes, missing page directories, invalid names and conflicting declarations are skipped and reported as frontend_notices in mod:list --json, each with code, path and message. The codes are frontend_path_invalid, frontend_path_escape, frontend_path_unavailable, frontend_alias_invalid, frontend_alias_conflict, page_namespace_invalid and page_namespace_conflict. mod:install prints them, and its JSON previews include them in warnings. Valid declarations are still installed.

Run mod:install inertia --dry-run --json to preview literal package page globs, aliases, TypeScript paths (both @acme/billing and @acme/billing/*) and includes, and Tailwind sources. Apply with mod:install inertia --no-interaction. Repeated installation says "Already wired." when every declaration is confirmed. Custom bootstraps receive the exact manual lines; keep adapters, layout assignment and plugins intact. Adding a new package declaration requires another installation; new files under existing globs do not. Providers only declare wiring; they never write application files.

A package command can use $plan = Mod::wiring()->plan(), inspect $plan->toArray() and explicitly call $plan->apply(). Warnings prevent writing, and stale previews are refused before any file is written. source() scans Tailwind classes without loading CSS. css() explicitly imports the chosen stylesheet into resources/css/app.css; style order remains application-owned. Alias declarations reject conflicting targets and are ordered most specific first. The JSON inventory adds package_pages, frontend_wiring and frontend_notices records.

Frontend files in mounted roots ​

A root can own a component-only kit without becoming a module:

php
use Tey\Mod\Facades\Mod;
use Tey\Mod\Layout\Root;

Mod::layout('custom')->extends('modules')->path('app/Modules/{module}')
    ->mounts('ui', 'App\\UI\\', 'app/UI', fn (Root $root) => $root
        ->frontend(components: 'resources/js', alias: '@/UI'));

For a root with pages and namespaced views:

php
use Tey\Mod\Facades\Mod;
use Tey\Mod\Layout\Root;

Mod::layout('custom')->extends('modules')->path('app/Modules/{module}')
    ->mounts('ui', 'App\\UI\\', 'app/UI', fn (Root $root) => $root
        ->frontend(pages: 'screens', components: 'resources/js', pageName: 'UI::{path}',
            views: 'templates', viewNamespace: 'ui', alias: '@/UI'));

All Root::frontend() paths are relative to the mount. Pages opt in with both pages: 'screens' and pageName: 'UI::{path}'; views opt in with both views: 'templates' and viewNamespace: 'ui'. Names and aliases are explicit; a literal alias requires a components path; conflicts are rejected before installation. Existing layout-level frontend paths retain their project-relative semantics. Omitted arguments preserve declarations, and child layouts inherit their parent's owners.

Run the normal installation preview/apply step. The owner adds a specific Vite alias before broader aliases, TypeScript paths/includes, Tailwind sources and canonical page globs. Component files are dependencies, never executable inputs. Views use Laravel's namespace/anonymous-component registration and its reserved namespace protection. CSS imports, global component registration, layouts, framework plugins and exclusions remain application-owned. frontend_owners in mod:list --json exposes the resolved paths and identities.

Static assets and executable entries ​

Declare narrow static patterns separately from styles or scripts that need compilation:

php
use Tey\Mod\Facades\Mod;

Mod::layout('modules')->assets(
    files: ['app/Modules/{module}/images/**', 'app/Modules/{module}/fonts/**'],
    entries: ['app/Modules/{module}/styles/editor.css'],
);
// Inside a mount: $root->assets(files: ['images/**'], entries: ['css/theme.css']);

Layout paths are project-relative; root paths are mount-relative. Static globs support * and ** plus declared layout dimensions. Entries are literal CSS/JS/TS file templates expanded across existing owners; absent optional files are omitted from inputs and reported in the inventory. Traversal, external paths, malformed patterns, raw style/script file declarations and escaping symlinks are refused before writing. Ordinary imports and CSS url()/@import dependencies continue to use Vite's normal graph.

Preview with mod:install assets --dry-run --json, then apply with mod:install assets --no-interaction. No Inertia stack is required. Automatic static wiring requires laravel-vite-plugin 3; older versions retain ordinary imports and receive manual instructions until an adapter is verified. Custom Vite options receive exact instructions. Existing central inputs, static patterns and plugin options are retained.

The installer owns resources/mod-assets.json and imports it into the Vite configuration. Do not edit that generated file; change declarations and sync. Adding static files beneath existing globs needs no sync. Adding or removing independent entries requires another explicit installation before building. Repeated sync is a no-op, and removed owned entries disappear from the generated list. Unrelated application inputs remain intact.

Reference images/fonts from JS with imports, or from Blade with Vite::asset('app/Modules/Editor/images/logo.svg'). Select independent stylesheet loading with @vite('app/Modules/Editor/styles/editor.css'). Building that entry does not load it globally. Use Laravel's usual build-directory/CDN configuration. Declaring images/fonts neither imports CSS nor adds Tailwind sources. CSS themes, class sources, import order and loading remain application-owned. The additive assets inventory records declarations, expanded entries, existence and confirmed wiring; it makes no claim that a production build exists.

Released under the MIT License. Created by Jasper Tey.