Docs LATEST

Modules

Module manifests, auto-loading conventions, and tenant-aware module tooling.

Architecture

Modules are the main extension mechanism in VelvetCMS Core. A module can contribute services, routes, views, public assets, commands, and config without changing Core itself.

Module Structure #

Blog/
├── module.json
├── src/
│   └── BlogModule.php
├── config/
│   └── editor.php
├── routes/
│   ├── web.php
│   └── api.php
├── resources/
│   └── views/
│       └── dashboard.velvet.php
└── public/
    └── app.css

Only module.json and a valid entry class are required. The other directories are conventions Core understands automatically.

Module Manifest #

Every module needs a module.json file. The manifest lists dependencies, optional routes, views, commands, and other metadata parsed by ModuleManifest.

{
  "name": "blog",
  "version": "1.0.0",
  "entry": "Vendor\\Blog\\BlogModule",
  "description": "Blog tools for VelvetCMS Core",
  "stability": "stable",
  "requires": {
    "core": "^2.4"
  },
  "commands": {
    "blog:reindex": "Vendor\\Blog\\Commands\\ReindexCommand"
  },
  "routes": {
    "web": "routes/web.php"
  },
  "views": "resources/views"
}

Manifest Fields #

Field Required Description
name Yes Unique module identifier
entry Yes Fully qualified class name of the module entry class
version Yes Module version string
description No Human-readable description
stability No stable, beta, rc, and so on
requires No Dependencies as name → semver constraint (core, php, other modules)
conflicts No Modules that cannot coexist with this package
provides No Capability tags
commands No Map of CLI signature → command class FQCN
routes No { "web": "routes/web.php", "api": "routes/api.php" } style map
views No Relative path registered as <module-name>:: view namespace

Only keys understood by ModuleManifest survive compilation verbatim. Arbitrary authoring metadata belongs in extra:

"extra": {
  "marketing": {
    "homepage": "https://example.com"
  }
}

Dependency Constraints #

Constraints are parsed with composer/semver, so you can use standard Composer-style version ranges:

  • >=1.0.0 - minimum version
  • <2.0.0 - maximum version
  • >=1.0.0 <2.0.0 - range
  • ^2.4 - compatible within Core major series 2.*
  • ~2.4.0 - compatible within Core minor line 2.4.*

Entry Class #

The manifest entry class is instantiated by the module manager and must implement the module contract used by Core. In practice, most modules use the base module support from Core rather than implementing everything from scratch.

The entry class is where you keep module-specific service bindings or event listeners that do not fit the built-in conventions.

namespace Vendor\Blog;

use VelvetCMS\Core\Application;
use VelvetCMS\Core\BaseModule;

final class BlogModule extends BaseModule
{
    public function register(Application $app): void
    {
        $app->singleton('blog.search', fn () => new SearchIndex(storage_path('blog')));
    }

    public function boot(Application $app): void
    {
        $app->get('events')->listen('page.saved', function ($page) {
            // React to page changes.
        });
    }
}

Discovery #

Modules are discovered from multiple sources:

  1. config/modules.php explicit module entries
  2. filesystem paths and glob patterns from modules.paths
  3. Composer packages with the velvetcms-module package type
  4. tenant-specific paths from modules.tenant_paths when tenancy is enabled

See Modules Configuration for the scan paths and tenant-specific discovery rules.

Convention-Based Auto-Loading #

VelvetCMS Core 2.x progressively reduced boilerplate modules need in boot(). The conventions below stabilized early in 2.1 and still apply today:

Config #

If a module contains config/*.php, those files are registered automatically under the module namespace.

config('blog:editor.enabled');
config('blog:editor.toolbar');

The separator stabilized in 2.1. Module config uses namespace:file.key, not namespace.file.key.

Historically modules merged config arrays by hand inside register(). Core auto-registers config/*.php under the module namespace and mergeConfigFrom() was removed accordingly.

Views #

If resources/views/ exists, Core exposes it automatically as a namespaced view path:

echo view('blog::dashboard', ['stats' => $stats]);

Routes #

If routes/web.php or routes/api.php exist, Core requires them automatically during module boot.

To opt out, disable route auto-loading in the manifest:

{
    "autoload": {
        "routes": false
    }
}

Commands #

Commands can be declared directly in the manifest instead of being registered imperatively through events:

{
    "commands": {
        "blog:reindex": "Vendor\\Blog\\Commands\\ReindexCommand"
    }
}

Core registers those signatures during the commands.registering phase.

Compiled Manifest #

Module discovery is compiled into tenant-aware artifacts for faster startup. The exact file locations depend on tenancy, but the workflow is consistent:

  • ./velvet module:compile validates manifests and writes compiled metadata.
  • ./velvet module:provision migrates global module artifacts into tenant scope when needed.

Compiled output contains the resolved module list, dependency order, and generated autoload metadata. Runtime loading reads those artifacts instead of re-scanning every module directory on every request.

Load Order #

During compilation and boot, the module manager:

  1. collects all discovered modules
  2. validates manifests and version constraints
  3. filters disabled modules
  4. sorts modules by dependency order
  5. registers modules and then boots them in order

If module A requires module B, Core will load module B first.

Tooling #

For day-to-day module work, the key commands are:

  • module:compile to validate manifests and build runtime artifacts
  • module:enable and module:disable to manage activation state
  • module:provision to migrate global module artifacts into tenant scope when needed
  • make:module to scaffold a new module

The full command reference lives in Command Reference.