In TruCMS a plugin is a Laravel module that the in-house Plugin Manager can install, activate, deactivate and delete. Booking, TruCommerce, TruPayments and the four payment gateways are built exactly this way, so everything they do, your plugin can do.
In the user guide: the Plugins chapter.
They are the same thing seen from two sides:
Modules/{Name} with a module.json, managed by nwidart/laravel-modules (v13). Whether it is loaded is a boolean in modules_statuses.json.App\Services\Plugins\*).php-fpm starts the app
bootstrap/providers.php
... AppServiceProvider, DotartisanServiceProvider, WidgetsServiceProvider
LaravelModulesServiceProvider
reads modules_statuses.json, takes every module that is "true"
for each, in "priority" order, registers the providers named in module.json
YourServiceProvider::register() bindings, config merge
(all providers registered)
YourServiceProvider::boot() views, translations, migrations, routes (child provider),
breadcrumbs, dashboard rows, shortcodes, widgets,
Event::listen(AdminBooting::class, ...)
$app->booted(...) WidgetsServiceProvider loads saved widget instances
FrontendRoutingServiceProvider adds the catch-all slug route
an ADMIN request additionally runs:
AdminTheme middleware -> core menu + settings registered -> event(AdminBooting)
-> YOUR listener registers sidebar items and settings sections
-> your controller -> your Blade view inside <x-admin-layout>An inactive module is simply never registered: no routes, menus, widgets, schedules or listeners. Its tables are left untouched.
| Action | Permission | What happens (class) |
|---|---|---|
| Install (upload a ZIP) | install plugins | PluginArchiveValidator inspects and safely extracts the archive to a private temporary folder, PluginMetadataReader validates module.json, PluginDependencyResolver checks installation dependencies, then PluginInstaller moves the folder to Modules/{Name}, records "{Name}": false in modules_statuses.json and runs optimize:clear. Nothing from the upload is executed and the plugin is always installed inactive |
| Activate | activate plugins | Checks every required plugin is installed, active and satisfies its version constraint, flips the flag with $module->enable(), runs optimize:clear. Any failure restores the previous modules_statuses.json snapshot |
| Deactivate | activate plugins | Refused while an active plugin depends on it or the dependency graph cannot be read; otherwise flag off and optimize:clear |
| Delete | delete plugins | Only an inactive, non-protected plugin with no installed dependents. Moves the folder to a private trash folder first (with rollback), removes its status entry, clears caches, then deletes the trash. The database tables are never dropped |
| List | manage plugins | PluginManager::listing() returns, per plugin, the metadata, active, protected, and the flags can_activate, can_deactivate, can_delete plus human-readable issues |
All operations take a cache lock (plugin-management, 120 seconds, config('plugins.lock_seconds')) so two administrators cannot change plugin state at the same time, and failures are reported as PluginOperationException with a translatable message key (lang/en/plugins.php).
What the Plugin Manager does not do
composer dump-autoload. A module's PSR-4 map reaches the autoloader through wikimedia/composer-merge-plugin (configured with Modules/*/composer.json) only when Composer regenerates the autoload files. A new plugin's classes are not loadable until you run composer dump-autoload on the server.php artisan migrate (module migration paths are auto-discovered) after activating, and the plugin's seeder if it has one. The core update pipeline runs migrate --force for you.vite.config.js and built (see Build and assets), or you must ship pre-built assets.down() or a command.requires in module.json maps a plugin alias to a Composer version constraint ({"trupayments": "^1.0"}); a plain list of aliases means "any version". The legacy key requirements is still read.Composer\Semver\Semver).module.json in the graph blocks deactivation and deletion until fixed."protected": true in the manifest or listed in config/plugins.php → protected: booking, trucommerce, trupayments) can never be deleted from the UI.Module facade: requirements($name), requiredBy($name), withRequirements(), paymentModules() (modules with "payments": true).config/plugins.php)| Setting | Default |
|---|---|
archive.max_upload_kilobytes | 20 480 (20 MB) |
archive.max_files / max_entries | 2 500 files / 3 000 entries |
archive.max_uncompressed_bytes | 104 857 600 (100 MB) |
archive.max_path_length | 240 characters |
temporary_path / trash_path | storage/app/private/plugin-installer / plugin-trash |
lock_seconds | 120 |
The validator also rejects: absolute or .. paths, Windows reserved device names, symlinks and special files, duplicate entries (case-insensitively), more than one module.json, a composer.json whose PSR-4 does not map Modules\{Name}\ to app/, and providers that do not exist as files.
Everything below is registered from the plugin's service provider; none of it needs a core edit.
| Capability | API | Guide |
|---|---|---|
| Own database tables | Migrations in database/migrations | Models, migrations and media |
| Admin screens and routes | Route provider + AdminTheme + ->can() | Routes, controllers and views |
| Public pages and URLs | FrontTheme routes, public_paths, HasSlug | Page types and public URLs |
| Sidebar entries, dashboard cards | MenuManager, Dashboard | Admin menu and dashboard |
| Settings screens | SettingsManager | Settings screens |
| Permissions and roles | Permission migrations/seeders | Permissions and roles |
| Page-builder blocks | Shortcode registry | Shortcodes |
| Data-bound sections | BindingSourceRegistry | Data-bound sections |
| Widgets | Widgets::register() | Widgets and widget areas |
| Menu-builder link sources | MenuBuilder::register() | Menus and navigation |
| Page types ("Shop page") | AppPages::register() | Page types and public URLs |
| Template-library layouts | Module resources/page-templates + seeder | Template library |
| Payment gateways, payments | TruPayments contracts | Payments and gateways |
| Events, jobs, schedules, notifications | Listeners, configureSchedules() | Events, jobs and schedules |
| Translations | lang/ with namespace | Localization |
| Cache invalidation | BaseModel, ResponseCacheInvalidator | Caching and performance |
app/ to wire your plugin in. If you find you must, a hook is missing — see Known quirks for the places the core is not fully decoupled yet.access {alias} permission on the route group (as TruCommerce and TruPayments do) plus a permission per action.boot(), and register admin-only things on AdminBooting.Schema::hasTable, seeders use updateOrCreate).