studeo / modular
Modular architecture and code generators for Laravel.
Requires
- php: ^8.4
- illuminate/console: ^13.0
- illuminate/filesystem: ^13.0
- illuminate/http: ^13.0
- illuminate/support: ^13.0
- illuminate/validation: ^13.0
- nikic/php-parser: ^5
- studeo/check: ^0.1
- symfony/yaml: ^8.1
Requires (Dev)
- inertiajs/inertia-laravel: ^3.0
- larastan/larastan: ^3.13
- laravel/pint: ^1.27
- livewire/livewire: ^4.0
- orchestra/testbench: ^11.0
- pestphp/pest: ^5.2
- pestphp/pest-plugin-laravel: ^5.0
Suggests
- inertiajs/inertia-laravel: Required by module:vue pages and forms and by --inertia controllers (^3.0): generated code renders pages with Inertia::render().
- livewire/livewire: Required by module:livewire (^4.0): generated components and Forms extend Livewire classes.
Provides
None
Conflicts
None
Replaces
None
README
A modular monolith for Laravel: modules under modules/, plain Composer PSR-4 autoloading, and generators that follow one architecture.
Status
Published for the maintainer's own use and offered as-is under the MIT license. The version line is 0.x: a minor release may break, so pin ^0.1. Issues and pull requests may go unanswered; forks are welcome. Security reports go through the private reporting route in SECURITY.md. The principles are in DESIGN.md and the behavioural contract in SPEC.md.
Table of Contents
- Status
- Overview
- Requirements
- Installation
- Quickstart
- Module Layout
- The Module Manifest
- Architecture in Brief
- Command Reference
- Running Without a Terminal
- Schemas
- Controllers and Inertia Pages
- Livewire 4
- Inertia 3 and Vue 3
- Architecture Checks: module:check
- The Module Cache
- Configuration
- More
- License
Overview
Studeo Modular slices a Laravel application into modules that live in modules/. A module is a directory with a module.json manifest and a service provider. It is not a Composer package: installing a module writes PSR-4 entries into your application's root composer.json, and classes load at normal Composer speed.
- Generators for models, migrations, factories, seeders, policies, Actions, data objects, enums, controllers, events, listeners, jobs, mail, notifications, middleware, commands, Livewire components and Vue pages, all written inside a module.
- Lifecycle commands to move or delete Livewire and Vue components across modules, with files, namespaces and empty directories handled.
- Schema files that drive the model, migration, factory, data objects, forms and Action preconditions from one definition.
module:check, an architecture checker that parses your module code and reports rule violations.
Requirements
PHP and Laravel versions are declared in composer.json. Livewire and Inertia with Vue are optional integrations; the package supports one major of each and the installers refuse an older one, writing nothing and printing the upgrade commands.
| Integration | Needed for |
|---|---|
| Livewire 4 | module:livewire and generated forms |
| Inertia 3 and Vue 3 | module:vue, and controllers with --inertia |
The package is developed and tested on Linux.
Installation
composer require studeo/modular
Optionally publish the configuration file:
php artisan vendor:publish --tag=modular-config
Quickstart
# 1. Create a module in modules/Billing php artisan module:make Billing # 2. Register its PSR-4 entries in the root composer.json and dump the autoloader php artisan module:install Billing # 3. Generate inside it php artisan module:model Invoice --module=Billing php artisan module:action CreateInvoice --module=Billing php artisan module:controller CreateInvoice --module=Billing --action=CreateInvoice --url=/invoices php artisan module:vue Invoices/Show --module=Billing --page php artisan module:livewire InvoiceHistory --module=Billing
module:install --all installs every module that is not registered yet. Run php artisan module:check --module=Billing at any point to check the module against the architecture rules.
Module Layout
modules/Billing/
├── module.json # manifest: name and namespace
├── BillingServiceProvider.php # registers the module through ModuleRegistrar
├── Actions/ # one invokable Action per operation
├── ApiResources/ # API resources
├── Commands/ # Artisan commands
├── Concerns/ Contracts/ Enums/ Events/ Exceptions/ Jobs/ Listeners/
├── Controllers/
├── Data/ # Input and Data objects
├── Livewire/
├── Mail/ Middleware/ Models/ Notifications/ Policies/ Rules/ Services/
├── config/billing.php
├── database/{factories,migrations,seeders}/
├── lang/
├── resources/{css,js,views}/
├── routes/web.php
├── schema/ # schema files, one per model
└── tests/
The layout is flat: PascalCase directories hold PHP classes and sit directly in the module root, lowercase directories hold other files, and the namespace matches the path. There is no src/, no Http/ and no Console/. module:make creates the directories it needs; generators create the others on demand.
The Module Manifest
module.json holds exactly two keys:
{
"name": "Billing",
"namespace": "Billing"
}
namemust match the module's directory name.namespaceis the module's root PHP namespace and is authoritative. With a root namespace prefix it readsStudeo\\Billing, for example.- Everything else is derived by convention: the provider is
{namespace}\{Name}ServiceProvider, and the module key used for config, views, translations and Livewire and Vue names is the kebab-case name (BlogPostsbecomesblog-posts).
Three things read the manifest:
module:install(andmodule:uninstall,module:remove) to decide which PSR-4 entries to write or remove.- Boot registration: the package's service provider registers every installed module's provider at boot.
- The module picker: commands that take
--moduleand are run without it list the modules found by discovery. With no modules they fail with a hint to runmodule:make; with exactly one they use it and say so; with several they ask. Directories with no usable manifest are never offered.
A module made by an older version has a composer.json instead of a module.json. That is a hard error, never a silent skip: the command prints the exact module.json to create, for example:
This module still uses a composer.json manifest, which is no longer read.
Create modules/Billing/module.json with:
{
"name": "Billing",
"namespace": "Billing"
}
A service provider that does not use ModuleRegistrar is also refused, with the replacement provider printed.
PSR-4 entries
module:install writes three entries per module into the root composer.json and runs composer dump-autoload:
{
"autoload": {
"psr-4": {
"Billing\\": "modules/Billing/",
"Billing\\Database\\Factories\\": "modules/Billing/database/factories/",
"Billing\\Database\\Seeders\\": "modules/Billing/database/seeders/"
}
}
}
No shared prefix entry is ever written. module:uninstall removes the entries without touching files; module:remove deletes the directory of a module that is not installed.
Architecture in Brief
Actions are final classes with a single __invoke. A generated Create{X} Action takes its Input and the acting user, authorizes create against the model, and does its work in DB::transaction:
final class CreateInvoiceAction { public function __invoke(CreateInvoiceInput $input, Authenticatable $actor): void { // authorization, then DB::transaction(...) } }
module:action --system generates an Action marked #[SystemAction] that takes only its Input and does no authorization. It may be called only from other Actions, Listeners and Jobs; forms and controllers refuse it.
Data objects are final readonly classes in Data/, and the name suffix decides the kind (module:dto refuses a bare name):
…InputextendsInputData: data entering the module. It declaresrules()and is built withfromRequest($request, $context)orfromValidated($payload, $context), which validate first. There is no publicfrom(). Properties marked#[Context]never come from the payload; they come only from the context array.…DataextendsOutputData: data leaving the module, built withfrom($source)orcollect($items).
Wire keys are snake_case; toArray() and JSON serialization use them. Data objects are never Livewire state.
Command Reference
The only single-letter shortcuts are -r (module:controller --resource), -p and -f (module:vue --page and --form), and -p on module:vue:delete and module:vue:move. Every other option is long-form.
Modules
| Command | Arguments / Options | Description |
|---|---|---|
module:make |
{module?} {--root-namespace=} |
Creates a module with its manifest and provider. --root-namespace overrides the configured prefix for this module; an empty value means no prefix. |
module:install |
{module?} {--all} |
Writes the module's PSR-4 entries to the root composer.json and dumps the autoloader. |
module:uninstall |
{module?} |
Removes the PSR-4 entries; files stay on disk. |
module:remove |
{module} {--force} |
Deletes the directory of an uninstalled module. |
module:check |
{--module=} {--all} {--format=text} |
Checks module code against the architecture rules. |
modular:cache, modular:clear |
Build and remove the module cache. |
Generators
Every generator takes an optional {name} and --module=.
| Command | Options | Output |
|---|---|---|
module:model |
--no-migration --no-factory --no-seeder --schema= |
Models/, plus a migration, factory and seeder by default |
module:action |
--system --schema= |
Actions/ and its paired Input in Data/ |
module:dto |
--schema= |
Data/; the name must end in Input or Data |
module:enum |
--cases= |
Enums/, string-backed |
module:schema |
--fields= |
schema/{table}.yaml |
module:schema:check |
{name?} --except= |
Compares generated files with their schema |
module:controller |
see Controllers | Controllers/ |
module:migration |
--create= --table= --blank --pivot --morph |
database/migrations/ |
module:factory, module:seeder |
--model= |
database/factories/, database/seeders/ |
module:policy |
--model= |
Policies/ |
module:pivot |
{model1?} {model2?} --table= --no-migration --morph |
Models/ and a migration |
module:resource |
--collection |
ApiResources/ |
module:rule |
--implicit |
Rules/ |
module:job |
--sync --batched |
Jobs/ |
module:event |
Events/ |
|
module:listener |
--event= --queued |
Listeners/ |
module:mail |
--markdown= --view= --queued |
Mail/ |
module:notification |
--markdown= --queued |
Notifications/ |
module:middleware |
Middleware/ |
|
module:command |
--command= |
Commands/ |
Livewire and Vue
| Command | Options |
|---|---|
module:livewire |
--classic --mfc --sfc --inline --page --layout= --title= --assets --no-test --schema= --action= --context=* |
module:livewire:move (alias :mv) |
{name?} {new-name?} --module= --to-module= --force |
module:livewire:delete (alias :rm) |
{name?} --module= --force |
module:vue (alias module:page) |
-p|--page -f|--form --schema= --action= --url= |
module:vue:move (aliases module:page:move, module:vue:mv) |
{name?} {new-name?} --module= --to-module= -p|--page --force |
module:vue:delete (aliases module:page:delete, module:vue:rm) |
{name?} --module= -p|--page --force |
Running Without a Terminal
A command that needs an answer only a prompt can give (a missing name or argument, a choice among several modules, a confirmation before something destructive) never prompts when there is no terminal on stdin or --no-interaction is set. It writes nothing, exits 1, and names the missing argument or option with one complete example:
Name must be given when the command is not interactive, e.g. php artisan module:action CreatePost --module=Blog.
module:removeneeds--forcewithout a terminal.module:livewire:moveandmodule:vue:moveneed[new-name]and--force.module:vueand the installer never write root-app files (vite.config.js,resources/js/app.js, the root Blade view, Composer or npm changes) without an explicit confirmation. Without a terminal they print the contents and instructions instead, and still generate the module component.- A value you give is never prompted for,
0included.
Schemas
A schema describes a model once. Commands that take --schema accept a CLI string, inline YAML or JSON, a file path, or @name, which reads modules/{Module}/schema/{name}.yaml. Text starting with { or [ is JSON, text with a newline is YAML, anything else is the CLI form:
php artisan module:schema Post --module=Blog --fields="title:string,slug:string:unique,author:belongsTo(User)"
php artisan module:model Post --module=Blog --schema=@posts
php artisan module:schema:check Post --module=Blog
The types and modifiers are a closed list; anything else is an error and nothing is written. All problems in a schema are reported together.
- Types:
string,char,text,mediumText,longText, the signed and unsigned integer family,boolean,decimal,float,double,date,dateTime,timestamp,json,jsonb,uuid,ulid,foreignId,foreignUuid,foreignUlid,enum. - Modifiers:
nullable,unique,index,primary,unsigned,constrained,cascadeOnDelete,nullOnDelete,default(x),cast(X),hashed,guarded,fillable,context. - Relationships:
belongsTo,hasOne,hasMany,belongsToMany. A target is a model of the same module orUser. - Enums are referenced by class name; create them with
module:enum.
module:schema scaffolds the file; after that the file is yours. module:schema:check compares the model, the create migration, the factory and the Input and Data classes with the schema and exits 0 when nothing differs, 1 on drift or a section it cannot verify, 2 when the schema is invalid or missing.
With --schema, a Create* Action also gets a private guard per unique field and per foreign key, which throw a ValidationException keyed by the column. Uniqueness and existence are Action preconditions, not validation rules.
Controllers and Inertia Pages
module:controller generates an invokable controller by default: one controller per Action.
| Option | Effect |
|---|---|
| (none) | Invokable controller. |
-r, --resource |
Resource controller. |
--api |
API resource controller. |
--standard |
Plain controller with no methods. |
--inertia |
Render Inertia pages instead of returning other responses. Valid on the default and on --resource; an error with --api or --standard. |
--model= |
With --resource or --api, a model in the module's Models/ that show, edit, update and destroy bind. |
--action= |
The Action an invokable controller calls, for example CreatePost. It must exist unless --schema is given. |
--schema= |
With --action, generates the Action and its Input when missing. |
--context=field=actor |
With --action, where a #[Context] field of the Input comes from. |
--url= |
With --action, the URL of the Route::post line the command prints. |
--resource, --api and --standard exclude one another. Controllers extend nothing, receive their Action through method injection, take the user from $request->user() (aborting with 401 when absent) and build the Input with fromRequest.
Route parameters. A bound model parameter is resolved by implicit binding, so the route parameter must match the controller parameter name: for a Post model, Route::get('/posts/{post}', ...). Implicit binding needs the SubstituteBindings middleware, which the web and api groups include.
Inertia page names. With --inertia, a controller renders Inertia::render('{Module}::{Resource}/{Method}'), where {Resource} is the controller name without Controller and {Method} is Index, Create, Show or Edit. A controller PostsController in module Blog renders Blog::Posts/Index. An invokable controller renders {Module}::{Resource}. module:vue prints the matching render call for the page it creates, Inertia::render('{Module}::{name}'); name the Vue page to match the controller. Page names use the module name (BlogPosts::Posts/Index), while Vue component and Livewire keys use the kebab module key.
Livewire 4
Components are registered at boot under the module key, in the dot form generated code uses:
<livewire:shop.order-summary />
module:livewire generates one of three shapes:
# Classic (default): Livewire/OrderSummary.php and resources/views/livewire/order-summary.blade.php php artisan module:livewire OrderSummary --module=Shop # Multi-file: Livewire/⚡checkout-flow/ holding checkout-flow.php, checkout-flow.blade.php and checkout-flow.test.php php artisan module:livewire CheckoutFlow --module=Shop --mfc # Single-file: Livewire/⚡quick-search.blade.php php artisan module:livewire QuickSearch --module=Shop --sfc
A test is generated for every component type unless --no-test is given (a multi-file component keeps it in its own directory); --assets adds empty .css and .js files to a multi-file component.
With --schema the component is a single-file form that delegates its rules to the Action's paired Input and saves through an Action (--action=, generated when missing, with --context=field=actor for #[Context] fields). Livewire state is plain snake_case values, never data objects:
php artisan module:livewire Posts/Create --module=Blog --schema=@posts --action=CreatePost
Inertia 3 and Vue 3
module:vue writes Vue 3 <script setup> files in the module's resources/js/:
php artisan module:vue PostCard --module=Blog # resources/js/Components/PostCard.vue php artisan module:vue Posts/Index --module=Blog --page # resources/js/Pages/Posts/Index.vue php artisan module:vue Posts/Create --module=Blog --schema=@posts --action=CreatePost --url=/posts
--schema implies a form page and requires --action and --url; --action and --url without a form are an error. The form uses useForm(), validates on the server only, and shows json fields as JSON text.
Page path. Inertia 3's default page path is resource_path('js/pages') (lowercase). Modules keep their pages in resources/js/Pages (capital P). The generated resources/js/app.js resolver loads module pages explicitly, so this only matters if you enable Inertia's page existence check or write your own resolver: point it at modules/*/resources/js/Pages.
The installer needs Inertia 3 on both sides. Before it changes anything in your application (composer require, npm install, vite.config.js, resources/js/app.js, the root view) it asks for confirmation; without a terminal it prints instructions and leaves your files alone. A custom app.js or Vite configuration is never overwritten. The resolver it generates handles module pages by the Module::Page form:
resolve: (name) => { if (name.includes('::')) { const [module, page] = name.split('::'); return resolvePageComponent( `../../modules/${module}/resources/js/Pages/${page}.vue`, import.meta.glob('../../modules/**/resources/js/Pages/**/*.vue') ); } return resolvePageComponent(`./Pages/${name}.vue`, import.meta.glob('./Pages/**/*.vue')); },
Vue enum fields are text inputs for now.
Architecture Checks: module:check
module:check parses the PHP files of a module and reports violations of the architecture rules. It never loads or runs module code. The rule engine and the generic rules come from the studeo/check package that Modular depends on.
php artisan module:check --module=Billing php artisan module:check --all php artisan module:check --all --format=json
Text output is one line per finding, path:line [rule] message, each message with a fix hint. --format=json prints only the findings array on standard output, as objects with rule, file, line, subject, severity and message.
| Exit code | Meaning |
|---|---|
| 0 | No errors. Warnings never fail the run. |
| 1 | At least one error. |
| 2 | A module could not be read, a directory was skipped under --all, the rule configuration is invalid, --module was combined with --all, --format is unknown, or the check itself failed. |
The default rules are action-authorization, action-shape, condition-extraction, controller-shape, dto-shape, file-length, flat-modules, method-complexity, method-naming, migration, module-isolation, nesting-depth, no-dto-in-livewire-state, step-count and system-action-usage.
To change a limit or add rules, extend Studeo\Modular\Check\DefaultRules, return your own Studeo\Check\Engine\RuleSet from rules(), and name your class in config/modular.php:
'check' => [ 'rules' => App\Architecture\Rules::class, ],
The class is built only by module:check. There is one rule set for the whole application. There is no baseline for application modules.
The Module Cache
At boot each module's registrar scans the module for listeners, policies and middleware. modular:cache writes what it finds to bootstrap/cache/modular.php, so production boots skip the scan:
php artisan modular:cache # write the cache php artisan modular:clear # remove it
Both are hooked into php artisan optimize and optimize:clear, so a deploy that runs optimize builds the cache. If the cache cannot be written or a module fails to load, optimize shows the failure, marks the step as failed and exits 1. modular:cache writes nothing, prints each failure and exits 1 when an installed module fails to load or two modules claim the same middleware alias. Entries are never checked against the module's files: after adding or removing a listener, policy or middleware, run modular:cache again (or modular:clear). A missing, corrupt or unrecognised cache file is ignored and the registrar scans live.
Long-lived workers (Octane, queue workers) read the cache once per process. Restart them after modular:cache or modular:clear.
Configuration
config/modular.php:
return [ // Where modules live. 'root_path' => base_path('modules'), // Namespace prefix of every module made from now on. null means no prefix // (module Account is "Account"); 'Studeo' gives "Studeo\Account". 'root_namespace' => null, // The class module:check asks for its rules. 'check' => [ 'rules' => DefaultRules::class, ], ];
module:make --root-namespace=Acme overrides the prefix for one module, and --root-namespace= (empty) means no prefix. The namespace is written to the module's module.json when the module is made and that file is what every other command reads, so changing the setting later does not move existing modules. The first segment may not be App, Database, Tests, Illuminate, Livewire, Inertia, Laravel, Symfony, Composer, PHPUnit or Pest.
More
DESIGN.md: principles and non-goals.SPEC.md: the manifest, layout, command and exit-code contract.API.md: public classes and extension points.CONTRIBUTING.md: the contribution stance.
License
MIT.