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
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
| PHP | 8.3+ |
| Laravel | 12.x, 13.x |
Installation
composer require synerdy/modsx
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.
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/ |
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. If your directories already follow Laravel's naming, they already follow this one.
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 prefix belongs to one module, all the way down. If Blog exists, modsx-blog-admin.php and modsx_blog_posts_table are Blog's — but a second module called BlogPost would then be claiming names that already read as Blog's, which is a naming conflict modsx:doctor reports. One module, one prefix.
The modsx prefix itself is configurable, so if you prefer mod- or your company's initials, change it once and the same rules apply.
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_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} |
Create the directory skeleton for a new module |
modsx:list |
Modules currently present in the application |
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?} |
Compare current state against a backup version |
modsx:info {name?} |
Show size, file count, and backup history |
modsx:prune {name?} |
Remove old versions, keeping the newest |
modsx:doctor |
Check for naming problems and orphaned backups |
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. The generators you can run are whatever your application has registered, not a fixed list, so adding an entry makes one from another package — make:livewire, make:filament-resource — follow the convention too.
Options for the generator go after --, and are handed on untouched:
php artisan modsx:make controller Blog/PostController -- --resource --model=Post php artisan modsx:make model Blog/Post -- -mfs
Use /, not \. Both are accepted, but a POSIX shell removes an unquoted backslash before Modsx ever sees it — Blog\PostController arrives as BlogPostController. PowerShell escapes with a backtick, so a backslash does survive there. / is the form that works in every shell.
--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
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.
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.
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 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/
└── 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
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.
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
Lists exactly which versions would go, then asks. The newest version is never removed, whatever --keep is set to.
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
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.
- One module sitting inside another's prefix, such as
BlogPostalongsideBlog. A migration named after either then reads as belonging to both, and nothing here guesses which was meant. - 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.
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. Both placeholders come from the one name // you type, which is what stops the two forms from drifting apart. 'scaffold' => [ 'app/Http/Controllers/{Studly}', 'app/Models/{Studly}', 'resources/views/{kebab}', ], // How modsx:make writes the module into the name it hands to Laravel's // own generator. '*' is the rule for anything not listed. 'generators' => [ '*' => '{Studly}/', 'view' => '{kebab}/', 'config' => '{kebab}-', 'migration' => '{snake}_', ], // 4 gives 0001, 0002, ... 'version_padding' => 4, // Default for modsx:prune. 'prune' => ['keep' => 5], ];
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.
- One module owns its prefix entirely.
BlogandBlogPostcannot coexist, becausemodsx_blog_post_*reads as belonging to either.modsx:doctorreports the conflict rather than guessing. - 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
License
MIT. See LICENSE.