Legacy Module Migration
Before 4.2.0, premium features lived in core/Modules with a module.json and an
modules_statuses.json flag. The /plugins framework replaces that layout. This page is the
migration path for a module you maintain.
The eight ported premium modules are the reference: cloud-storage, coin-payment-gateway,
community, coupons, freelancer-level, hourly-job, promote-freelancer, and
security-manage.
What changes
| Old | New |
|---|---|
core/Modules/<Name>/ | plugins/<slug>/ |
module.json | plugin.json |
modules_statuses.json | plugins/statuses.json |
moduleExists('Name') | plugin_is_active('slug') |
| Service provider registration | Auto-wired by the bridge |
Providers/<Name>ServiceProvider.php | src/<Name>Plugin.php implementing PluginInterface |
| Module routes | routes/web.php, routes/api.php |
| Module views | views/, namespace = slug |
| Module migrations | database/migrations/ |
| Module config | config/<slug>.php |
Module menus in module.json | plugin.json → xilancerMetaData.admin_settings.menu_item |
Blade hooks in module.json | plugin.json → xilancerMetaData.hooks |
| Page-builder addons | plugin.json → xilancerMetaData.pageBuilderAddon |
There is no module.json in a plugin. Shipping one does nothing.
Step 1 - Create the plugin directory
mkdir -p plugins/my-module/src
Choose a slug: lowercase, numbers, dashes. The directory name should match the slug.
Step 2 - Convert the manifest
Old module.json:
{
"name": "My Module",
"alias": "mymodule",
"description": "…",
"providers": ["Modules\\MyModule\\Providers\\MyModuleServiceProvider"],
"files": []
}
New plugin.json:
{
"name": "My Module",
"slug": "my-module",
"version": "1.0.0",
"entry": {
"file": "src/MyModulePlugin.php",
"class": "MyModule\\MyModulePlugin"
},
"autoload": {
"psr-4": {
"MyModule\\": "src/"
}
},
"requires": {
"framework": ">=1.0",
"php": ">=8.3"
},
"provides": [],
"capabilities": [],
"legacyName": "MyModule",
"xilancerMetaData": {
"plugin_type": "external",
"admin_settings": {
"menu_item": [
{
"id": "my-module-menu",
"label": "My Module",
"route": "admin.my-module.index",
"parent": null,
"icon": "fa-solid fa-star",
"permissions": "my-module-manage",
"class": "bg-danger"
}
]
}
}
}
legacyName
Set legacyName to the old module name, exactly as it appeared in module.json. It preserves:
- The lowercased legacy view namespace:
mymodule::panel. - The lowercased legacy translation namespace.
- The lowercased legacy config key:
config('mymodule.key').
It does not make moduleExists('MyModule') return true. moduleExists() only reads core
modules. Replace those checks with plugin_is_active('my-module').
Step 3 - Convert the service provider
Old:
namespace Modules\MyModule\Providers;
use Illuminate\Support\ServiceProvider;
class MyModuleServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->loadViewsFrom(__DIR__ . '/../Resources/views', 'mymodule');
$this->loadMigrationsFrom(__DIR__ . '/../Database/Migrations');
$this->loadRoutesFrom(__DIR__ . '/../Routes/web.php');
view()->composer('*', function ($view): void {
$view->with('myModule', app(MyModule::class));
});
}
}
New:
namespace MyModule;
use PluginsFramework\Plugins\PluginContext;
use PluginsFramework\Plugins\PluginInterface;
final class MyModulePlugin implements PluginInterface
{
public function __construct(private readonly PluginContext $ctx)
{
}
public function boot(): void
{
// Views, migrations, routes, lang, and config are auto-wired.
// Register only hooks, slots, and registries here.
}
}
Remove:
loadViewsFrom,loadMigrationsFrom,loadRoutesFrom,loadTranslationsFrom,mergeConfigFrom- the bridge does these.- Global view composers - use slots instead.
- Provider
register()bindings - use explicit construction or a small container binding in your own code if you truly need one.
Keep:
- Hook registrations.
- Slot registrations.
- Registry entries.
- Permission seeding, in a command or migration.
Step 4 - Move routes
Old Routes/web.php may rely on the module's automatic prefix and middleware. Plugins own their
prefixes.
Route::group([
'prefix' => 'my-module',
'as' => 'my-module.',
'middleware' => ['auth', 'userEmailVerify', 'Google2FA'],
], static function (): void {
Route::get('/', [DashboardController::class, 'index'])->name('index');
});
Rules:
- Replace
Route::resourceclosures with controller classes. - Add
->permission('my-module-manage')to admin routes. - Move server callbacks to
routes/api.php. - Keep route names stable so menus and old links do not break.
If old links used /mymodule/..., either keep the prefix or add a redirect route.
Step 5 - Move views and config
plugins/my-module/
├── views/ (was Resources/views)
├── lang/ (was Resources/lang)
├── database/migrations/ (was Database/Migrations)
└── config/
└── my-module.php (was Config/config.php or mymodule.php)
The bridge registers:
my-module::for views and translations.plugins.my-modulefor config.mymodule::andmymodulefor the legacy namespaces whenlegacyNameis set.
You can migrate blades gradually. New code should use the slug namespace.
Step 6 - Convert hooks and menus
Menus
Old module.json:
{
"admin_settings": {
"menu_item": [
{ "id": "my-module-menu", "label": "My Module", "route": "admin.my-module.index" }
]
}
}
New: the same admin_settings object under xilancerMetaData in plugin.json.
Blade hooks
Old:
{
"hooks": {
"body_end": ["assets.banner"]
}
}
New: the same hooks object under xilancerMetaData.
The renderer resolves my-module::assets.banner (or the legacy namespace).
Page-builder addons
Old:
{
"pageBuilderAddon": [
{ "addon": "Modules\\MyModule\\PageBuilder\\HeroBlock" }
]
}
New:
"xilancerMetaData": {
"pageBuilderAddon": [
{ "addon": "MyModule\\PageBuilder\\HeroBlock" }
]
}
Update the namespace to the plugin namespace.
Step 7 - Replace core checks
Search core and theme code for:
grep -rn "moduleExists('MyModule')" core/app core/Modules core/resources
Replace with:
plugin_is_active('my-module')
For gateways, use:
plugin_gateway_declared('MyGateway')
Do not leave both checks. A ported module is no longer a core module, so moduleExists() returns
false even when the plugin is active.
Step 8 - Handle the database
If the module already created tables:
- Keep the table names. Renaming them risks data loss and breaks old reports.
- Copy the migrations into
database/migrations/unchanged. - On activation,
artisan migratesees the migration as already applied if the filename matches themigrationstable entry. - If the filename changed, mark it manually or make the migration idempotent:
if (!Schema::hasTable('my_module_items')) {
Schema::create('my_module_items', function (Blueprint $table): void {
// …
});
}
Do not drop and recreate a table with production data.
Step 9 - Remove the old module
Only after the plugin is active and verified:
- Deactivate the old module in
modules_statuses.json. - Remove
core/Modules/MyModule. - Remove its service provider from
config/app.phpif it was registered there. - Clear caches.
- Run the site's smoke tests.
Keep a backup of the old module directory until the next release.
Step 10 - Verify
-
php artisan plugins validate my-modulepasses. -
php artisan plugins activate my-modulesucceeds. -
php artisan plugins listshows no problem. - Admin menu items appear with the right permission.
- Frontend routes resolve.
- Views render in both new and legacy namespaces.
- Config reads under
plugins.my-moduleand the legacy key. - Migrations are marked applied, not re-run.
- Hooks fire once, not twice.
- Old links redirect or still work.
- No
moduleExists()check remains for this feature.
Common migration errors
| Error | Cause | Fix |
|---|---|---|
Class not found | Namespace still Modules\... | Update namespace and PSR-4 map |
| View not found | Namespace still mymodule:: and no legacyName | Set legacyName or update the view call |
| Config null | File named config.php | Rename to <slug>.php |
| Route 404 | Route file still in Routes/web.php | Move to routes/web.php |
| Menu missing | Menu still in module.json | Move to xilancerMetaData |
| Hook fires twice | Old provider still registered | Remove the provider from config/app.php |
| Migration re-runs | Filename changed | Make idempotent or mark applied |
moduleExists() false | Ported module is not a core module | Use plugin_is_active() |
| Assets 404 | Public path assumed Modules/... | Publish under plugins/<slug>/assets or use asset() |
Should you port?
Port when:
- You want the plugin to survive core updates.
- You want admin activation and validation.
- You want to distribute it as a zip.
- You want hooks and slots instead of core edits.
Do not port when:
- The feature is tightly coupled to core internals and has no seam.
- It needs a core schema change.
- It is a one-off site customization that will never be shared.
In those cases, keep it in core and document the coupling. A half-ported plugin is worse than an honest module.
Porting checklist
- Manifest converted, no
module.json. - Entry class implements
PluginInterface. - Namespaces match PSR-4.
- Routes moved and prefixed.
- Admin routes permission-gated.
- Views, lang, migrations, config moved.
-
legacyNameset if old namespaces are still referenced. - Menus and hooks moved into
xilancerMetaData. -
moduleExists()replaced withplugin_is_active(). - Migrations idempotent or filename-preserving.
- Old module removed.
- Validator passes.
- Smoke tests pass.
Last updated: October 21, 2026

