Xgenious/ docs

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

OldNew
core/Modules/<Name>/plugins/<slug>/
module.jsonplugin.json
modules_statuses.jsonplugins/statuses.json
moduleExists('Name')plugin_is_active('slug')
Service provider registrationAuto-wired by the bridge
Providers/<Name>ServiceProvider.phpsrc/<Name>Plugin.php implementing PluginInterface
Module routesroutes/web.php, routes/api.php
Module viewsviews/, namespace = slug
Module migrationsdatabase/migrations/
Module configconfig/<slug>.php
Module menus in module.jsonplugin.json → xilancerMetaData.admin_settings.menu_item
Blade hooks in module.jsonplugin.json → xilancerMetaData.hooks
Page-builder addonsplugin.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::resource closures 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-module for config.
  • mymodule:: and mymodule for the legacy namespaces when legacyName is set.

You can migrate blades gradually. New code should use the slug namespace.

Step 6 - Convert hooks and 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:

  1. Keep the table names. Renaming them risks data loss and breaks old reports.
  2. Copy the migrations into database/migrations/ unchanged.
  3. On activation, artisan migrate sees the migration as already applied if the filename matches the migrations table entry.
  4. 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:

  1. Deactivate the old module in modules_statuses.json.
  2. Remove core/Modules/MyModule.
  3. Remove its service provider from config/app.php if it was registered there.
  4. Clear caches.
  5. 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-module passes.
  • php artisan plugins activate my-module succeeds.
  • php artisan plugins list shows 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-module and 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

ErrorCauseFix
Class not foundNamespace still Modules\...Update namespace and PSR-4 map
View not foundNamespace still mymodule:: and no legacyNameSet legacyName or update the view call
Config nullFile named config.phpRename to <slug>.php
Route 404Route file still in Routes/web.phpMove to routes/web.php
Menu missingMenu still in module.jsonMove to xilancerMetaData
Hook fires twiceOld provider still registeredRemove the provider from config/app.php
Migration re-runsFilename changedMake idempotent or mark applied
moduleExists() falsePorted module is not a core moduleUse plugin_is_active()
Assets 404Public 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.
  • legacyName set if old namespaces are still referenced.
  • Menus and hooks moved into xilancerMetaData.
  • moduleExists() replaced with plugin_is_active().
  • Migrations idempotent or filename-preserving.
  • Old module removed.
  • Validator passes.
  • Smoke tests pass.

Last updated: October 21, 2026

Still stuck?
Our support team is ready to help you get set up.
Get support