Developer guides
Building Modules
Module manifests, discovery, registration contracts, dependencies, migrations, and lifecycle behavior.
On this page
A Module owns an optional domain
Use a Module when the feature introduces an optional commerce capability with domain rules and persistence. Digital delivery, events, domains, and provisioning are examples. Use an Extension when you are adapting a provider or contributing to an existing integration point. Use a Theme for presentation.
Core's public entrypoint is Modules/Contracts/Module.php:
interface Module
{
public function id(): string;
public function register(ModuleContext $context): void;
}
ModuleContext is deliberately independent of Livewire markup and CSS. It registers capabilities, permissions, navigation, account contributions, routes, and listeners. A Module can supply a Livewire screen, but its domain entrypoint does not have to be a screen component.
Scaffold in a development checkout
Run from Core with a valid sibling optional-packages directory configured:
AGOVENA_OPTIONAL_PACKAGES_PATH=../optional-packages
php artisan agovena:make-module workshop
The ID must match lowercase letters and numbers separated by single hyphens, beginning with a letter. The generator writes module.json, src/WorkshopModule.php, and src/WorkshopServiceProvider.php. It refuses an existing target unless --force is supplied. Use --force only if overwriting those scaffold files is intended.
Review the generated manifest before installation. The current generator writes agovena: "^0.1", while Core currently declares platform version 0.0.1 in config/agovena.php. A generated compatibility constraint is a starting point, not a guarantee it matches your checkout. Set the constraint to the versions you actually support and test.
Without a configured optional-packages root, the generator falls back to a Core modules/ folder. Runtime discovery does not automatically scan that fallback. Prefer the sibling repository, or explicitly configure the relevant extra_module_paths parent. Do not make first-party packages permanent Core subtrees.
Sources: MakeModuleCommand, ScaffoldingGenerator, and platform configuration.
Manifest contract
The Downloads manifest is a concrete example:
{
"id": "downloads",
"name": "Downloads",
"version": "1.0.0",
"description": "Downloadable file assets and customer entitlements after payment.",
"author": "Agovena",
"agovena": "^0.0.1",
"group": "commerce",
"provider": "Agovena\\Modules\\Digital\\DigitalServiceProvider",
"dependencies": []
}
| Field | Runtime meaning |
|---|---|
id, name, provider |
Required for discovery; provider is a PHP class name |
version |
Package version, default 0.0.0 |
description |
Description, default empty |
agovena |
Composer-style platform constraint, default * |
dependencies |
List of other Module IDs, not version constraints |
author |
Author label, default Agovena |
group |
Catalog grouping, default other |
autoload.psr-4 |
Optional namespace-to-relative-directory map |
The runtime supplies the manifest's filesystem path; it is not a manifest path field. If PSR-4 is omitted, discovery derives the namespace from provider and uses src/ when present. For a custom namespace, declare it explicitly, for example "Acme\\Workshop\\": "src/". Namespace prefixes must end in a backslash; relative paths containing .. are ignored by the autoloader.
Sources: Availability capability reference, ModuleManifest, and PackageAutoload.
Provider and domain registration
A conventional provider extends Laravel ServiceProvider and exposes module(): Module. Laravel's register(): void binds services; the separate domain object's register(ModuleContext $context) registers Agovena contributions. Do not combine these incompatible method signatures in one class.
The Core availability provider binds the ProductStock contract and registers the physical availability listeners. Optional packages use the same contracts through explicit integration points; they do not provide inventory themselves:
$this->app->singleton(ProductStock::class, CatalogProductStock::class);
Event::listen(OrderPlacing::class, AssertStockBeforeOrderPlacing::class);
Event::listen(OrderCreated::class, ReserveStockWhenOrderCreated::class);
Event::listen(OrderCancelled::class, RestockWhenOrderCancelled::class);
This excerpt is from AvailabilityServiceProvider and AvailabilityCapability. Physical commerce consumes this shared Core availability contract; it is not an optional package.
Context methods and route boundaries
| Context method | Contribution |
|---|---|
moduleId() |
Current package ID |
admin() |
Admin registrar for permissions and UI contracts |
capabilities() |
Product capability registry |
customerAccountNav($item) |
Customer account navigation |
customerAccountOverview($id, $factory, $sort) |
Customer-specific overview card |
listen($event, $listener) |
Core or Module event subscription |
adminRoutes($callback) |
/admin, name prefix admin., staff middleware |
customerRoutes($callback) |
/account, name prefix customer., authenticated and verified customer middleware |
apiRoutes($callback) |
/api/v1, name prefix api.v1., Sanctum, throttling, token abilities |
A navigation permission is not a substitute for authorizing the underlying page or action. The Admin route helper supplies web authentication, permission synchronization, and admin.access; package actions still need their own relevant authorization checks.
Module API routes use the Core route-name-to-ability map. The supported prefixes are subscriptions., services., downloads., digital-secrets., and event-tickets. after api.v1.. A new arbitrary prefix fails bearer requests closed unless the Core mapping is extended deliberately. See ModuleContext and authentication.
Discovery and lifecycle
ModuleManager discovers installed-package storage first, then the optional repository, then extra roots. Each root contains child directories with module.json. The first matching ID wins.
- Discover: read manifests and register autoload mappings. Discovery does not enable the package.
- Install: validate platform compatibility and dependency discovery, create an installed-but-disabled record, and run package migrations. Installation does not automatically install dependencies.
- Enable: require installation and enabled dependencies, run pending migrations, persist enablement, register the provider, and call the Module contract once per manager runtime.
- Disable: mark the Module and dependent Modules disabled, and disable Extensions that depend on them. Preserve tables and records. Newly booted runtimes no longer register the disabled Module.
- Upgrade:
migrateInstalled()applies pending migrations to installed Modules, including disabled ones. - Uninstall: remove the installation marker. Data is not dropped.
purgeData: trueis rejected because generic purging is not implemented.
Keep package migrations in database/migrations. Use the supported manager and upgrade flow instead of assuming that toggling enablement or deleting a folder reverses schema changes.
Verify a Module change
Write tests for discovery, enabled capability registration, unavailable behavior while disabled, ownership, domain listeners, and preservation of data after disablement for optional packages. Core capabilities such as availability, physical commerce, and recurring billing are tested directly in Core and are not enabled through ModuleManager.
php artisan test tests/Feature/ModuleRuntimeTest.php
php artisan test tests/Feature/ModuleMatrixTest.php
ModuleRuntimeTest verifies that Inventory stock lives outside products, is decremented during ordering, cannot become oversold in its tested scenario, and survives disablement. Follow contribution and testing for the full verification workflow.