Repository navigation
feat: modular (ddd) implementation - #1613
protibimbok wants to merge 29 commits into
Conversation
files - NewPrefixFS mounts a module's views under its name, so "index.tmpl" is the view "blog/index.tmpl"; it passes fstest.TestFS and works with View.LoadViewsFromFS and view.Compile. - NewLangFS serves every module's translations as groups "<module>/<group>" through one app.lang_fs, asking the application's own lang FS first so an app file can override a module's. - StaticFS resolves a module's static Path (embedded, or on disk under the module's directory, inside the module only) and refuses to open directories, so gin and fiber both answer 404 instead of listing them. CleanPrefix normalises the URL prefix and refuses "/".
foundation.Setup().WithModules(Modules) registers the application's modules. Build() binds a fresh registry (binding "goravel.modular") right after WithConfig, so modular.path (where third-party modules are placed, "modules" by default) is known, available as facades.Modular() and app.MakeModular(); an application booted without foundation.Setup() has none. A duplicate module name or a module whose directory cannot be resolved is reported and the modules before it are kept.
Each Build() step now takes the modules' contributions after the
application's own:
- config: "modules.<name>" in a step of its own after the registry is
bound, so after WithConfig; translations through
app.lang_fs, with an existing app.lang_fs kept as fallback
- providers: added with WithProviders, sorted by relationships as usual
- global middleware: driver defaults, then modules, then the
WithMiddleware
callback, which sees the full list
- views: registered under "<name>/" after providers boot and before the
driver compiles the view set
- commands, migrations, seeders, jobs, schedule: appended in module
order;
commands go through the commands filter and hooks like app commands
- events: merged per event, so listeners of a shared event are kept
- routes: static paths on the root router (no group middleware, disk
paths
relative to the module's resolved directory), then web
and API routes inside the groups configured by modular.routes.web and
modular.routes.api ("" and "api" by default)
Steps a module does not use are untouched: an application without
modules
builds exactly as before.
- match.Modules() finds the Modules() function returning
[]modular.Module.
- modify.AddModule / RemoveModule edit bootstrap/modules.go like the
other
With* helpers; an aliased import ("blogposts
<main>/modules/blog-posts")
is supported for kebab-case directories.
- modify.InitModules() creates an empty bootstrap/modules.go and adds
WithModules(Modules) to foundation.Setup(). It goes through the new
withSliceHandler.EnsureWithMethod, which edits app.go before writing
the
helper file and checks that the edit landed, so a chain that is not a
statement of its own (builder := foundation.Setup()...) returns
PackageSetupNotFound and leaves nothing behind.
- modify.ModulesStub() exposes the empty modules.go.
artisan modular:init prepares an application for modules and can be run again: every target is reported as CREATED, EXISTS or MANUAL. - <modular.path>/.gitkeep (default modules/) - config/modular.go with modular.path and the modular.routes.web/api groups, using the app's facades package or the framework's when the app has none - bootstrap/modules.go and .WithModules(Modules) in foundation.Setup(). When the chain cannot be edited (it is assigned to a variable), modules.go is still written and the row is MANUAL with the line to add; the command does not fail. It needs bootstrap/app.go to use foundation.Setup() and is registered with the default commands, so it works before the application has any module.
artisan make:module <Name>... [--api] [--plain] [--order=N] [--force]
scaffolds <modular.path>/<kebab-name> and lists the module in
bootstrap/modules.go.
- default: module.go (provider, web and API routes, config, embedded
views
and translations, Vite entries), providers/, routes/web.go and api.go,
http/controllers/, resources/views/index.tmpl, lang/en/messages.json,
database/migrations, database/seeders, models, and tests/feature when
the
app has tests/test_case.go
- --api: no views, web routes or Vite entries; translations are kept
- --plain: module.go with Name() only
- --order=N adds Order()
- --force rewrites the stubs and removes the ones the chosen variant
does
not write (and the directories they leave empty); other files are kept
and the module is listed once
"BlogPosts" becomes the module "blog-posts" in package blogposts,
imported
with an alias. Routes are /<name> and /api/<name>, so two modules never
claim the same path. The view has no {{ define }}, since define names
are
global. The controller reads its title through the Lang facade when the
app
has one, otherwise it uses a plain string.
Names Go cannot import (keywords, main, init, internal, vendor) are
refused
with ModularModuleNameForbidden. A name whose package is already
imported in
bootstrap/modules.go from another path ("Blogposts" after "BlogPosts",
"modular") is refused with ModularModuleImportConflict before anything
is
written. When WithModules is not in bootstrap/app.go after the run (the
chain is assigned to a variable), the command warns with the line to
add.
modify.AddModule now writes to an existing bootstrap/modules.go even
when
WithModules is not wired yet, so make:module works right after a
modular:init that reported MANUAL.
A third-party module (a module from another Go module, without Path()) is placed at <modular.path>/<name>. Vite and Tailwind cannot read its files from the Go module cache, so they are copied into the project. - contracts/modular.ProvidesSources: Sources() fs.FS, the files to copy. Mock generated; the registry reports the "sources" capability. - modular.Publish copies Sources(), without Go files (*.go, go.mod, go.sum: they would be compiled into the application), and, unless the sources already hold resources/views, the module's Views() into resources/views. It writes DO-NOT-EDIT.md and the marker .modular-publish.json (Go module, version, sum, file list), removes files an earlier publish wrote that the module no longer ships, and never touches other files. A directory without the marker, or with the marker of another Go module, is refused (ModularPublishTargetNotOwned). - modular.DependencyOf resolves the Go module and version from the build info (the replacement when replaced); modular.PublishState reports none / missing / published / stale / conflict. It compares the marker and the files on disk, so a replaced or workspace module that changed without a new version, and an edited copy, are stale. conflict is a directory Publish would refuse. - artisan modular:publish [--module=<name>] prints PUBLISHED, UNCHANGED or SKIPPED per module. A failing module prints its error and FAILED, does not stop the others, and the command returns ModularPublishFailed with the count. - modular:init adds "//go:generate go run . artisan modular:publish" to main.go, so go generate publishes after a dependency upgrade.
artisan modular:list shows each registered module with its directory, publish state and order, followed by its capabilities, Vite entries, Vite aliases and static prefixes. --json writes the same as one JSON array (name, path, order, publish, capabilities, vite_entries, vite_aliases, static) straight to stdout, without the colour codes ctx.Line adds, so it can be piped. Without modules it points at bootstrap/modules.go; without a registry (application not built with foundation.Setup()) it returns ModularRegistryNotSet.
vite:inputs gains "modules": [{name, path, entries, aliases}], every
path
relative to the project root, so goravel-vite-plugin can build module
entries, add the @<name> aliases and watch module directories. Without a
registry (legacy Boot) the list is empty.
- Entries come from ProvidesViteEntries; a module without any is still
listed, the plugin needs its path.
- Aliases come from ProvidesViteAlias. A key must look like @<name> and
its
target must stay inside the module (ModularViteAliasInvalid); a key
used
by two modules, or equal to another module's implicit @<name>, fails
with
ModularViteAliasDuplicate. A module may redefine its own @<name>.
- A third-party module whose published copy is missing, differs from the
linked version or is not owned by modular:publish fails the command
(ModularModuleNotPublished, ModularModulePublishStale,
ModularPublishTargetNotOwned), so Vite never builds without its
sources
or with old ones.
NewInputsCommand takes the module registry as a fourth argument.
Entries and assets may be written through a Vite alias, in templates and
in
Go: `{{ vite "@/app.js" }}`, `{{ vite "@blog/app.js" }}`,
`{{ viteAsset "@blog/../assets/logo.svg" }}`.
goravel-vite-plugin owns the alias table and hands it over in the two
files
the framework already reads: the manifest, under the reserved key
"goravel:aliases", and the hot file, which becomes
{"url": "...", "aliases": {...}}. A hot file holding a bare URL still
works,
without aliases.
Tags, Asset and Content strip the leading slash, replace the longest
alias
matching at a path boundary (ViteAliasNotFound when none does), then
clean
the path and refuse one that leaves the project
(ViteEntryOutsideProject).
Leaving the alias directory while staying inside the project is allowed.
modify.AddToModule(dir, method, pkg, item) registers a generated item on
a
module struct, e.g. &migrations.Posts{} in Migrations(). It supports the
slice capabilities: Commands, Jobs, Migrations, Seeders,
ServiceProviders.
- The module struct is the receiver of Name() in the module directory,
module.go first. The method is edited in whichever file of the package
declares it and appended after Name()'s file when the struct lacks it.
- The import block of the edited file is regrouped into standard
library,
third-party and application imports.
- match.ModuleMethod(method) matches the slice literal a method returns.
createImport put a new import block after the first declaration of a
file
without imports, which does not compile. It is now the first
declaration.
./artisan make:controller PostController --module=blog writes modules/blog/http/controllers/post_controller.go, with the package and import path of that directory. NewMake reads the option, finds the module in the registry (as given, then in kebab case) and moves the generator's directory under the module's, dropping the application directory: app/http/controllers becomes <module>/http/controllers, database/factories stays database/factories. An unknown module fails with ModularModuleNotFound and a third-party one with ModularModuleNotLocal. support/console cannot reach the container, so Build() records the registry with modular.SetDefault and NewMake reads modular.Default(). The flag is on the generators that only write a file: agent, tool, policy, channel, command-hook, factory, model, observer, event, listener, controller, middleware, request, mail, notification, filter and rule. Rules, filters and command hooks have no module capability, they still register in bootstrap/. Also fixes the Ordered comment: the default order is 0.
make:migration create_posts_table --module=blog writes the migration to modules/blog/database/migrations and adds it to the module's Migrations() method, creating the method when the module does not have one. Migrator gains CreateIn(dir, name, modelName) for that; Create calls it with the default migrations path.
in the module With --module, make:command, make:job, make:seeder and make:provider write into the module and register the new type on the module struct (Commands, Jobs, Seeders, ServiceProviders) instead of bootstrap/. make:view --module writes to <module>/resources/views and defines the template as "<module>/<view>", the name it is rendered by. make:test --module writes to <module>/tests and embeds the application's TestCase.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1613 +/- ##
==========================================
+ Coverage 75.72% 77.64% +1.92%
==========================================
Files 426 438 +12
Lines 28275 29631 +1356
==========================================
+ Hits 21410 23008 +1598
+ Misses 6864 6622 -242
Partials 1 1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
make:migration imported <app>/app/facades whenever bootstrap/app.go calls foundation.Setup(). An application without that package, or without its Schema facade, got a migration that does not compile, and since artisan runs through `go run .` every later artisan command failed as well. The stub now uses the app's facades only when app/facades/schema.go exists, and github.com/goravel/framework/facades otherwise. The package name falls back with the import, so an app whose facades package has another name does not get facades.Schema() from one package and f.Schema() from the other.
Module.Name() is documented as kebab case, but Register only rejected duplicates. The name becomes the config key modules.<name>, the view and translation prefix and the Vite alias @<name>, so "blog.admin" landed under modules.blog, "" mounted views at the root where none resolve, and "Blog" failed later in vite:inputs. Register now fails with ModularModuleNameNotKebab. modular.ValidName holds the pattern make:module already used. modular:publish --module also tries the kebab-case name, like the --module option of the make:* commands: --module BlogPosts finds blog-posts.
Switching variants with --force (e.g. --plain on a full module) removed every stub path the new variant does not write, including files the user had edited since: controllers, providers, routes, views. A stale stub is now removed only when its content is still that of a generated variant (full, --api or --plain). Edited files are kept and reported with a warning.
listeners configureEventListeners appended module listeners to the map WithEvents returned. When that function returns the same map on every call, each Build() (Restart rebuilds) added the module listeners again, so they fired two, three, ... times; append could also write into the app's slices. The listeners are now merged into a copy with slices.Concat.
vite:inputs checked module aliases with CleanRelative but joined ViteEntries onto the module directory as given, so "../../x.js" or "/x.js" reached Vite as a build input outside the module. Entries now go through the same check and fail with ModularViteEntryInvalid; "" and "." are rejected too.
|
@goravel-agent analyze |
|
@protibimbok Could you please update the PR description to show the new feature? |
🔎 PR analysis1. Breaking changes — 5 found
2. How to use itModule registration: Before: not available — a package's routes, config, views, migrations, jobs and commands were each wired by hand in After: // bootstrap/modules.go
func Modules() []modular.Module {
return []modular.Module{
&blog.Module{},
}
}
// bootstrap/app.go
func Boot() contractsfoundation.Application {
return foundation.Setup().
WithConfig(config.Boot).
WithModules(Modules).
Start()
}The Before: After: Module scaffolding and commands: Before: not available — the directory, the config file and the bootstrap entry were written by hand. After: Module routes and static files: Before: not available — routes and static files came only from After: func (m *Module) Routes(router route.Router) {
router.Get("/blog", func(ctx http.Context) http.Response {
return ctx.Response().Success().Json(http.Json{"module": "blog"})
})
}Module views and translations: Before: not available — a package mounted its views and translations through a service provider of its own with After: //go:embed resources/views
var views embed.FS
func (m *Module) Views() fs.FS {
return views
}Vite aliases: Before: After: the alias table goravel-vite-plugin publishes — manifest key tags, err := facades.Vite().Tags("@blog/resources/js/app.js")3. Laravel comparison — Deviates
func Modules() []modular.Module {
return []modular.Module{&blog.Module{}}
}
func (m *Module) Routes(router route.Router) {
router.Get("/blog", func(ctx http.Context) http.Response {
return ctx.Response().Success().Json(http.Json{"module": "blog"})
})
}$this->loadRoutesFrom(__DIR__.'/../routes/web.php');
return ctx.Response().View().Make("blog/index.tmpl")return view('courier::dashboard');
facades.Lang(ctx).Get("blog/messages.welcome")echo __('courier::messages.welcome');
tags, err := facades.Vite().Tags("@blog/resources/js/app.js") |
📑 Description
Implements goravel/goravel#1030
✅ Checks