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

Scaffolds ​

A scaffold is a recipe of several file types generated together. Each member follows the active layout, so the same recipe can work in modules or across DDD layers.

Generating a Recipe ​

Register this CRUD recipe in a service provider. Create the named variants in the next section before running it:

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

Mod::scaffold('crud', fn (Scaffold $s) => $s
    ->makes('model', options: ['--migration', '--factory'])
    ->makes('request', name: 'Store{name}Request', as: 'storeRequest', stub: 'crud')
    ->makes('request', name: 'Update{name}Request', as: 'updateRequest', stub: 'crud')
    ->makes('resource', name: '{name}Resource')
    ->makes('policy', name: '{name}Policy')
    ->makes('controller', name: '{name}Controller', stub: 'crud'));
bash
php artisan mod:crud Knowledge:Document --no-interaction
# -> app/Modules/Knowledge/Models/Document.php
# -> app/Modules/Knowledge/Database/Factories/DocumentFactory.php
# -> app/Modules/Knowledge/Http/Requests/StoreDocumentRequest.php
# -> app/Modules/Knowledge/Http/Requests/UpdateDocumentRequest.php
# -> app/Modules/Knowledge/Http/Resources/DocumentResource.php
# -> app/Modules/Knowledge/Policies/DocumentPolicy.php
# -> app/Modules/Knowledge/Http/Controllers/DocumentController.php

The plan includes all eight files, including the migration and factory requested by the model. Generated bases appear in the plan too. In a terminal, one confirmation accepts the whole plan; cancelling writes nothing. Companion output follows Laravel's native order, so a model's factory is printed before its migration.

Named Variants ​

stub: 'crud' selects stubs/mod.<type>.crud.stub. These request and controller variants refer to the recipe's sibling classes:

stubs/mod.request.crud.stub
php
<?php

namespace {{ namespace }};

use {{ model.fqcn }};
use Illuminate\Foundation\Http\FormRequest;

class {{ class }} extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', {{ model }}::class);
    }

    public function rules(): array
    {
        return [
            //
        ];
    }
}
stubs/mod.controller.crud.stub
php
<?php

namespace {{ namespace }};

use App\Http\Controllers\Controller;
use {{ model.fqcn }};
use {{ resource.fqcn }};
use {{ storeRequest.fqcn }};
use {{ updateRequest.fqcn }};

class {{ class }} extends Controller
{
    public function index()
    {
        return {{ resource }}::collection({{ model }}::paginate());
    }

    public function store({{ storeRequest }} $request)
    {
        return new {{ resource }}({{ model }}::create($request->validated()));
    }

    public function show({{ model }} ${{ model.camel }})
    {
        return new {{ resource }}(${{ model.camel }});
    }

    public function update({{ updateRequest }} $request, {{ model }} ${{ model.camel }})
    {
        ${{ model.camel }}->update($request->validated());

        return new {{ resource }}(${{ model.camel }});
    }
}

A missing variant offers to create it in a terminal, with publication held until the plan is accepted. Without interaction, create the named file first, for example from the file type's published stub.

Aliases and Name Forms ​

as: names a member; without it, the file type id is its alias. {{ model }} is the short class name and {{ model.fqcn }} is the full name. Every member's stub can use sibling aliases, including aliases for companion files. A generator template can be a member, with slot values forwarded through its options:.

The name: pattern uses {name} and question tokens. Stub name forms chain left to right: {{ name.plural.kebab }} pluralizes, then converts to kebab case. Literal braces surround a placeholder without an escape: {{{ model.camel }}} gives {widget}. {{ tabs.array }} renders a PHP array literal. Stubs do not gain loops or conditionals.

Including Another Recipe ​

Reuse a recipe, then add members or replace an included member by its alias:

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

Mod::scaffold('audited-crud', fn (Scaffold $s) => $s
    ->include('crud')
    ->makes('job', name: 'Record{name}', as: 'audit'));

include() copies questions, members, parts and repetitions. A later part() replaces an inherited part of the same name. Include cycles disable the recipe with a diagnostic naming the chain. A duplicate alias declared twice in one recipe is an error; replacement applies to included members.

Layout Overrides ​

A layout can override a global recipe and include its original definition:

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

Mod::layout('ddd')->generates('resource', in: 'application:{domain}/Http/Resources')
    ->scaffolds('crud', fn (Scaffold $s) => $s
        ->include('crud')
        ->makes('dto', name: '{name}Data')
        ->makes('view-model', name: 'Show{name}ViewModel'));

This explicitly moves resources into the application layer. Without that override, DDD resources remain in the domain layer, alongside DTOs and view models.

Recipe Classes ​

An invokable class declares a string $name and receives the same builder:

app/Scaffolds/DocumentScaffold.php
php
<?php

namespace App\Scaffolds;

use Tey\Mod\Scaffolds\Scaffold;

class DocumentScaffold
{
    public string $name = 'document';

    public function __invoke(Scaffold $s): void
    {
        $s->makes('model')->makes('controller', name: '{name}Controller');
    }
}
app/Providers/AppServiceProvider.phpboot()
php
use App\Scaffolds\DocumentScaffold;
use Tey\Mod\Facades\Mod;
use Tey\Mod\Scaffolds\Scaffold;

Mod::scaffolds([DocumentScaffold::class]);

Plugins shows package registration. App recipes win over package recipes; two packages with the same name disable that scaffold with a warning naming both. mod:list shows the effective source.

Collisions ​

Mod checks all files and inserts before writing. Choose whether to keep or overwrite existing members:

bash
php artisan mod:crud Knowledge:Document --skip-existing --no-interaction
php artisan mod:crud Knowledge:Document --force --no-interaction

--skip-existing keeps existing files and writes the rest. --force overwrites members, but generated bases are never overwritten. A generation-time failure rolls the subtree back.

Asking Questions ​

asks() adds a question and its command option. The types are text, list, choice, confirm, model and class. A default can use {name}. A model or class answer supplies its short name and .fqcn.

A list prompt accepts comma-separated text; scripts can use --tabs=Overview,Details or repeated --tabs=Overview --tabs=Details. Confirms take --flag or --no-flag. Without a terminal, mod uses a declared default or exits naming the option that supplies the missing answer.

Parts: Scaffolds Inside Scaffolds ​

A tab page can run independently, or become a repeated child of a resource. Create the templates below and the routes file in Registering routes, then register:

app/Providers/AppServiceProvider.phpboot()
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', label: 'Which base view model does the page extend?')
    ->asks('tab', type: 'text', label: 'Which tab is this?')
    ->makes('view-model', name: '{name}{tab}ViewModel', as: 'page', stub: 'tab-page'));

Mod::scaffold('resource-tabs', fn (Scaffold $s) => $s
    ->asks('model', type: 'model', default: '{name}', label: 'Which model do the tabs manage?')
    ->asks('tabs', type: 'list', default: ['Overview', 'Details', 'Notes'], label: 'Which tabs?')
    ->makes('view-model', name: 'Manage{name}ViewModel', as: 'base', stub: 'tabs-layout')
    ->makes('controller', name: '{name}Controller', stub: 'tabs')
    ->part('tab', uses: 'tab-page', with: ['base' => '{{ base.fqcn }}'],
        configure: fn (Part $p) => $p
            ->inserts(into: 'base', at: 'tabs', stub: 'tabs-entry')
            ->inserts(into: 'controller', at: 'actions', stub: 'tabs-action')
            ->inserts(into: '@module/routes/web.php', at: 'routes', stub: 'tab-route'))
    ->each('tabs', part: 'tab'));

With an existing Inventory:Widget model:

bash
php artisan mod:resource-tabs Inventory:Widget --tabs=Overview,Details,Notes --no-interaction
# -> app/Modules/Inventory/ViewModels/ManageWidgetViewModel.php
# -> app/Modules/Inventory/ViewModels/WidgetOverviewViewModel.php
# -> app/Modules/Inventory/ViewModels/WidgetDetailsViewModel.php
# -> app/Modules/Inventory/ViewModels/WidgetNotesViewModel.php
# -> app/Modules/Inventory/Http/Controllers/WidgetController.php

Values go down through with: and questions. Results come up through aliases: tab.page inside a part's inserts, or tab.History.page outside. Each node's inserts can edit its own member aliases; a descendant cannot edit a grandparent's aliases. Inline parts can declare their own members, including templates with slot options. Use configure: for a closure after named arguments, or a positional closure for an inline part.

Templates for the Tree ​

The layout view model owns navigation entries. Each page owns its title. Three generated page view models therefore produce three navigation entries in this recipe; a recipe that omits a navigation insert can have a different count. The controller template calls inertia(), so this recipe requires an Inertia app. Add matching pages as frontend scaffold members.

stubs/mod.view-model.tabs-layout.stub
php
<?php

namespace {{ namespace }};

use {{ model.fqcn }};
{{ baseImport }}
abstract class {{ class }}{{ extends }}
{
    public function __construct(protected {{ model }} ${{ model.camel }}) {}

    public function layout(): array
    {
        return [
            'tabs' => [
                // mod:tabs
            ],
        ];
    }

    abstract public function title(): string;
}
stubs/mod.view-model.tab-page.stub
php
<?php

namespace {{ namespace }};

use {{ base.fqcn }};

class {{ class }} extends {{ base }}
{
    public function title(): string
    {
        return '{{ tab }}';
    }
}
stubs/mod.controller.tabs.stub
php
<?php

namespace {{ namespace }};

use App\Http\Controllers\Controller;
use {{ model.fqcn }};

class {{ class }} extends Controller
{
    // mod:actions
}
stubs/mod.insert.tabs-entry.stub
php
                ['label' => '{{ tab }}', 'route' => '{{ name.kebab }}.{{ tab.kebab }}'],
stubs/mod.insert.tabs-action.stub
php
    public function {{ tab.camel }}({{ model }} ${{ model.camel }})
    {
        return inertia('{{ name }}/{{ tab }}', new \{{ tab.page.fqcn }}(${{ model.camel }}));
    }

Growing a Cluster Later ​

Add a child using the same planner as each():

bash
php artisan mod:resource-tabs.tab Inventory:Widget History --no-interaction
# -> app/Modules/Inventory/ViewModels/WidgetHistoryViewModel.php

Mod finds the cluster from deterministic names, without a state file. Creating three tabs at once is byte-identical to creating two and growing the third. A terminal shows every file and every insert before one confirmation. Missing anchors, duplicate outputs and repeated inline inserts refuse before writing.

If the cluster is missing, a terminal offers to create the root first; a non-interactive run names the root command to run. A leaf such as mod:tab-page also runs on its own when its --base and --tab answers are supplied.

Registering Routes ​

An insert stub is stubs/mod.insert.<name>.stub. The tree above uses this route insert:

stubs/mod.insert.tab-route.stub
php
    Route::get('{{ name.plural.kebab }}/{{{ model.camel }}}/{{ tab.kebab }}', [\{{ controller.fqcn }}::class, '{{ tab.camel }}'])->name('{{ name.kebab }}.{{ tab.kebab }}');

Start with an anchored routes file:

app/Modules/Inventory/routes/web.php
php
<?php

use Illuminate\Support\Facades\Route;

Route::middleware('web')->group(function () {
    // mod:routes
});

Load it from a module provider, which discovery registers:

app/Modules/Inventory/Providers/InventoryServiceProvider.php
php
<?php

namespace App\Modules\Inventory\Providers;

use Illuminate\Support\ServiceProvider;

class InventoryServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $this->loadRoutesFrom(__DIR__.'/../routes/web.php');
    }
}

Mod does not discover route files automatically. A routes file is the only file mod offers to start or add a missing anchor to; those changes wait for the final confirmation. Without interaction, prepare the file and anchor first.

Inserts go immediately before the retained mod:<anchor> marker. Markers match literally in //, #, Blade and HTML comments. Keep anchors in every base variant. Inserts use full class names; they do not edit imports or parse PHP. Existing CRLF files retain their line endings, anchor indentation and trailing whitespace. Stub indentation and blank lines are preserved.

Overriding One Part ​

Replace one node for creation and growth:

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

Mod::scaffold('resource-tabs.tab', fn (Part $p) => $p
    ->uses('tab-page', with: ['base' => '{{ base.fqcn }}'])
    ->makes('job', name: 'Log{tab}', as: 'audit')
    ->inserts(into: 'base', at: 'tabs', stub: 'tabs-entry'));

The parent recipe must exist. Dotted names name overrides, not new root recipes. mod:list shows the finite tree with Uses and From columns; JSON exposes children, references and effective provenance.

Recursive Scaffolds ​

A reference stays finite in the registry and expands only when invoked. This section recipe can grow under itself:

stubs/mod.view-model.section.stub
php
<?php
namespace {{ namespace }};
class {{ class }} { public function children(): array { return [
// mod:children
]; } }
stubs/mod.insert.section-child.stub
php
'{{ section.page.fqcn }}',
app/Providers/AppServiceProvider.php
php
use Tey\Mod\Facades\Mod;
use Tey\Mod\Scaffolds\Part;
use Tey\Mod\Scaffolds\Scaffold;

Mod::scaffold('section', fn (Scaffold $s) => $s
    ->makes('view-model', name: '{name}SectionViewModel', as: 'page', stub: 'section')
    ->part('section', uses: 'section', configure: fn (Part $p) => $p
        ->inserts(into: 'page', at: 'children', stub: 'section-child')));
bash
php artisan mod:section Docs:Guide --no-interaction
php artisan mod:section.section Docs:Guide Install --no-interaction
php artisan mod:section.section Docs:Guide Install/Requirements --no-interaction
# -> app/Modules/Docs/ViewModels/Guide/Install/RequirementsSectionViewModel.php

The deep class is named RequirementsSectionViewModel, with a namespace matching its folders. The recursion limit is 10. Deeper dot paths accept slash-separated ancestor inputs, such as mod:nested.group.item Inventory:Widget First/History.

Writing a Reusable Recipe ​

Record required packages, import aliases, model base settings and variant files alongside the recipe. If a house variant calls a query-builder package, record it as a dependency of the recipe. Keep authorization and validation specific to the application.

Members support PHP classes and migrations. Choose the view model shape your app already uses: a layout with tabs, a request-based section index, or a public tabs() method with links. Keep page count and navigation count explicit when adapting it.

Placing Individual Members ​

A member can use Laravel’s ungrouped placement while the rest stay in the selected module:

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

Mod::scaffold('typed-page', fn (Scaffold $s) => $s
    ->makes('view-model', name: '{name}PageViewModel', as: 'page')
    ->makes('interface', name: 'Support/PageData', ungrouped: true, as: 'contract', existing: 'keep'));

existing: 'keep' writes the contract once and retains it on later runs, including with --force.

A question can place one member in another group:

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

Mod::scaffold('admin-crud', fn (Scaffold $s) => $s
    ->asks('area', type: 'text', label: 'Which application module holds the admin pages?', default: 'Backoffice')
    ->makes('model')
    ->makes('controller', name: '{name}Controller', group: '{{ area }}'));
bash
php artisan mod:admin-crud Inventory:Widget --area=Backoffice --no-interaction

The member override leaves subsequent members in the scaffold’s default group. In JSON plans, group is the resolved group or null for ungrouped files; exists reports the current file and existing holds the policy. An ungrouped plain member can place a shared composable in the app’s resources/js/composables folder. Frontend Files covers page members and stack variants.

Vendor Scaffolds ​

Since 0.6, package recipes declare a vendor with Mod::vendor('acme')->scaffold(...). Run them as mod:acme/pages; declared parts use mod:acme/pages.form. Use full ids in includes, overrides and mod:rename --scaffold=acme/pages. App recipes retain their names. Shipping scaffolds covers registration, command names, provider pins and reconciliation notices.

Released under the MIT License. Created by Jasper Tey.