Skip to content

feat: modular (ddd) implementation - #1613

Open
protibimbok wants to merge 29 commits into
masterfrom
feat/modular
Open

protibimbok wants to merge 29 commits into
masterfrom
feat/modular

Conversation

@protibimbok

@protibimbok protibimbok commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

📑 Description

Implements goravel/goravel#1030

✅ Checks

  • Added test cases for my code

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.
@protibimbok
protibimbok requested a review from a team as a code owner November 11, 2026 15:35
@codecov

codecov Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.36125% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.64%. Comparing base (82c6a43) to head (36cf220).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
packages/modify/utils.go 98.00% 2 Missing ⚠️
packages/modify/with_slice_actions.go 90.00% 2 Missing ⚠️
modular/console/init_command.go 98.27% 1 Missing ⚠️
modular/console/list_command.go 98.59% 1 Missing ⚠️
modular/console/module_make_command.go 99.35% 1 Missing ⚠️
modular/publish.go 99.12% 1 Missing ⚠️
modular/static.go 97.05% 1 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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.
@hwbrzzl

hwbrzzl commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

@goravel-agent analyze

@hwbrzzl

hwbrzzl commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

@protibimbok Could you please update the PR description to show the new feature?

@goravel-agent

Copy link
Copy Markdown
Contributor

🔎 PR analysis

1. Breaking changes — 5 found

  • contracts/database/migration.Migrator gained CreateIn(dir, name, modelName) → 1 in-repo implementor plus its mock; every implementor outside the module stops compiling → add the method and return Create(name, modelName) when dir is empty.
  • contracts/foundation.Application gained MakeModular() modular.Registry → 1 in-repo implementor plus its mock; test fakes outside the module stop compiling → return nil where no registry is bound.
  • contracts/foundation.ApplicationBuilder gained WithModules(func() []modular.Module) → 1 in-repo implementor plus its mock; builder fakes outside the module stop compiling → return the receiver unchanged.
  • vite/console.NewInputsCommand gained a fourth argument, modules modular.Registry → 1 caller and 11 test call sites → pass app.MakeModular().
  • ProviderRepository now keys a provider by its full package path instead of its short type name → 0 known callers; an app that registers two providers with the same short name boots both, where one was silently dropped → nothing to change.

2. How to use it

Module registration:

Before: not available — a package's routes, config, views, migrations, jobs and commands were each wired by hand in bootstrap/app.go and one service provider of its own.

After: WithModules takes the module list; every capability interface a module implements (Routes, ApiRoutes, Config, Views, Translations, Migrations, Seeders, Commands, Jobs, Events, Schedule, GlobalMiddleware, ViteEntries, ViteAliases, Static, ServiceProviders) is merged into the build, and Order() decides the order.

// 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 --module make commands:

Before: make:* wrote every file under the app's own paths, so make:controller BlogController produced app/http/controllers/blog_controller.go.

After: --module=blog writes into the module instead, modules/blog/http/controllers/blog_controller.go, and make:console, make:job, make:seeder, make:migration and make:provider also append the new type to the module's Commands(), Jobs(), Seeders(), Migrations() or ServiceProviders(). A third-party module is refused, only the app's own modules take generated files.

Module scaffolding and commands:

Before: not available — the directory, the config file and the bootstrap entry were written by hand.

After: modular:init writes modules/.gitkeep, config/modular.go and bootstrap/modules.go, adds .WithModules(Modules) to the chain and a //go:generate go run . artisan modular:publish line to main.go; make:module blog scaffolds a module and registers it in that list; modular:list (--json) prints each module with its capabilities; modular:publish copies a third-party module's sources into modules/blog.

Module routes and static files:

Before: not available — routes and static files came only from WithRouting and the app's own router.

After: Routes(router) runs inside the modular.routes.web group and ApiRoutes(router) inside modular.routes.api, with prefix "" and "api" and the middleware listed in config/modular.go; Static() maps a URL prefix to a directory and those files are served before the module routes.

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 LoadViewsFromFS.

After: Views() is mounted under the module name, so the template is rendered as blog/index.tmpl; Translations() turns en/messages.json into the group blog/messages, read as blog/messages.welcome. The app's own lang filesystem still wins for a name both define.

//go:embed resources/views
var views embed.FS

func (m *Module) Views() fs.FS {
	return views
}

Vite aliases:

Before: {{ vite }}, {{ viteAsset }} and Vite.Tags took a path from the project root; a leading @ was not resolved.

After: the alias table goravel-vite-plugin publishes — manifest key goravel:aliases for a build, the hot file in dev — resolves @blog/... to the module's directory; ViteEntries() are built even when no view references them, and ViteAliases() adds keys such as @blog-assets.

tags, err := facades.Vite().Tags("@blog/resources/js/app.js")

3. Laravel comparison — Deviates

No Laravel equivalent — WithModules(func() []modular.Module) (laravel/framework@13.x) — Laravel has no module unit; the closest concept is a package service provider in bootstrap/providers.php, so a feature that is one module here is a provider plus N method calls there, https://laravel.com/docs/packages#service-providers

func Modules() []modular.Module {
	return []modular.Module{&blog.Module{}}
}

Partially matches — Routes(router route.Router) in the modular.routes.web group ↔ loadRoutesFrom(__DIR__.'/../routes/web.php') (laravel/framework@13.x) — same job, a package's routes get a group; Laravel's prefix and middleware are written in the routes file, Goravel's are read from config/modular.go, https://laravel.com/docs/packages#routes

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');

Deviates — blog/index.tmpl ↔ courier::dashboard (laravel/framework@13.x) — same job, a different address: a directory prefix here, a :: namespace there; Laravel also checks resources/views/vendor/courier first so the app can override a package view, Goravel has no override directory, https://laravel.com/docs/packages#views

return ctx.Response().View().Make("blog/index.tmpl")
return view('courier::dashboard');

Deviates — blog/messages.welcome ↔ courier::messages.welcome (laravel/framework@13.x) — same lookup, a different separator, and a module cannot contribute the * group (en.json) Laravel allows, https://laravel.com/docs/packages#translations

facades.Lang(ctx).Get("blog/messages.welcome")
echo __('courier::messages.welcome');

No Laravel equivalent — facades.Vite().Tags("@blog/resources/js/app.js") (laravel/framework@13.x) — no Laravel call resolves a Vite alias on the server; the closest concept is resolve.alias in vite.config.js, which only Vite itself reads, https://laravel.com/docs/vite#aliases

tags, err := facades.Vite().Tags("@blog/resources/js/app.js")

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants