synerdy / modsx
Convention-based modules for Laravel: organise, back up, version and restore parts of your application using a directory-name prefix.
Requires
- php: ^8.3
- composer-runtime-api: ^2.0
- ext-zip: *
- illuminate/console: ^12.0 || ^13.0
- illuminate/filesystem: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
- laravel/prompts: ^0.3 || ^1.0
- symfony/finder: ^7.0 || ^8.0
Requires (Dev)
- larastan/larastan: ^3.10
- laravel/pint: ^1.18
- league/commonmark: ^2.8
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^3.7
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Organise a Laravel application into modules using nothing but a directory-naming convention — then back them up, version them and restore them from the command line.
📖 Full documentation — this README with sidebar navigation.
Read this in another language: Polski
The idea
Most Laravel module packages ask you to restructure your application: a separate source tree, a service provider per module, custom autoloading, their own routing and view namespaces. That is a lot of machinery to adopt, and a lot to unwind if you change your mind.
Modsx takes the opposite approach. A module is just a set of directories that share a name. You create them yourself, in the places Laravel already puts things:
resources/views/modsx-blog/
app/Http/Controllers/ModsxBlog/
That is a module. It works immediately — Laravel resolves those views and controllers exactly as it always has, because nothing about the framework has changed. No provider, no namespace registration, no autoload rules.
This package doesn't create that structure and plays no part in running it. It only finds it and manages it: backup, versioning, restore, removal.
Three things follow from this:
- You can adopt the convention without installing anything. Start prefixing directories today; install the package the day you actually want backups.
- You can uninstall it and lose nothing. Remove the package and your modules keep working — they were never anything but ordinary Laravel directories.
- It composes with the rest of the ecosystem. Livewire, Filament, Inertia, Folio — anything that reads from
app/,resources/orroutes/sees ordinary directories, because that is what they are.
The trade-off is honest: this is not a package manager. It does not resolve dependencies between modules, does not manage Composer requirements, and does not touch your database. See Limitations.
Requirements
| Requirement | Version |
|---|---|
| PHP | 8.3+ |
| Laravel | 12.x, 13.x |
Installation
composer require --dev synerdy/modsx
--dev, because Modsx is tooling: outside an artisan command it does nothing at all — the service provider returns immediately unless the application is running in the console, and no part of your application ever calls into this package. The convention keeps working with Modsx uninstalled; that is the whole point of it.
Install it into require instead if you run modsx:* where dev dependencies are absent — a deploy script that backs a module up before it changes, or CI that runs modsx:doctor after composer install --no-dev.
The service provider is auto-discovered. To change anything, publish the config:
php artisan vendor:publish --tag=modsx-config
Backups are written to modsx-backups/ in your project root. You almost certainly want them out of version control:
# .gitignore /modsx-backups
Committing them instead is a legitimate choice if you want module versions to travel with the repository — just be aware that a backup is a full directory copy, so the repo will grow with every one.
Upgrading
While Modsx is on 0.x, a new minor does not arrive with a plain composer update. Ask for it by name:
composer require --dev synerdy/modsx:^0.7
Composer treats anything below 1.0.0 with pre-release caution: there, ^0.6.1 means >=0.6.1 <0.7.0, putting the minor where the major normally sits. So composer update stays on the minor you installed — by design, not by accident — and composer why-not synerdy/modsx 0.7.0 will tell you as much. Requiring the new minor rewrites the constraint and updates in one step.
Worth reading the changelog first: on 0.x a minor is allowed to carry a breaking change, and they are called out there when it does.
Trying the 1.0 beta
The commands marked 1.0 in the table below are not in 0.7.0. They are in a prerelease, which Composer will not install unless you say so:
composer require --dev synerdy/modsx:1.0.0-beta.1 # this exact beta
Naming a version with a suffix lifts the stability filter for that package on its own — there is no need to touch minimum-stability. To follow every 1.0 prerelease instead of pinning one, put this in composer.json and run composer update synerdy/modsx:
"require-dev": { "synerdy/modsx": "^1.0@beta" }
A plain composer require synerdy/modsx still resolves to the newest stable release, so nobody gets a beta by accident. Going back is the same command with a stable constraint:
composer require --dev synerdy/modsx:^0.7
It is a beta because two things it writes — modsx-state.json and _snapshots/*.json — have never been used outside its own tests. If either turns out to need a different shape, changing it now costs nothing; after 1.0.0 it costs a migration path or a major version. Treat the backup tree it writes as something you may be asked to delete and recreate.
Naming convention
This is the one section worth reading carefully. Everything else follows from it.
A module has a single canonical name in StudlyCase:
Blog UserProfile AdminPanel
Every other form is derived from that one name, and Modsx matches all of them:
| Where | Form | Blog |
UserProfile |
|---|---|---|---|
Directories under resources/, public/, lang/ |
modsx- + kebab-case |
modsx-blog |
modsx-user-profile |
PHP namespace directories under app/, database/, tests/ |
Modsx + StudlyCase |
ModsxBlog |
ModsxUserProfile |
Single files — routes/, config/, lang/ |
modsx- + kebab-case |
modsx-blog.php |
modsx-user-profile.php |
| Migration filenames, after the timestamp | modsx_ + snake_case |
modsx_blog_… |
modsx_user_profile_… |
The first two are Laravel's own convention, not an invention of this package — the framework maps App\View\Components\UserProfile to <x-user-profile> using exactly the same StudlyCase ↔ kebab-case conversion. With the prefix it works the same way: App\View\Components\ModsxUserProfile\PostCard is <x-modsx-user-profile.post-card>. If your directories already follow Laravel's naming, they already follow this one.
The split between the two forms is not cosmetic. Directories under app/, database/ and tests/ are PSR-4 namespace segments, and a PHP identifier cannot contain a hyphen — App\Support\modsx-blog is not a name PHP will ever load. Everywhere else the name is just a path, so kebab-case applies.
Every form must come from the same name.
modsx-userprofileandModsxUserProfileare two different modules: the first isUserprofile, the second isUserProfile. Back upUserProfileand themodsx-userprofileviews are silently left behind.Write the name in StudlyCase first, then convert:
UserProfile→user-profile, neveruserprofile. Or letphp artisan modsx:scaffold UserProfileandphp artisan modsx:makewrite them for you, which is the only way to be sure they agree. If you suspect you've already made this mistake somewhere,php artisan modsx:doctorwill find it.
The modsx prefix itself is configurable, so if you prefer mod- or your company's initials, change it once and the same rules apply.
Which names belong to a module
One rule: a name identifies a module. The same way in a directory, in a file, and in a migration. modsx-blog is Blog's; modsx-blog-post is a different name, so it is a different module.
Directories — either form of the name:
| Path | Module | Why |
|---|---|---|
app/Models/ModsxBlog/ |
Blog | StudlyCase form |
resources/views/modsx-blog/ |
Blog | kebab-case form |
resources/views/modsx-blog-post/ |
BlogPost | a different name is a different module |
resources/views/modsx-blogging/ |
Blogging | likewise |
Single files — the name up to the first dot, exactly:
| Path | Module | Why |
|---|---|---|
config/modsx-blog.php |
Blog | the name, exactly |
routes/modsx-blog.php |
Blog | any scanned path |
lang/en/modsx-blog.php, lang/pl/modsx-blog.php |
Blog | every locale |
public/modsx-blog.css, public/modsx-blog.min.js |
Blog | any extension, cut at the first dot |
config/modsx-blog-post.php |
BlogPost | not Blog — same as the directory |
config/modsx-blog-admin.php |
BlogAdmin | names a module; unclaimed if there isn't one |
config/blog-modsx.php |
none | the prefix is not at the front |
app/Support/ModsxBlog.php |
none | classes live in the module's directory — app/Support/ModsxBlog/ChangeFormat.php |
Because module names are unique, at most one module can ever match a file — two modules cannot both claim one.
The single-file form is for the places Laravel expects one file per concern — routes/, config/, lang/. Everywhere else a module is a directory: views in resources/views/modsx-blog/, source assets in resources/css/modsx-blog/ and resources/js/modsx-blog/, classes in app/Support/ModsxBlog/. That is what modsx:scaffold creates, and it is the form to reach for whenever you are unsure — a directory is matched by its exact name and holds as much as you like. Even config/modsx-blog/settings.php works, read by Laravel as config('modsx-blog.settings').
Migrations — the module first, then the ordinary Laravel name:
| Name after the timestamp | Module | Why |
|---|---|---|
modsx_blog_create_posts_table |
Blog | |
modsx_blog_add_slug_to_posts_table |
Blog | |
modsx_blog_post_create_comments_table |
BlogPost, or Blog if BlogPost doesn't exist | the longer name wins |
modsx_blogging_create_x_table |
Blogging | |
create_modsx_blog_posts_table |
none | modsx:doctor reports it with the name to use |
A migration is the one thing that cannot be named for its module and nothing else — every migration needs its own name — so this is the only place where a longer module name takes precedence.
Blog and BlogPost side by side is a supported layout:
app/Models/ModsxBlog/ Blog
app/Models/ModsxBlogPost/ BlogPost
config/modsx-blog.php Blog
config/modsx-blog-post.php BlogPost
..._modsx_blog_create_posts_table.php Blog
..._modsx_blog_post_create_comments_table.php BlogPost
modsx:delete Blog touches the first column only.
What belongs to a module
| Backed up | Restored | Removed by modsx:delete |
|
|---|---|---|---|
Directories (ModsxBlog/, modsx-blog/) |
yes | yes | yes |
Files (routes/modsx-blog.php, …) |
yes | yes | yes |
| Migrations | archived only | no | no |
Migrations are the deliberate exception. Modsx never touches the database, so putting an old migration file back while the schema has moved on would leave your repository and your database disagreeing with nothing to say so. Deleting one while its tables still exist would be worse. So they are copied into every backup for reference — you can always read what the schema used to look like — and otherwise left exactly where they are.
Example layout
app/
├── Http/Controllers/ModsxBlog/
│ ├── PostController.php
│ └── CategoryController.php
├── Livewire/ModsxBlog/
│ └── PostList.php
├── Models/ModsxBlog/
│ └── Post.php
└── Services/ModsxBlog/
└── PostPublisher.php
resources/
├── views/modsx-blog/
│ ├── index.blade.php
│ └── show.blade.php
├── views/components/modsx-blog/
│ └── post-card.blade.php
├── css/modsx-blog/
│ └── blog.css
└── js/modsx-blog/
└── editor.js
routes/modsx-blog.php
config/modsx-blog.php
database/migrations/2026_01_01_000000_modsx_blog_create_posts_table.php
None of these is mandatory. A module can be a single view folder.
Livewire
Livewire 3 and 4 work without special handling, because Livewire discovers components by directory:
app/Livewire/ModsxBlog/PostList.php → <livewire:modsx-blog.post-list />
resources/views/livewire/modsx-blog/post-list.blade.php
Livewire 4 single-file components live under resources/views/components/, so the same prefix applies:
resources/views/components/modsx-blog/post-list.blade.php
There is nothing Livewire-specific in this package — it sees ordinary directories, which is precisely why it keeps working across Livewire versions.
Commands
Run any command without arguments and it will prompt you, with a picker for existing names rather than free-text entry.
| Command | Purpose |
|---|---|
modsx:make {generator} {Module/Name} |
Run one of Laravel's generators with the module filled in |
modsx:scaffold {name} {path?*} |
Create a module's directories, configured or named |
modsx:list |
Modules currently present in the application |
modsx:status {name?} |
1.0 Every module: what it is, what it came from, what has moved |
modsx:deps {name?} |
1.0 Which modules a module needs, worked out by reading it |
modsx:path {name?} |
Everything belonging to a module |
modsx:backup {name?} |
Copy a module to a new numbered version |
modsx:backuplist {name?} |
Available backup versions |
modsx:export {name?} {version?} |
Pack a backup version into a portable .zip |
modsx:import {path} |
Unpack a .zip exported by modsx:export |
modsx:delete {name?} |
Back up, then remove the module |
modsx:restore {name?} {version?} |
Back up the current state, then restore a version |
modsx:diff {name?} {version?} {against?} |
Compare against a backup version, or two versions with each other |
modsx:info {name?} |
Show size, file count, and backup history |
modsx:prune {name?} |
Remove old versions, keeping the newest |
modsx:snapshot {name?} |
1.0 Record the version every module is at, as one snapshot |
modsx:snapshotlist |
1.0 Snapshots that have been taken |
modsx:rollback {snapshot?} |
1.0 Put the whole project back to a snapshot |
modsx:snapshotprune |
1.0 Remove old snapshots, keeping the newest |
modsx:doctor |
Check for naming problems and orphaned backups |
Commands marked 1.0 are in the 1.0.0-beta.1 prerelease and not in the current stable release — see Trying the 1.0 beta.
modsx:make
Runs one of Laravel's own generators with the module written in for you.
php artisan modsx:make controller Blog/PostController php artisan modsx:make view blog.index php artisan modsx:make migration Blog/create_posts_table
This is php artisan make:* with the prefix cut out. The generator is Laravel's, the options are Laravel's, the output is Laravel's — the only thing Modsx does is work out the name:
| You type | It runs |
|---|---|
modsx:make controller Blog/PostController |
make:controller ModsxBlog/PostController |
modsx:make view blog.index |
make:view modsx-blog/index |
modsx:make config Blog/settings |
make:config modsx-blog-settings |
modsx:make migration Blog/create_posts_table |
make:migration modsx_blog_create_posts_table --create=posts |
Three forms of one name, a different one per generator, is the part of the convention that is easy to get subtly wrong — and getting it wrong makes two modules that read as one. Which form goes where is a table in config/modsx.php:
'generators' => [ '*' => '{Studly}/', // ModsxBlog/PostController 'view' => '{kebab}/', // modsx-blog/index 'config' => '{kebab}-', // modsx-blog-settings 'migration' => '{snake}_', // modsx_blog_create_posts_table ],
Anything not listed gets *, which is right for any PHP class.
Anything not listed gets *, and the generators you can run are whatever your application has registered, not a fixed list — a package's own is wrapped just as well as Laravel's.
Every Laravel generator, and the name it gets
Laravel ships three naming styles across its generators, and the table above covers all three. Written out in full, against Laravel 12/13:
PascalCase — into the module's namespace directory. These all fall under *:
| You type | It runs |
|---|---|
modsx:make cast Blog/MoneyCast |
make:cast ModsxBlog/MoneyCast |
modsx:make channel Blog/OrderChannel |
make:channel ModsxBlog/OrderChannel |
modsx:make class Blog/PaymentService |
make:class ModsxBlog/PaymentService |
modsx:make command Blog/SendEmails |
make:command ModsxBlog/SendEmails |
modsx:make component Blog/Alert |
make:component ModsxBlog/Alert |
modsx:make controller Blog/UserController |
make:controller ModsxBlog/UserController |
modsx:make enum Blog/OrderStatus |
make:enum ModsxBlog/OrderStatus |
modsx:make event Blog/OrderCreated |
make:event ModsxBlog/OrderCreated |
modsx:make exception Blog/PaymentException |
make:exception ModsxBlog/PaymentException |
modsx:make factory Blog/UserFactory |
make:factory ModsxBlog/UserFactory |
modsx:make interface Blog/PaymentGateway |
make:interface ModsxBlog/PaymentGateway |
modsx:make job Blog/ProcessOrder |
make:job ModsxBlog/ProcessOrder |
modsx:make job-middleware Blog/RateLimited |
make:job-middleware ModsxBlog/RateLimited |
modsx:make listener Blog/SendWelcomeEmail |
make:listener ModsxBlog/SendWelcomeEmail |
modsx:make mail Blog/OrderShipped |
make:mail ModsxBlog/OrderShipped |
modsx:make middleware Blog/Authenticate |
make:middleware ModsxBlog/Authenticate |
modsx:make model Blog/User |
make:model ModsxBlog/User |
modsx:make notification Blog/InvoicePaid |
make:notification ModsxBlog/InvoicePaid |
modsx:make observer Blog/UserObserver |
make:observer ModsxBlog/UserObserver |
modsx:make policy Blog/UserPolicy |
make:policy ModsxBlog/UserPolicy |
modsx:make provider Blog/AppServiceProvider |
make:provider ModsxBlog/AppServiceProvider |
modsx:make request Blog/StoreUserRequest |
make:request ModsxBlog/StoreUserRequest |
modsx:make resource Blog/UserResource |
make:resource ModsxBlog/UserResource |
modsx:make rule Blog/ValidPhoneNumber |
make:rule ModsxBlog/ValidPhoneNumber |
modsx:make scope Blog/PopularScope |
make:scope ModsxBlog/PopularScope |
modsx:make seeder Blog/UserSeeder |
make:seeder ModsxBlog/UserSeeder |
modsx:make test Blog/UserTest |
make:test ModsxBlog/UserTest |
modsx:make trait Blog/HasRoles |
make:trait ModsxBlog/HasRoles |
The three that differ:
| You type | It runs | Form |
|---|---|---|
modsx:make view blog.users.index |
make:view modsx-blog/users.index |
view path |
modsx:make config blog.services |
make:config modsx-blog-services |
kebab-case |
modsx:make migration blog.create_users_table |
make:migration modsx_blog_create_users_table --create=users |
snake_case |
Either separator, for every generator. The tables above pick whichever reads more naturally, but the module ends at the first /, \ or . whatever you are generating. These are the same call:
| With a slash | With a dot |
|---|---|
modsx:make config Blog/services |
modsx:make config blog.services |
modsx:make migration Blog/create_users_table |
modsx:make migration blog.create_users_table |
modsx:make controller Blog/UserController |
modsx:make controller Blog.UserController |
make:config is the one place Modsx departs from plain Laravel, where a config name is snake_case. It has to: config/modsx_blog_services.php would not be recognised as the module's file — the rule looks for the modsx- kebab prefix — so the config would be orphaned, backed up with nothing and removed with nothing. Kebab-case here is forced by the convention, not chosen.
Not module-scoped at all: make:cache-table, make:session-table, make:notifications-table, make:queue-table, make:queue-batches-table and make:queue-failed-table take no name — they generate a fixed framework migration. Pass one through modsx:make and Laravel answers "No arguments expected", which is the right answer: those tables belong to the application, not to a module.
Generators from other packages are on the same footing — the list is whatever your application has registered, and * is already the right form for a class:
| You type | It runs | What you get |
|---|---|---|
modsx:make livewire Blog/Alert |
make:livewire ModsxBlog/Alert |
app/Livewire/ModsxBlog/Alert.php and views/livewire/modsx-blog/alert.blade.php, so <livewire:modsx-blog.alert /> |
modsx:make filament-resource Blog/PostResource |
make:filament-resource ModsxBlog/PostResource |
app/Filament/Resources/ModsxBlog/PostResource.php |
Livewire derives its view path from where the class went, exactly as Laravel's own components do, so StudlyCase is all it needs from us. Add a modsx.generators entry only when * is wrong for a generator — when its name is a path or a filename rather than a class.
layout, page, partial
A module's own views live in resources/views/modsx-blog/, but its layout is one slice of the application's layouts/ — the framework's directory first, the module second, exactly as in resources/css/modsx-blog/. Three names of Modsx's own reach those:
php artisan modsx:make layout blog.app # -> resources/views/layouts/modsx-blog/app.blade.php php artisan modsx:make page blog.index # -> resources/views/pages/modsx-blog/index.blade.php php artisan modsx:make partial blog.head # -> resources/views/partials/modsx-blog/head.blade.php
There is no make:layout in Laravel. These are entries in the same config table, and what makes them different is that the entry names the generator to run as well as the form:
'generators' => [ 'view' => '{kebab}/', // runs make:view 'layout' => ['view', 'layouts/{kebab}/'], // also make:view, elsewhere 'page' => ['view', 'pages/{kebab}/'], 'partial' => ['view', 'partials/{kebab}/'], ],
So the names are yours to choose. 'service' => ['class', 'Services/{Studly}/'] gives you modsx:make service Blog/PostPublisher writing app/Services/ModsxBlog/PostPublisher.php, and it appears in the picker alongside Laravel's own.
There is deliberately no component: make:component is a generator of Laravel's and already lands correctly — it writes the class, and Laravel derives views/components/modsx-blog/ from where that class went.
The form applies to the whole name, not just the module. Type it however you like and the generator receives it in the form its entry names, converted segment by segment:
php artisan modsx:make view blog.PostList # -> modsx-blog/post-list php artisan modsx:make view blog.admin.PostList # -> modsx-blog/admin.post-list php artisan modsx:make config Blog/MailSettings # -> modsx-blog-mail-settings php artisan modsx:make migration Blog/CreatePostsTable # -> modsx_blog_create_posts_table php artisan modsx:make controller Blog/PostController # -> ModsxBlog/PostController, untouched
A {Studly} entry leaves the rest of the name alone, since a class name is already written the way the generator wants it.
The generator's options are written where you would write them anyway, and handed on untouched:
php artisan modsx:make controller Blog/PostController --resource --model=Post php artisan modsx:make model Blog/Post -fs php artisan modsx:make component blog.alert --view
modsx:make answers to exactly one option of its own, --dry-run. Everything else it does not recognise belongs to the generator and is passed through, which is why nothing needs declaring for make:livewire or any other package's.
A -- still works, and is the way to reach a generator option that collides with ours:
php artisan modsx:make controller Blog/PostController -- --dry-run
Separate the module with / or ., whichever the generator you are calling reads better with. A view name is written with dots in Laravel's own documentation, so write it that way here too — all lower case, exactly as you would type it to make:view:
php artisan modsx:make view blog.create # -> make:view modsx-blog/create php artisan modsx:make view blog.admin.index # -> make:view modsx-blog/admin.index php artisan modsx:make controller Blog/PostController
Only the first separator divides — a module name can contain neither — so the rest of the name keeps its own dots. The two forms are interchangeable, and so is the result: make:view turns dots into slashes itself, which is why modsx-blog/create is the same view as modsx-blog.create.
A backslash works too, but avoid it in a POSIX shell: an unquoted one is removed before Modsx ever sees it, so Blog\PostController arrives as BlogPostController. PowerShell escapes with a backtick, so there it survives.
--dry-run prints the command it would run and stops:
$ php artisan modsx:make migration Blog/create_posts_table --dry-run Would run: php artisan make:migration modsx_blog_create_posts_table --create=posts
That --create=posts is not decoration. Laravel guesses the table from the migration name with /^create_(\w+)_table$/, which modsx_blog_create_posts_table cannot match with the module in front, so without it every create-migration would come out as an empty stub. Modsx runs the guess against the part you actually wrote and passes the answer on.
If the module doesn't exist yet you are told so and asked once — defaulting to yes, with the closest existing name in case it was a typo. A non-interactive run warns and carries on: creating a file is not destructive, and this is the one command in the package that never is.
One thing it can't fix: make:model -m has Laravel name the migration, so it comes out as create_posts_table with no module prefix and does not belong to the module — it will not be backed up with it. Modsx warns when you do that. Generate the migration separately instead.
modsx:scaffold
Creates the directories for a new module. The convention works perfectly well without this command — you can make the directories by hand and never install anything — but typing both forms yourself is the one way to get it wrong. Here they come from a single name, so they cannot disagree.
php artisan modsx:scaffold Blog
php artisan modsx:scaffold user-profile # any case; it is normalised
The configured list
Which directories it creates is up to you, in config/modsx.php:
'scaffold' => [ 'app/Http/Controllers/{Studly}', 'app/Models/{Studly}', 'resources/views/{kebab}', ],
{Studly} becomes ModsxBlog, {kebab} becomes modsx-blog. Both come from the one name you typed.
The published config carries a longer list commented out — Livewire, services, form requests, factories, seeders, tests, resources/css/, resources/js/, components — so you uncomment what your modules have. The default stays short on purpose: a directory nobody fills in is invisible to git and gets reported by modsx:doctor, so a generous default would only make work for --fix.
Views follow the same shape as everything else — the framework's directory first, the module inside it, exactly like resources/css/modsx-blog/:
resources/views/
├── components/
│ ├── layouts/app.blade.php the application's
│ └── modsx-blog/card.blade.php Blog's -> <x-modsx-blog.card>
├── layouts/
│ ├── app.blade.php the application's
│ └── modsx-blog/ Blog's
├── partials/modsx-blog/ Blog's
└── modsx-blog/ Blog's own pages
The two live side by side: a starter kit's layouts/app.blade.php carries no prefix, so no module ever claims it, while layouts/modsx-blog/ is Blog's and travels with it. Modules are found at any depth, so nesting one level in costs nothing.
Note the direction. layouts/modsx-blog/ — not modsx-blog/layouts/. The module goes inside the framework's directory everywhere else in this convention, and views are no exception; inverting it here is what would make <x-modsx-blog.card> stop resolving.
Naming the directories yourself
Name directories after the module and it makes those instead of the configured list — for the one you want now, without changing what every future module gets:
php artisan modsx:scaffold Blog resources/css # resources/css/modsx-blog/ php artisan modsx:scaffold Blog resources/css resources/js app/Services # resources/css/modsx-blog/ # resources/js/modsx-blog/ # app/Services/ModsxBlog/
Note the third one: ModsxBlog, not modsx-blog. You write the path as it looks in the project and the form of the module's own directory is read off where that path leads:
| You type | It creates | Why |
|---|---|---|
resources/css |
resources/css/modsx-blog |
a path, so kebab-case |
resources/js |
resources/js/modsx-blog |
|
resources/views/layouts |
resources/views/layouts/modsx-blog |
|
public/vendor |
public/vendor/modsx-blog |
|
lang/en |
lang/en/modsx-blog |
|
app/Services |
app/Services/ModsxBlog |
app/ is PSR-4 |
app/Livewire |
app/Livewire/ModsxBlog |
|
database/factories |
database/factories/ModsxBlog |
database/ is PSR-4 |
tests/Feature |
tests/Feature/ModsxBlog |
tests/ is PSR-4 |
app/, database/ and tests/ are the PSR-4 roots of a stock Laravel application — App\, Database\, Tests\ — and a hyphen is not a legal PHP identifier, so directories under them take the StudlyCase form. Everywhere else the name is only ever a path. This is the convention's own rule, applied for you rather than invented here.
Where that guess is wrong — a PSR-4 root of your own, say — write the placeholder and it settles the form instead:
php artisan modsx:scaffold Blog "modules/Shared/{Studly}" # modules/Shared/ModsxBlog/ php artisan modsx:scaffold Blog "storage/exports/{kebab}" # storage/exports/modsx-blog/
A directory that already exists is reported and left alone, exactly as with the configured list, so running it twice is safe:
$ php artisan modsx:scaffold Blog resources/css resources/css/modsx-blog ...................................... created $ php artisan modsx:scaffold Blog resources/css resources/css/modsx-blog ................................ already existed
And a path cannot leave the project:
$ php artisan modsx:scaffold Blog ../../etc
ERROR Invalid path [../../etc]. Give a directory inside the project, such as
"resources/css" or "app/Services"; it may not contain "..".
It creates directories and nothing else — no controller stubs, no boilerplate. Generating code would make this a code generator, which is exactly what Modsx is not. It never overwrites anything either: directories that already exist are reported and left alone, so it is safe to re-run.
Note that git does not track empty directories, so a skeleton you never fill in quietly disappears at your next commit. That is the intended behaviour: the directories you actually use will have files in them. Until then it still sits on disk, though — php artisan modsx:doctor --fix finds and removes any of a module's directories left empty, so you don't have to hunt for them by hand.
modsx:list
php artisan modsx:list php artisan modsx:list --json
Module Directories Files Backups Latest
Blog 4 2 3 0003
UserProfile 2 - - -
A module appears here if any of its directories exists — a module is a set of directories, and that is what makes one. Files and migrations named for a module belong to it, and are counted in the columns above, but they do not bring one into being: a config/modsx-blog.php with no modsx-blog directory anywhere is reported by modsx:doctor as naming a module that does not exist, and modsx:backup Blog will say the same.
modsx:status
php artisan modsx:status php artisan modsx:status Blog php artisan modsx:status --json
Module State Current Latest backup Changes
Blog modified 0001 0002 1
Shop clean 0001 0001 0
Billing untracked - - -
Admin missing - 0001 -
The shape is the one version control already taught you. Current is the version the working tree came from, so Changes counts what has happened since — not the distance to the newest backup, which is a different question and gets its own column.
| State | Meaning |
|---|---|
clean |
In the application, identical to the version it came from |
modified |
In the application, differs from it — Changes says by how many files |
untracked |
In the application, never backed up |
missing |
Backups exist, but the module is not in the application |
A module can be clean and still be behind, and that combination is the one worth being told about:
WARN [Blog] is working from 0001, but 0002 exists. Backing up now would build the next version on the older one.
Where Current comes from
modsx:backup and modsx:restore record the version in modsx-backups/Blog/modsx-state.json. modsx:import deliberately records nothing: it adds a version to the backup tree without touching the application, so the working tree did not come from it.
This file never decides whether a module exists. Discovery stays what it has always been, a directory named by the convention. A module you made with mkdir has no record here and is reported untracked — the truthful answer rather than a gap. Delete every one of these files and the package behaves exactly as it did before they existed; only the Current column goes blank.
It sits beside the versions it points at because it means nothing without them. Prune the version it names and modsx:doctor reports it, while modsx:status carries on by measuring against the newest version instead.
If you commit your backup tree, keep this file out of it — which version your working copy came from is a local fact, not a shared one:
modsx-backups/*/modsx-state.json
modsx:deps
php artisan modsx:deps # every module php artisan modsx:deps Blog # one module, and what a snapshot of it would hold php artisan modsx:deps --json
INFO Blog
Media ......................... found in the code
User .......................... found in the code
INFO Media
needs nothing else
The graph is derived, not declared. A module called Media appears in other modules' code as ModsxMedia, modsx-media or modsx_media and in no other form, so a reference to it is something that can be found rather than something you have to remember to write down.
That is the whole argument for reading it rather than keeping a list. A hand-kept list of requirements is a list nothing checks: add a reference to another module, forget to update the list, and a snapshot built from it is quietly incomplete — which is worse than no snapshot, because it is trusted.
| Where it looks | What counts as a reference |
|---|---|
use App\Models\ModsxMedia\Asset; |
the Studly form, anywhere in PHP |
@include('modsx-media.player') |
the kebab form, in any file |
config('modsx-media.disk') |
the same form, in any call |
$table = 'modsx_media_assets'; |
the snake form, table names included |
A mention inside a comment or a string counts too. That is deliberate: the mistake it causes is a snapshot holding one module too many, which costs a directory, while the opposite mistake breaks a rollback.
Name boundaries are respected, so ModsxBlogPost is a reference to BlogPost and not also to Blog — the same rule that decides which module owns a file. The one exception is the snake form, where a suffix is ordinary: modsx_media_assets is Media's table, so an underscore is allowed to follow it.
Given modules Blog, BlogPost, Media and MediaAssets, this is what a line inside Blog resolves to:
| Line found in Blog | Edge to |
|---|---|
use App\Models\ModsxMedia\Asset; |
Media |
use App\Models\ModsxMediaAssets\Row; |
MediaAssets — not Media |
@include('modsx-media.player') |
Media |
@include('modsx-media-assets.row') |
MediaAssets — not Media |
view('modsx-blog-post.comment') |
BlogPost — not Blog |
config('modsx-media.disk') |
Media |
$table = 'modsx_media_assets'; |
Media and MediaAssets — the snake form allows a suffix |
// see also ModsxMedia |
Media — a comment counts |
use App\Models\ModsxBlog\Post; inside Blog itself |
nothing; a module never needs itself |
use App\Models\ModsxGhost\Thing; with no Ghost module |
nothing; only real modules can be edges |
What reading cannot see
A class name assembled from a string, a listener wired up somewhere else. Those go in configuration, which adds edges and never replaces the ones found in the code:
// config/modsx.php 'dependencies' => [ 'Blog' => ['Search'], ],
An edge found in both places is reported as found in the code — of the two claims, that is the one that can be pointed at.
Modules that depend on one another are listed rather than treated as a fault. A ring can be a deliberate design; the only consequence here is that a snapshot of any one of them holds all of them.
modsx:path
Shows exactly what Modsx considers part of a module — that is, exactly what a backup would copy. Worth running before your first modsx:delete.
php artisan modsx:path Blog
php artisan modsx:path # every module
php artisan modsx:path --json
Directories, files and migrations are listed separately, with migrations marked as archived so it is clear a restore will not put them back.
modsx:backup
Copies every directory belonging to the module into a new sequential version.
php artisan modsx:backup Blog php artisan modsx:backup Blog -m "before switching to repository pattern" php artisan modsx:backup --all # every module at once php artisan modsx:backup Blog --skip-unchanged # do nothing if nothing changed php artisan modsx:backup Blog --json
modsx-backups/
└── Blog/
├── 0001/
│ ├── modsx.json
│ ├── app/Http/Controllers/ModsxBlog/ ← restored
│ ├── routes/modsx-blog.php ← restored
│ └── _archive/
│ └── database/migrations/... ← kept for reference only
└── 0002/
└── ...
Version numbers come from the highest existing number, never from whatever the filesystem lists last, and the command refuses to write to a path that already exists. Versions are never overwritten and never reused.
Archived migrations sit in _archive/, apart from everything else. That is not a label — restore reads the manifest's list of paths and files and never looks anywhere else, so there is no flag anyone could set wrong.
-m/--comment attaches an optional free-text note to the version — entirely opt-in, there is no prompt for it. It shows up in modsx:backuplist and modsx:info.
--skip-unchanged compares the module against its newest version file by file and does nothing if they match, so a backup on every deploy doesn't fill the disk with identical copies. A changed migration doesn't count as a change here, since it isn't part of what a restore would put back.
Each version carries a modsx.json manifest recording the module name, creation time, the exact source paths and files, the archived migrations, the optional comment, and the PHP, Laravel and package versions in use. Restore reads it, so it puts things back where they came from rather than inferring their location.
The whole copy is assembled in a staging directory and moved into place at the end, so an interrupted backup leaves no half-written version behind.
Backing up two modules whose names differ only in letter case is refused. On Windows and macOS UserProfile and Userprofile are the same directory, so they would share one version sequence and a restore could hand back the wrong module. The refusal applies on every platform: behaviour that depends on the filesystem is worse than a consistent no.
modsx:backuplist
php artisan modsx:backuplist # every module php artisan modsx:backuplist Blog php artisan modsx:backuplist Blog --limit=5 # newest 5 php artisan modsx:backuplist --json
Blog
Version Created Directories Files Archived Comment
0001 2026-08-20T09:14:02+02:00 2 -
0002 2026-08-21T17:40:55+02:00 2 before switching to repository pattern
modsx:export
Packs one backup version into a portable .zip, written next to the version directory it came from.
php artisan modsx:export Blog # newest version php artisan modsx:export Blog 0003 # a specific version php artisan modsx:export # interactive
modsx-backups/
└── Blog/
├── 0001/
├── 0002/
└── Blog-0002.zip ← created by modsx:export
The zip is a derived, on-demand artifact, not a new version. Versions themselves stay unpacked directories, deliberately: open one in a file explorer or cd into it, and you see exactly what belongs to the module, instantly — no extracting, no tooling. modsx:export doesn't change that default; it adds a single-file form for the one thing unpacked directories are worse at — moving a version somewhere else. Re-running modsx:export on the same version overwrites its zip; there is no "already exists" guard here the way there is for a version itself. Pruning a version removes its zip along with it.
Where the zip ends up is not configurable — moving it anywhere else afterwards is a plain cp/mv, not something Modsx needs to know about.
modsx:import
Unpacks a .zip created by modsx:export back into the backup tree, at the module and version its own modsx.json names — this is how a module travels between projects as a single file instead of a directory tree.
php artisan modsx:import path/to/Blog-0002.zip
Refuses to import over a version that already exists, for the same reason modsx:backup refuses to overwrite one: a version, once written, is never silently replaced. After importing, restore it the normal way: php artisan modsx:restore Blog 0002.
modsx:delete
Backs up first, and removes nothing unless the backup succeeded.
php artisan modsx:delete Blog php artisan modsx:delete Blog --force # skip the prompt, for CI php artisan modsx:delete Blog --skip-backup # if you really mean it php artisan modsx:delete Blog --force --json
Everything that will be removed is listed before the confirmation prompt, and the version number created by the backup is printed, so you always know what to pass to modsx:restore.
Migrations are listed too — as kept. They stay in the application, because their tables are still in your database and deleting the file that documents them would leave the schema with nothing explaining it. modsx:doctor will later remind you they belong to a module that is gone.
modsx:restore
php artisan modsx:restore Blog # newest version php artisan modsx:restore Blog 0003 # a specific version php artisan modsx:restore # interactive php artisan modsx:restore Blog --json php artisan modsx:restore Blog 0003 --force # skip the confirmation, for scripts
The sequence is:
- Back up the module's current state, so the restore is itself reversible.
- Copy the chosen version out of the backup into a staging area.
- Move the entire current state aside, in one pass.
- Move the restored state into place.
Everything is copied out of the backup before the application is touched, so a corrupt or incomplete backup is discovered while the current state is still intact. And because step 3 moves the old state aside whole rather than deleting it path by path, a failure during step 4 is undone: you get back exactly what you had, not a half-restored mixture.
Anything the version did not contain is gone afterwards — moved aside and never put back. That is what restoring an exact state has to mean, and modsx:diff will tell you in advance what it covers.
That covers everything the module owns right now, not just what the version's manifest lists — otherwise the result would match no version at all. Say Blog 0001 was taken, and then you added these:
| Added after the backup | After modsx:restore Blog 0001 |
|---|---|
resources/views/modsx-blog/extra.blade.php |
gone — inside a directory the module owns |
routes/modsx-blog.php |
gone — a standalone file the module owns |
app/Models/ModsxBlog/Draft.php |
gone — same, in the Studly form |
config/unrelated.php |
untouched — belongs to no module |
config/modsx-shop.php |
untouched — belongs to Shop, not Blog |
The removal is only safe because it is never the last copy. Step 1 above backs the current state up first, so a file swept away by a restore is one version behind, not lost:
php artisan modsx:restore Blog 0001 --force # extra.blade.php disappears... php artisan modsx:backuplist Blog # ...into version 0002, taken just now php artisan modsx:restore Blog 0002 # and here it is again
The same holds for modsx:rollback, which takes a whole safety snapshot before it moves anything.
modsx:import is the exception that catches people out: it never touches the application. It only unpacks a zip into the backup tree, so nothing in your working copy changes until you restore.
Archived migrations are never restored. They are not read at this step at all.
If the module isn't currently in the application, steps 1 and 3 are skipped and this becomes an install from backup — which is how you move a module between projects: copy modsx-backups/Blog/ across and restore it.
modsx:prune
php artisan modsx:prune # every module, config default php artisan modsx:prune Blog --keep=5 php artisan modsx:prune --keep=3 --dry-run # show the plan, change nothing php artisan modsx:prune --dry-run --json # machine-readable plan, for CI php artisan modsx:prune Blog --keep=3 --force # skip the confirmation, for scripts
Lists exactly which versions would go, then asks. The newest version is never removed, whatever --keep is set to.
modsx:snapshot
php artisan modsx:snapshot # the whole project php artisan modsx:snapshot --comment="before the rewrite" php artisan modsx:snapshot Blog # Blog and everything it needs php artisan modsx:snapshot --json
Module Version
Blog 0002 backed up
Media 0001 unchanged
User 0001 unchanged
INFO Snapshot 0002 taken, holding 3 module(s).
A snapshot records which version of each module was current at one moment. It answers the question a per-module backup cannot:
I can't restore Blog from three weeks ago, because back then it depended on a different User.
It copies nothing. The versions it names are already in the backup tree, so what is written is a few hundred bytes of version numbers in modsx-backups/_snapshots/0002.json. Copying them would double the disk cost and give one version two places to live.
A module that has not changed since its last backup gets no new version, only another reference to the one it had — so a snapshot of an untouched project writes nothing but the snapshot. That is the point: a snapshot nobody minds taking is one that will be there when it is needed.
Naming a module snapshots that module and its dependency closure, worked out exactly as modsx:deps shows it.
modsx:snapshotlist
php artisan modsx:snapshotlist php artisan modsx:snapshotlist --limit=5 php artisan modsx:snapshotlist --json
Snapshot Created Scope Modules Comment
0001 2026-09-03T20:42:51+00:00 whole project 3 before the rewrite
0002 2026-09-03T20:42:52+00:00 whole project 3 -
0003 2026-09-03T20:43:01+00:00 Blog 2 -
A snapshot shown in red names a version that is no longer in the backup tree, and can no longer be rolled back to. modsx:prune will not cause this — it holds those versions back — so it means the backup tree was edited by hand.
modsx:rollback
php artisan modsx:rollback # the newest snapshot
php artisan modsx:rollback 0001
php artisan modsx:rollback 0001 --force
Module Current Snapshot
Blog 0002 0001 will move
Media 0001 0001 already there
User 0001 0001 already there
A separate verb from modsx:restore on purpose: restoring is one module and one version, rolling back moves everything at once, and those are not two things anyone should be able to confuse at two in the morning. The plan is shown before the prompt, because the number that matters is not how many modules the snapshot holds but how many are somewhere else right now.
What it guarantees, stated exactly:
| Stage | What it does |
|---|---|
| Checked first | Every version the snapshot names is confirmed to still exist before anything is touched. This is the failure that actually happens, and it is caught while the application is whole. |
| A way back | A snapshot of the current state is taken first, and its number is printed at the end. |
| Per module | Each module is staged and swapped on its own, so a failure inside one leaves that module untouched. |
| Between modules | A failure part-way puts the modules already restored back to where the safety snapshot found them, and says so. |
What it is not is a single filesystem transaction — there is no such thing across a dozen directory trees. The last row is compensation, not atomicity, which is why the safety snapshot is named rather than left for you to work out.
It moves only the modules the snapshot names. A module created after the snapshot was taken was never part of that moment and is left exactly where it is; a module deleted since is brought back. So the warning above is about files inside a restored module, not about the project as a whole.
--force is required in non-interactive use, --json included. Machine-readable output is not permission.
It does not touch your database. Rolling code back does not roll a schema back, and modsx runs no migrations, in either direction. Archived migrations travel with their module's backup, so what you get back is the files; whether the schema still matches them is yours to judge.
modsx:snapshotprune
php artisan modsx:snapshotprune --keep=5 php artisan modsx:snapshotprune --keep=5 --dry-run php artisan modsx:snapshotprune --keep=5 --force
Snapshots hold versions back from modsx:prune, so this exists to let one go. Removing a snapshot removes no versions — it only stops them being held, so the next modsx:prune can consider them again.
Snapshots and pruning
modsx:prune will not delete a version that a snapshot names, the way a tag keeps a commit from being collected. Without that, a rollback would only discover the loss at the moment it needed the version, which is too late to be useful. It says what it left and why:
Shop 0001 ....... kept, held by a snapshot
There is no flag to override this. --force means don't ask me everywhere in this package, and letting it also mean ignore a safeguard would let a scripted prune quietly strand every snapshot naming those versions. The way to release them is to let the snapshot go with modsx:snapshotprune — after which the next prune considers them again.
A snapshot can still end up naming a version that is gone, if the backup tree is edited by hand. modsx:doctor reports that, and modsx:snapshotprune clears it.
A worked example
Three modules, where Blog uses both of the others:
php artisan modsx:deps
INFO Blog
Media ......................... found in the code
User .......................... found in the code
INFO Media
needs nothing else
INFO User
needs nothing else
Take a snapshot before starting anything risky:
php artisan modsx:snapshot --comment="before the payments rewrite"
Module Version
Blog 0001 backed up
Media 0001 backed up
User 0001 backed up
INFO Snapshot 0001 taken, holding 3 module(s).
Three weeks later, Blog and User have both moved on. Take another:
php artisan modsx:snapshot
Module Version
Blog 0004 backed up
Media 0001 unchanged
User 0002 backed up
INFO Snapshot 0002 taken, holding 3 module(s).
Media was not touched in those three weeks, so no new version was written for it — only another reference to 0001. That is what makes snapshots cheap enough to take before every risky thing you do.
Now the rewrite has to be abandoned. Restoring Blog on its own would not do it:
php artisan modsx:restore Blog 0001 # Blog 0001 beside a User 0002 it never knew php artisan modsx:rollback 0001 # the whole moment, as it was
Module Current Snapshot
Blog 0004 0001 will move
Media 0001 0001 already there
User 0002 0001 will move
WARN Anything not in the snapshot is replaced by what was. Files added since are moved aside and not put back.
INFO Rolled back to snapshot 0001. 3 module(s) restored.
The state before this is snapshot ......................................... 0003
Snapshot 0003 is the way back out, taken automatically before anything moved. Changed your mind again:
php artisan modsx:rollback 0003
Where things stand at any point:
php artisan modsx:status php artisan modsx:snapshotlist
Snapshot Created Scope Modules Comment
0001 2026-09-03T09:14:02+00:00 whole project 3 before the payments rewrite
0002 2026-09-24T16:30:55+00:00 whole project 3 -
0003 2026-09-24T16:41:18+00:00 whole project 3 before rolling back to snapshot 0001
And when the backup tree gets large, let the old moments go before pruning versions:
php artisan modsx:snapshotprune --keep=5 --dry-run # see which would go php artisan modsx:snapshotprune --keep=5 # let them go php artisan modsx:prune --keep=3 # now these versions are free
| You want | Command |
|---|---|
| Just this module back | modsx:restore Blog 0004 |
| This module and what it needed then | modsx:snapshot Blog first, then modsx:rollback |
| The whole project as it was | modsx:rollback 0001 |
| To see what would move first | modsx:rollback 0001 and read the table before answering |
| To know what a snapshot would hold | modsx:deps Blog |
modsx:diff
php artisan modsx:diff Blog # against the newest version php artisan modsx:diff Blog 0003 # against a specific version php artisan modsx:diff # interactive php artisan modsx:diff Blog --json
Compares the module in your application against a backup version, file by file, using a content hash:
- Added — in the application now, not in that version. A restore would delete these.
- Modified — in both, but the contents differ. A restore would overwrite these.
- Removed — in that version, gone from the application. A restore would bring these back.
- Unchanged — identical on both sides.
The comparison is on file contents, not directory names, so a module whose files were all rewritten in place is reported as modified rather than unchanged.
php artisan modsx:diff Blog --summary # counts only, no file list
Two versions against each other
Give a second version and the application drops out of the comparison entirely — the two versions are compared with each other:
php artisan modsx:diff Blog 0002 0004
The first version is the baseline and the second is what it gets compared with, which is how you would read it aloud: what happened to Blog between 0002 and 0004. The three groups keep their names and change their meaning:
- Added — only in
0004; it appeared after0002. - Modified — in both versions, but the contents differ.
- Removed — only in
0002; it was gone by0004.
Swapping the two arguments gives the same comparison seen from the other end: what was added becomes what is gone. No restore is involved either way, so nothing here is described in terms of one.
Your working tree is not read at all in this mode, so the answer is the same whatever state the application happens to be in right now.
--summary and --json work here too. The JSON carries from and to instead of version, so a script can tell the two modes apart by shape alone.
Worth running before modsx:restore: it tells you exactly what you are about to lose.
modsx:info
php artisan modsx:info Blog php artisan modsx:info --json
Shows:
- Current state: whether the module exists in the application, its directories and files, and its total size on disk
- Backup history: number of backed-up versions, total backup size, and a table of each version with its creation date, size, archived-migration count, and comment (if one was given at backup time)
Useful for understanding storage usage and deciding whether to prune old versions.
modsx:doctor
php artisan modsx:doctor php artisan modsx:doctor --json # exit code 1 if problems were found, for CI php artisan modsx:doctor --fix # remove empty module directories
Problems (exit code 1):
- Module names that differ only in word boundaries, such as
UserprofilealongsideUserProfile. Both are valid names, so nothing else flags this — but it is almost always one module that was meant to be one. - Backup trees that differ only in letter case. On Windows and macOS those are one directory, so two modules share a version sequence and a restore can return the wrong one.
- Backup versions with no readable
modsx.json.
Informational (exit code 0):
- Migrations that name a module but aren't archived with it — the classic
create_modsx_blog_posts_table— together with the name they need instead. Without this the convention would just quietly do nothing and you would never find out why. - Backups taken while a different prefix was configured.
- Directories in the backup tree that aren't versions, and are therefore skipped when listing.
- Modules present in only one of the two directory forms.
- Backups with no matching module in the application.
- Empty module directories — left by
modsx:scaffold, or by deleting the last file in one by hand.--fixremoves them. The check looks for files including hidden ones, so a directory kept alive on purpose with a.gitkeepis never touched — only a directory with nothing in it at all, at any depth, is reported. - Files naming a module that doesn't exist, such as
config/modsx-blog-admin.phpwith noBlogAdminmodule. The file keeps working; it just belongs to nothing and is backed up with nothing, which is worth knowing. - One module's name continuing another's, such as
BlogPostalongsideBlog. A supported layout, listed so the rule for their migrations is stated somewhere: the longer name wins. - A module recorded as coming from a version that no longer exists, after that version was pruned. Not a fault: the record still says truthfully where the working tree came from, and
modsx:statuscarries on by measuring against the newest version. Deleting the file is a complete fix. - A snapshot naming a version that is no longer there, which only editing the backup tree by hand can bring about, since
modsx:pruneholds those versions back. The snapshot is still listed and still looks usable, while the one thing it exists for — rolling back to it — is no longer possible.modsx:snapshotpruneclears it.
Configuration
config/modsx.php:
return [ // Directory prefix. 'modsx' matches modsx-blog and ModsxBlog. 'prefix' => env('MODSX_PREFIX', 'modsx'), // Where versioned backups are written. 'backup_path' => env('MODSX_BACKUP_PATH', base_path('modsx-backups')), // Only these paths are scanned. Keeping the list tight is what keeps // discovery fast: a full scan of the project root would walk storage/, // .git/ and public/build/. 'scan_paths' => [ 'app', 'config', 'database', 'lang', 'public', 'resources', 'routes', 'tests', ], // Directory names never descended into. 'exclude' => [ 'vendor', 'node_modules', 'storage', 'bootstrap/cache', '.git', '.idea', '.vscode', ], // What modsx:scaffold creates when you name no directories yourself. Both // placeholders come from the one name you type, which is what stops the // two forms from drifting apart. The published file carries a longer list // below this one, commented out — Livewire, services, form requests, // factories, seeders, tests, resources/css, resources/js, components — // so you uncomment what your modules have. 'scaffold' => [ 'app/Http/Controllers/{Studly}', 'app/Models/{Studly}', 'resources/views/{kebab}', ], // What modsx:make hands each generator. A value of its own runs the // generator of that name; a pair runs the generator you name, with the // module placed inside one of the application's own directories. 'generators' => [ '*' => '{Studly}/', 'view' => '{kebab}/', 'config' => '{kebab}-', 'migration' => '{snake}_', 'layout' => ['view', 'layouts/{kebab}/'], 'page' => ['view', 'pages/{kebab}/'], 'partial' => ['view', 'partials/{kebab}/'], ], // 4 gives 0001, 0002, ... 'version_padding' => 4, // Default for modsx:prune. 'prune' => ['keep' => 5], // Extra dependencies, for what reading a module's files cannot see. // Adds edges to the graph modsx:deps derives; never replaces them. 'dependencies' => [ // 'Blog' => ['Search'], ], ];
Two notes:
- If you change
prefixafter creating modules, rename the existing directories to match. Nothing is found under the old prefix. - The backup directory is never scanned for modules, wherever you point it — including inside a path that is otherwise scanned.
Limitations
Deliberate, and worth knowing before you rely on this:
- No database, and therefore no migration restore. Restoring an older version does not roll back migrations or touch data. Migration files are archived into every backup so you can read what a schema used to be, but they are never restored and never deleted — putting an old one back while the schema has moved on would leave your repository and your database disagreeing, with nothing to say so. This is a decision, not a gap to be filled later.
- A migration matching two modules goes to the longer name.
BlogandBlogPostcoexist happily — files name one module each — butmodsx_blog_post_create_comments_tablematches both, and the longer name wins. That is right for a migration of BlogPost's; if Blog ever needs one whose name begins with BlogPost's, it has to be named differently. This is the only rule you cannot read off a single filename. - No dependency resolution. Modsx doesn't know that
BlogneedsUsers. Restoring one won't restore the other. - No Composer integration. Third-party packages a module depends on remain your
composer.json's problem. - Backups are plain directory copies. No compression, no deduplication. A large module backed up fifty times occupies fifty copies — hence
modsx:pruneand--skip-unchanged. - Restore is recoverable, not atomic. The current state is moved aside whole before the restored state goes in, so a failure partway through is rolled back automatically. A machine that dies at exactly the wrong moment can still leave the module in pieces — but everything it had is in one place, and the pre-restore backup is still there.
FAQ
Do I need this package to use the convention?
No. That's the point. Prefix your directories and everything works. Install the package when you want backups. modsx:scaffold and modsx:make are conveniences for people who already have it installed, not requirements — they write directories and names you could just as well type by hand.
Why aren't my migrations being archived?
Almost certainly the name. The convention is that the name after the timestamp starts with the module prefix: 2026_01_01_000000_modsx_blog_create_posts_table.php, not the usual verb-first ..._create_modsx_blog_posts_table.php. Run php artisan modsx:doctor — it finds migrations that mention a module but aren't named for it, and tells you what to rename them to. php artisan modsx:make migration Blog/create_posts_table writes the name correctly in the first place.
What happens to my modules if I uninstall it?
Nothing. They are ordinary Laravel directories and were never anything else. Only modsx-backups/ becomes unmanaged, and that is just files you can keep or delete.
Does it conflict with nwidart/laravel-modules?
The two solve the same problem in incompatible ways, so running both is a bad idea. They won't clash on disk, though: the default backup directory is modsx-backups/ precisely to stay clear of that package's Modules/ source tree.
Can I move a module to another project?
Yes, two ways. Copy modsx-backups/Blog/ into the target project's backup directory and run php artisan modsx:restore Blog. Or, for a single file instead of a directory tree: modsx:export it, copy the .zip across, modsx:import it there, then restore. Namespaces survive because the directory layout does.
Why numbered versions instead of timestamps? They're short, they sort correctly, and they're easy to pick at a prompt. The creation time is in the manifest.
Can two modules share a directory? No. A directory belongs to exactly one module — the one its name encodes.
Is it safe to run in production?
The commands are developer tools. They confirm before destroying anything and refuse to run non-interactively without --force, but a deploy pipeline is not where module directories should be moving around.
Roadmap
Nothing outstanding. Migration restore is deliberately absent rather than pending — see Limitations.
Contributing
Issues and pull requests welcome — see CONTRIBUTING.md.
composer install composer test composer lint composer analyse # Run a command by hand in a Testbench app composer smoke -- modsx:doctor
composer smoke rebuilds package discovery before it runs. The test suite registers the service provider itself, so running it leaves behind a package manifest that doesn't mention Modsx — after which vendor/bin/testbench can't see the commands at all.
License
MIT. See LICENSE.