Skip to main content
WintertraceLive demo
Browse the documentation

Building modules

The module manifest (module.json)

Every module is described by a module.json. The fields it must declare, what each one controls, and how the manifest drives discovery and compatibility.

Every module needs a module.json in its root directory. This file is the module’s identity card: it tells the ModuleManager who the module is, which class boots it, and what it depends on. Without it, the directory is simply skipped during discovery.

Full schema

{
    "name": "My Module",
    "version": "1.0.0",
    "namespace": "Schneespur\\Module\\MyModule",
    "service_provider": "Schneespur\\Module\\MyModule\\MyModuleServiceProvider",
    "description": "Short description of what this module does.",
    "min_schneespur_version": "1.0.0",
    "requires_permissions": [],
    "default_enabled": false,
    "requires": {},
    "conflicts": []
}

Field reference

FieldTypeRequiredDescription
namestringyesHuman-readable module name. Shown in admin UI.
versionstringyesSemantic version (e.g. 1.2.3)
namespacestringyesPHP namespace for PSR-4 autoloading. Must match directory structure under src/.
service_providerstringyesFully-qualified class name of the module’s ServiceProvider
descriptionstringrecommendedShort description. Shown in module management UI.
min_schneespur_versionstringrecommendedMinimum compatible Schneespur/Wintertrace version
requires_permissionsstring[]optionalCore permissions the module needs to function
default_enabledbooleanoptionalNot read by the core. See the note below
requiresobjectoptionalDependency map: {"other-module": "^1.0.0"}
conflictsstring[]optionalModule slugs that cannot be active simultaneously

default_enabled has no effect. No code path reads it. Both routes that create a modules row — installing through the admin interface, and the install branch of ModulesSync — write enabled = true regardless. Since 1.2.0 that row is the only thing that decides whether a module boots at all; see Lifecycle.

So setting it to false and expecting the module to install dormant will not do what you want. The field is kept because manifests in the wild carry it. Treat it as documentation of intent, not as behaviour.

Example from the reference module

{
    "name": "Example Module",
    "version": "1.0.0",
    "namespace": "Schneespur\\Module\\Example",
    "service_provider": "Schneespur\\Module\\Example\\ExampleServiceProvider",
    "description": "Reference module demonstrating all extension points.",
    "min_schneespur_version": "1.0.0",
    "requires_permissions": [],
    "default_enabled": false,
    "requires": {},
    "conflicts": []
}

Naming conventions

  • Module slug: The directory name under modules/. Use lowercase with hyphens: my-module, telegram-notifications
  • Namespace: Schneespur\Module\{PascalCaseName} — e.g. Schneespur\Module\TelegramNotifications
  • ServiceProvider class: {PascalCaseName}ServiceProvider — e.g. TelegramNotificationsServiceProvider

These three are linked: the slug is the directory name, the namespace determines the autoloader path, and the ServiceProvider class is the entry point named in the manifest. If any of these diverges from the directory structure, the autoloader cannot find the class.

Localised names and descriptions

For multi-language installations, name and description can be objects instead of strings:

{
    "name": {
        "de": "Telegram-Benachrichtigungen",
        "en": "Telegram Notifications"
    },
    "description": {
        "de": "Sendet Einsatz-Benachrichtigungen über Telegram.",
        "en": "Sends job notifications via Telegram."
    }
}

The Module model’s pickLocalized() method resolves to the current app locale or falls back to English.

Version constraints in requires

PatternExampleMatches
*"*"Any version
>=X.Y.Z">=2.0.0"2.0.0, 2.1.0, 3.0.0, …
^X.Y.Z"^1.2.0"1.2.0, 1.3.0, 1.99.0 (not 2.0.0)
~X.Y.Z"~1.2.0"1.2.0, 1.2.1, 1.2.99 (not 1.3.0)

These constraints are validated by the DependencyValidator before enabling — see Module lifecycle — Dependency validation.