Releases: Synerdy/modsx
Release list
v1.0.0-beta.5
Fixed
-
The documented install command did not work on Windows. PowerShell strips
a^from an argument whether it is quoted or not, so
composer require --dev synerdy/modsx:^1.0@betareached Composer as
1.0@betaand was refused as an exact version that does not exist. Reported
from a real terminal, having been written into the README by someone who had
watched the same thing happen while testing and documented the broken form
anyway.Every shell command on the page now uses a tilde.
~1.0@betais the same
range as^1.0@beta, and~0.7.0the same as^0.7.The quiet half was worse than the loud one.
composer require --dev synerdy/modsx:^0.7succeeds on PowerShell and writes0.7into
composer.json - an exact pin where a range was meant, with nothing to say so.
Measured by reading composer.json back, not inferred from the resolver's
output, which looked correct.
v1.0.0-beta.4
Added
-
modsx:scaffoldcreates files, not only directories. An entry whose last
segment has a dot in it names a file:'scaffold' => [ 'app/Http/Controllers/{Studly}', 'resources/views/{kebab}', 'docs/{kebab}.md', 'config/{kebab}.php', ],
It closes the last place where the prefix still had to be typed by hand.
Directories were covered -modsx:scaffold Blog docsworks out
docs/modsx-blogfor you - but a single file atdocs/modsx-blog.md,
config/modsx-blog.phporroutes/modsx-blog.phphad to be made in an
editor, spelling the prefixed name yourself. That is the one mistake this
package exists to prevent, and it was the one it left you to make.The file is created empty, and an existing one is never written over. Nothing
is put inside on purpose: the same entry shape makes a Markdown page and a
PHP config file, and a heading that helps in the first is a syntax error in
the second. An emptyconfig/modsx-blog.phpreads back as1rather than an
array until you fill it in - measured rather than assumed, and it does not
stop the application booting.A file is told from a directory by the last segment having a dot, rather than
by a list of blessed extensions, because what a module keeps is yours to
decide. A directory deliberately namedmodsx-blog.v2would be read as a
file; rare enough to accept, and the answer is to write the entry without a
dot. -
A module can keep its documentation, in
docs/.docs/modsx-blog.mdfor
one page,docs/modsx-blog/for a directory of them - the same two shapes
configuration already had, and for the same reason.Almost nothing was needed to support it. The name has always identified the
module in a file as much as in a directory, and the rule cuts at the first
dot, so.mdbehaved like.phpand.cssalready did. What was missing
was thatdocswas not inscan_paths, which made the whole directory
invisible. It is there by default now.Everything else follows without a line of special handling: documentation is
copied bymodsx:backup, brought back bymodsx:restore, counted as a
change bymodsx:diff, removed bymodsx:delete, and listed by
modsx:path. That is the point of keeping it here rather than beside the
project's own - restoring a module from three weeks ago gives you the
documentation it had three weeks ago, not today's description of code that
no longer exists.modsx:infonames it, since that is where somebody goes to find out what a
module is and "there is a page about this" answers it better than one row in
a list of twenty. Recognised by sitting under the scanneddocsdirectory
rather than by a setting of its own: a second setting could disagree with
scan_paths, and documentation would then stop being part of the module
while still being labelled as its documentation.A directory of pages makes a module, a lone page does not - the rule
everywhere else, applied here too. Sodocs/modsx-shop/brings Shop into
being before any of its code exists, whiledocs/modsx-shop.mdalone is
reported bymodsx:doctoras a page naming a module that is not there.Adding a scan path is a breaking change for anyone who already keeps a
docs/modsx-*.md: it becomes module content, which meansmodsx:delete
removes it. Cheap while this is a prerelease.
Fixed
-
The check that the README names the current prerelease was asking the wrong
question. It demanded the newest tag by name, which is false during exactly
the moment it matters: preparing a release writes the new version, commits
it, and only then tags it, so the README is briefly ahead of every tag. The
first release after adding the check would have been blocked by it.It now asks only that the README has not fallen behind - that it names no
prerelease an existing tag has superseded. That is the failure it was written
for, and being ahead is no longer mistaken for it.
v1.0.0-beta.3
Added
-
modsx:prune --duplicatesremoves versions that record nothing, meaning
a version holding exactly what the version after it holds. It ignores
--keep: the two ask different questions of a version, one about how much
history to hold on to and one about history that says nothing, and the
answers do not combine.Only versions next to each other count, and the distinction is the whole
design. Two identical versions with a different one between them are not a
repeat but a return - the module was changed and changed back - and removing
the later one would leavelatest()naming a state the application is not
in, whichmodsx:status,modsx:backuplistand a baremodsx:restoreall
read. So0001 A, 0002 A, 0003 Acollapses to0003, while
0001 A, 0002 B, 0003 Ais left exactly as it is.The newest of each run is what stays, for the same reason.
A version carrying a comment is always asked about, never assumed, and the
comment is shown while asking. The content survives in the version it
duplicates, but a note about a moment is not content and only its author can
say whether it still matters. Where there is nobody to ask ---force,
--json, no terminal - those versions are kept and the answer says so;
--with-commentsis how a script says it has already decided.Archived migrations count towards being identical here, unlike in the check
that skips an unchanged backup. That check asks whether a restore would do
anything; this one is about deleting, and a version holding the only copy of
a migration must not look disposable.Versions a snapshot or the state pointer names are never removed, as with
--keep.
Changed
-
modsx:backupno longer copies a module that has not changed. It
compares against the newest version first and says what it found:INFO Nothing to back up: [Blog] is identical to version 0004.A second copy of a module that has not moved records no fact and costs a
full directory, so it was never what anyone wanted - but it was the default,
and--skip-unchangedhad to be remembered to avoid it. The flag is now the
other way round:--even-if-unchangedwrites a version anyway, for the one
case where a second copy of the same thing is the point, marking a moment
that matters even though the code has not.The safety copies
modsx:restoreandmodsx:deletetake before they change
anything follow the same rule, and that is where most duplicate versions were
coming from: restoring twice in a row wrote the same state twice. When a
version already holds the current state, that is what those copies exist for.A comment does not force a version on its own. If nothing changed, the
version it was meant for is never written, and the command says the comment
went nowhere rather than losing it quietly.Breaking, and deliberately so while this is a prerelease:
--skip-unchanged
is gone, andBackupManager::backup()takes$evenIfUnchangedwhere it took
$skipUnchanged. Scripts passing the old flag will be told it does not exist
rather than quietly doing something else.
v1.0.0-beta.2
Fixed
-
A backup no longer fails while a file watcher is running. On Windows
rename()is refused for as long as another process holds a handle inside
the directory, and every write here ends in arename(): the result is
assembled in a staging directory and moved into place only once complete. A
runningnpm run devwas enough - Vite's watcher notices the new files,
opens the directory to watch it, and the move that would have finished the
backup failed with nothing butFailed to copy [...]to explain it.Reported against 1.0.0-beta.1 with the diagnosis already done, including the
detail that decided the fix: a rename immediately after the copy succeeds,
one 250ms later does not. The watcher's handle is not transient, it holds for
as long as the watch does, so retrying alone would only have made the failure
slower. What rescues it is copying, which needs no exclusive handle.Moves now go through PathMover, which separates two guarantees that a single
"move" was quietly conflating:- place cares that the destination ends up whole. Its source is a staging
directory nobody else refers to, so renaming, and copying when that is
refused, are equally good. Used by backup, export, import and restore. - move cares that the source is gone, because something else is about to
stand where it did. Copying cannot promise that - deleting the original can
fail halfway, which on Windows is exactly what a held file does - so it
renames or it fails. Used only where the application's own files are moved
aside.
The second is why restoring over a file something has open now stops before
touching anything and says so, rather than emptying the module of everything
except the file nobody could overwrite. That case cannot be engineered away:
on Windows an open file cannot be replaced. It can be refused honestly. - place cares that the destination ends up whole. Its source is a staging
-
modsx:doctorfinds staging directories a run left behind, and
--fixremoves them. A move that fell back to copying cannot always delete
its source, so an interrupted run can leave one. They were invisible to every
other check, since their names begin with a dot andFile::directories()
does not return those.
Documentation
-
A "Windows and file watchers" section, saying what to exclude from a
watcher and why it is worth doing even now that backups survive without it:
a watcher that sees a backup being written reloads the page over files that
are not part of the application.Deliberately advice rather than automation. Editing somebody's
vite.config.json install would mean rewriting arbitrary JavaScript from
PHP with no parser, in a file that may not exist, may be TypeScript, and may
configurewatch.ignoredas a function - and it would fix one watcher out of
a class that also includes editors, antivirus and folder sync.
v1.0.0-beta.1
Added
-
modsx:snapshot,modsx:rollback,modsx:snapshotlist,modsx:snapshotprune
— the whole project at one moment. A snapshot records which version of each
module was current together, which answers the question a per-module backup
cannot: I cannot restore Blog from three weeks ago, because back then it
depended on a different User.A snapshot 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 unchanged since its last
backup gets no new version either, only another reference to the one it had,
so a snapshot of an untouched project writes nothing but the snapshot. That
is deliberate: a snapshot nobody minds taking is one that will be there when
it is needed.What
modsx:rollbackguarantees, stated exactly, because the difference
matters. Every version the snapshot names is confirmed to exist before
anything is touched - that is the failure that actually happens, and it is
caught while the application is whole. A snapshot of the current state is
taken first and its number printed at the end. Each module is then staged and
swapped on its own, and a failure part-way puts the modules already restored
back where the safety snapshot found them. What it is not is one filesystem
transaction; there is no such thing across a dozen directory trees, so the
last step is compensation, and the safety snapshot is named rather than left
to be worked out.It does not touch the database. Rolling code back does not roll a schema
back, and no migrations are run in either direction. -
modsx:deps— which modules a module needs, worked out by reading it.
The graph is derived, not declared. A module called Media appears in other
modules' code asModsxMedia,modsx-mediaormodsx_mediaand in no other
form, so a reference to it is something that can be found rather than
something someone has to remember to write down.That is the whole argument against a
requires:list: it is a register
nothing checks, and a snapshot built from a stale one is quietly incomplete -
worse than no snapshot, because it is trusted. A mention in a comment or a
string counts here too, since the mistake that causes is a snapshot holding
one module too many, while the opposite mistake breaks a rollback.Name boundaries are respected, so
ModsxBlogPostrefers to BlogPost and not
also to Blog - the same rule that decides which module owns a file. The snake
form is the exception, where a suffix is ordinary:modsx_media_assetsis
Media's table.Configuration under
modsx.dependenciesadds edges for what reading cannot
see - a class name assembled from a string, a listener wired up elsewhere -
and never replaces the ones found in the code. An edge found in both is
reported as found, because that is the claim that can be pointed at. -
modsx:status— every module in one table: what state it is in, which
version its working tree came from, what the newest backup is, and how many
files have changed since.Module State Current Latest backup Changes Blog modified 0001 0002 1 Shop clean 0001 0001 0 Billing untracked - - - Admin missing - 0001 -The frame is the one version control already taught everyone:
Currentis
where the tree came from, soChangescounts what has happened since rather
than the distance to the newest backup, which is a different question and
gets its own column. That makes a module able to becleanand behind at the
same time, which is the combination worth being told about - backing up from
an older version builds the next one on top of it, and the listing now says
so before that happens. -
A module's working tree records the version it came from, in
modsx-backups/<Module>/modsx-state.json, written bymodsx:backupand
modsx:restore.modsx:importdeliberately records nothing: it adds a
version to the backup tree without touching the application, so the working
tree did not come from it, and saying otherwise is the one thing this record
must never do.It never decides whether a module exists. Discovery stays exactly what it
was, a directory named by the convention, andModuleLocatorknows nothing
about this file: a module made withmkdirhas no record and is reported
untracked, which is the truthful answer rather than a gap. Delete every one
of these files and the package behaves as it did before they existed - a
property with a test on it, because it is what keeps the convention the only
source of truth about what a module is.It lives beside the versions it points at because it means nothing without
them: when they go, it goes. It cannot live inside the module either, or a
backup would copy it and version 0004 would contain a file claiming the
module is at 0003. -
modsx:doctorreports a record naming a version that has been pruned.
Informational, not a problem: the record still says truthfully where the tree
came from, andmodsx:statuscarries on by measuring against the newest
version instead. Deleting the file is a complete fix.
Changed
-
modsx:pruneno longer deletes a version that a snapshot names, the way
a tag keeps a commit from being collected. Without it a rollback would
discover the loss at the moment it needed the version, which is too late to
be useful. It lists what it held back and why, since a command that silently
removes less than the age rule offered looks like a bug rather than a
safeguard.Deliberately with no override. Everywhere else in this package
--force
means do not ask me, and letting it also mean ignore a safeguard would
let a scripted prune quietly strand every snapshot naming those versions.
Releasing them ismodsx:snapshotprune's job: let the snapshot go, and the
versions follow. -
modsx:doctorreports a snapshot naming a version that is no longer
there. Informational, and only reachable by editing the backup tree by
hand, since prune holds those versions back: the snapshot is still listed and
still looks usable while the one thing it exists for has become impossible.
Fixed
- The release workflow no longer asks GitHub to mark a prerelease as the
latest release.v1.0.0-beta.1is the highest tag in the repository, so
the newest-tag check claimed the Latest badge for it while the suffix check
marked it a prerelease - two contradictory instructions in one call. Only
stable tags are candidates now, which also stops a beta denying the badge to
the stable release that follows it, since git's version sort puts
v1.0.0-beta.1abovev1.0.0.
Documentation
- A
.gitignoreline for anyone who commits their backup tree:
modsx-backups/*/modsx-state.json. Which version your working copy came
from is a local fact, not a shared one, and two people restoring different
versions would otherwise conflict over it.
v0.7.0 - comparing two versions, and a manifest that can't escape
Comparing two backup versions
modsx:diff has always compared your application against a version. Give it a second version and the application drops out of the comparison entirely:
php artisan modsx:diff Blog 0002 0004The version named first is the baseline in both forms, so modsx:diff Blog 0002 and modsx:diff Blog 0002 0004 ask the same question from the same starting point — only the other side moves. Swapping the two arguments gives the same comparison seen from the other end: what was added becomes what is gone.
The wording follows. Against the application, the useful frame is what a restore would do next:
Added (restore would delete)
Modified (restore would overwrite)
Removed (restore would bring back)
Between two versions no restore is in sight, so describing those numbers by an action nobody asked for would simply be untrue:
Added (only in 0004)
Modified (differs between the two)
Removed (only in 0002)
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. --summary and --json work here too; the JSON carries from and to in place of version, which is how a script tells the two modes apart by shape alone.
A manifest can no longer place files outside the project
modsx:import takes a version number and a list of paths out of a manifest that somebody else wrote, and checked neither.
A version of ../../.. and a path of ../../escaped each wrote outside the project root. Both were reproduced before being fixed, rather than assumed — and ZipArchive::extractTo() was verified not to be the way in, since it sanitises entry names itself.
The version is now validated in the one place it becomes a path, and a manifest path is refused if it escapes with .., is absolute, or carries a drive letter.
If you only ever import archives you exported yourself, none of this was reachable. It starts to matter the moment an archive arrives from anywhere else.
A generator's options no longer need --
php artisan modsx:make component blog.alert --view # now
php artisan modsx:make component blog.alert -- --view # beforeAnything else used to be refused with Symfony's The "--view" option does not exist — a message that says nothing about the separator you forgot. Which is easy to forget: --resource, -m, --view and --api come up constantly.
modsx:make answers to one option of its own, --dry-run. It now sorts the raw tokens itself and passes everything else to the generator, which is also why a generator from any package needs nothing declared. Writing -- still works, and remains the way to reach a generator option that collides with ours.
The cost, stated plainly: a misspelling of --dry-run is no longer caught by Symfony. It is checked for by name instead, and answered with Did you mean --dry-run? rather than forwarded to a generator that would blame itself.
An exported archive is named after its module
Blog-0002.zip, not 0002.zip — which said nothing at all once the file had been moved or mailed anywhere.
modsx:prune removes both names, so an archive written under the old one is still swept along with the version it belongs to, rather than left behind for good.
modsx:backuplist counts files and archived migrations
A version has held both since 0.3.0, so listing its directories alone described less than half of what was in it.
Version Created Directories Files Archived Comment
0002 2026-09-01T21:14:07 3 2 1 before the rewrite
Also
modsx:backupnames the files it did find when a module has files but no directories, instead of reporting the module as missing. It still refuses: a module is a set of directories, and the unclaimed-file check rests on that.public/buildjoins the defaultexcludelist, so a compiled asset bundle is not walked while looking for a module's directories.- CI rebuilds
docs/and fails if it differs from what is committed, and checks that the two READMEs keep the same headings, the same number of code blocks and the same number of table rows.
Upgrading
While Modsx is on 0.x, this release will not arrive with a plain composer update. Ask for it by name:
composer require --dev synerdy/modsx:^0.7Composer 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.
The one thing to check before upgrading: if anything of yours refers to an exported archive by filename, it is Blog-0002.zip now.
v0.6.0 - reaching the directories a module doesn't own
Reaching the directories a module doesn't own
A module's own views are 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/. Neither command could express that: both write the module at the front of a name.
The workaround was typing modsx-blog by hand — into make:view layouts.modsx-blog.app, or into mkdir — which is the mistake these commands exist to prevent.
modsx:make layout, page, partial
php artisan modsx:make layout blog.app # -> views/layouts/modsx-blog/app.blade.php
php artisan modsx:make page blog.index # -> views/pages/modsx-blog/index.blade.php
php artisan modsx:make partial blog.head # -> views/partials/modsx-blog/head.blade.phpThere is no make:layout in Laravel. These are entries in the same modsx.generators table, and what makes them different is that the entry names the generator to run as well as the form:
'layout' => ['view', 'layouts/{kebab}/'],
'page' => ['view', 'pages/{kebab}/'],
'partial' => ['view', 'partials/{kebab}/'],So the names are yours. 'service' => ['class', 'Services/{Studly}/'] gives you modsx:make service Blog/PostPublisher, and it appears in the interactive picker alongside Laravel's own generators.
Deliberately no component: make:component is Laravel's own and already lands correctly, writing the class and letting Laravel derive views/components/modsx-blog/ from where that class went.
modsx:scaffold takes the directories to create
Naming them 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/js app/Services
# resources/js/modsx-blog/
# app/Services/ModsxBlog/Note the last 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 |
|---|---|
resources/css, public/vendor, lang/en |
…/modsx-blog |
app/Services, database/factories, tests/Feature |
…/ModsxBlog |
app/, database/ and tests/ are the PSR-4 roots of a stock Laravel application, where a hyphen is not a legal PHP identifier. Everywhere else the name is only ever a path. Where that guess is wrong — a PSR-4 root of your own — a placeholder settles it: modsx:scaffold Blog "modules/Shared/{Studly}".
An existing directory is skipped and reported, as with the configured list, and a path may not contain ...
Also
- A path typed on the command line was refused with a message about a
modsx.scaffoldentry the reader never touched. It has its own message now. - The convention table listed
app/anddatabase/as the namespace roots while omittingtests/.
Full detail in the CHANGELOG.
v0.5.2 - dot notation for the module
Separate the module with a dot, the way Laravel writes a view
A view is blog.create in Laravel's own documentation — all lower case, dots throughout. modsx:make wanted the module separated with a slash, and typing the natural form got an error about backslashes, which is unhelpful advice to someone who typed a dot.
The module now ends at the first /, \ or ., whichever comes first:
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 config blog.services # -> make:config modsx-blog-services
php artisan modsx:make controller Blog/PostControllerOnly the first separator divides — a module name can contain none — so the rest of the name keeps its own dots. Strictly additive: every existing test passed unchanged.
The output was never wrong. make:view does str_replace(['\\', '.'], '/', $name) itself, which makes modsx-blog/create and modsx-blog.create the same view. It was the input that was too narrow.
Every generator, documented
The modsx:make section now lists every generator Laravel ships, written as the call you would make through Modsx, with the name each one receives — read off real runs rather than reasoned about:
- 28 generators take PascalCase and fall under
*→ModsxBlog/UserController - three have a form of their own:
view(view path),config(kebab-case),migration(snake_case) - six
*-tablegenerators take no name at all — they are framework scaffolds, not anything belonging to a module
Either separator works at every one of them.
The one departure from plain Laravel
A config name is snake_case to make:config. Modsx gives it kebab-case, and 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.
Tests
Assertions moved from what the command prints to where the file actually lands, across every documented form. Two of them had only been passing by accident: Testbench resolves view.paths before the base path moves, so make:view had been writing into vendor/, and make:model only uses app/Models when that directory exists.
Full detail in the CHANGELOG.
v0.5.1 - modsx:make converts the whole name
modsx:make converted only half the name
The module was written in the generator's form and the rest of the name passed through untouched:
modsx:make config Blog/MailSettings -> modsx-blog-MailSettings
modsx:make view Blog/PostList -> modsx-blog/PostList
Kebab-case and StudlyCase inside one identifier — neither a config key you would type nor one Laravel would generate. Migrations were converted; nothing else was.
The entry in modsx.generators now settles the whole name:
modsx:make view Blog/PostList -> modsx-blog/post-list
modsx:make view Blog/Admin/PostList -> modsx-blog/admin/post-list
modsx:make config Blog/MailSettings -> modsx-blog-mail-settings
modsx:make migration Blog/CreatePostsTable -> modsx_blog_create_posts_table
modsx:make controller Blog/PostController -> ModsxBlog/PostController (untouched)
A {Studly} entry leaves the rest of the name alone, a class name already being written the way its generator wants it. Migrations stop being a special case in the code — they are one of three patterns rather than the exception. Conversion runs segment by segment, because Str::kebab('Admin/PostList') is admin/-post-list: the separator reads as a word boundary.
A longer scaffold list, commented out
The published config now offers Livewire, services, form requests, middleware, factories, seeders, tests, resources/css/, resources/js/, and view directories — all commented. The defaults are unchanged and stay short on purpose: a directory nobody fills in is invisible to git and reported by modsx:doctor, so a generous default would only make work for --fix.
Views take the same shape as everything else, the module going inside the framework's directory:
resources/views/
├── components/modsx-blog/ -> <x-modsx-blog.card>
├── layouts/modsx-blog/
├── partials/modsx-blog/
└── modsx-blog/
layouts/modsx-blog/, not modsx-blog/layouts/ — the module comes second everywhere else in this convention, exactly as in resources/css/modsx-blog/. A starter kit's own layouts/app.blade.php carries no prefix, so no module claims it, and the two sit side by side. Modules are found at any depth.
Documentation caught up with the code
- Limitations still said
BlogandBlogPostcould not coexist and thatmodsx:doctorreported their prefix as a conflict. Both stopped being true in 0.5.0. What remains is that a migration matching two modules goes to the longer name. - Installation now reads
composer require --dev synerdy/modsx. Outside an artisan command the package does nothing at all — the service provider returns immediately unless the application is running in the console — and nothing in an application ever calls into it. The one case forrequireis named too: runningmodsx:*where dev dependencies are absent. - The naming tables no longer show a single file where the convention wants a directory, and a new paragraph says why the two name forms exist at all: directories under
app/are PSR-4 namespace segments, and a PHP identifier cannot contain a hyphen.
Full detail in the CHANGELOG.
v0.5.0 - a name identifies a module
Deleting a module could remove a neighbouring module's files
With both Blog and BlogPost present, Blog claimed every file whose name merely started with modsx-blog. So modsx:delete Blog removed config/modsx-blog-post.php — BlogPost's — and backing Blog up archived BlogPost's migrations into Blog's version. modsx:doctor reported the pair as an error, but nothing stopped the command.
The cause was that one name meant two different things:
resources/views/modsx-blog-admin/ -> module BlogAdmin (a module of its own)
config/modsx-blog-admin.php -> Blog's file, and BlogAdmin's too
A name identifies a module — in a file as much as in a directory
A file's name is now read exactly as a directory's is, cut at the first dot:
| Path | Module |
|---|---|
config/modsx-blog.php |
Blog |
resources/views/modsx-blog.blade.php |
Blog |
lang/en/modsx-blog.php, lang/pl/modsx-blog.php |
Blog |
config/modsx-blog-post.php |
BlogPost, and only BlogPost |
config/modsx-blog-admin.php |
BlogAdmin |
Module names are unique, so at most one module can match a file — two can never both claim one. Where a module genuinely needs several files in one place, the form is a directory: config/modsx-blog/settings.php, which Laravel reads as config('modsx-blog.settings').
Breaking: a file like config/modsx-blog-admin.php was Blog's and now is not. modsx:doctor lists every file naming a module that doesn't exist, under a new unclaimed_files key — without that, such a file would drop out of its backup silently. Existing backups are unaffected: restore reads the manifest, so a version gives back exactly what it recorded.
Migrations go to the longest module name that claims them
A migration is the one thing that cannot be named for its module and nothing else, since every migration needs its own name:
modsx_blog_create_posts_table -> Blog
modsx_blog_post_create_comments_table -> BlogPost, or Blog if BlogPost doesn't exist
Decided by the module list, with no vocabulary of migration verbs — make:migration accepts any name at all (backfill_, cleanup_), and module names are themselves sometimes verbs (Import, Update).
Blog alongside BlogPost is therefore a supported layout. modsx:doctor no longer counts it as a problem; it is listed informationally, stating how their migrations divide.
Also
confirmDestructive()moved into a trait used only by the three commands that declare--force. It had guarded itself withhasOption('force')because the other eleven did not have it — eleven static-analysis errors that a stale PHPStan cache had been hiding.composer smokeruns a command by hand in a Testbench app, rebuilding package discovery first. Development only.
Full detail in the CHANGELOG.