Module Development Guide
A module is a standalone Composer package that plugs into the core app. This page covers the shape a module needs and how it hooks into the rest of the platform.
Package layout
ZerpPackages/product-service/
├── composer.json
├── module.json
└── src/
├── Providers/
│ ├── ProductServiceServiceProvider.php
│ └── EventServiceProvider.php
├── Listeners/
├── Http/Controllers/
└── ...
composer.json — standard Composer package metadata, PSR-4 autoload, and a
Laravel extra.laravel.providers entry so the core app auto-discovers the
service provider:
{
"name": "zerp/product-service",
"type": "library",
"autoload": {
"psr-4": {"Zerp\\ProductService\\": "src/"}
},
"extra": {
"laravel": {
"providers": ["Zerp\\ProductService\\Providers\\ProductServiceServiceProvider"]
}
}
}
module.json — metadata the core app reads at seed/runtime (not a Laravel
convention, a Zerp-specific file App\Classes\Module and PackageSeeder
look for):
{
"name": "ProductService",
"alias": "Product & Service",
"priority": 0,
"version": 5.0,
"monthly_price": 0,
"yearly_price": 0,
"display": false,
"package_name": "product-service"
}
How a module gets registered
database/seeders/PackageSeeder.php (core app) scans vendor/zerp/* (and
the legacy in-repo packages/local/* path) for a module.json in each
directory, and for every one it finds:
- Creates an
AddOnrow (module, price, priority) if one doesn't exist —is_enableis set based on whether the module was selected duringapp:install(see below); every discovered module gets a row either way, enabled or not. - If enabled: activates it for the seeded user via
UserActiveModule. - If enabled: runs
php artisan package:seed <ModuleName>if the module defines one.
This runs automatically as part of php artisan app:install, which
prompts for which modules to enable (a preset bundle or a custom
selection) — see Getting Started.
Cross-module integration: events, not direct calls
Modules never call into each other directly. The core app fires domain
events (app/Events/*.php — CreatePurchaseInvoice, CreateSalesInvoice,
CreateUser, CreateWarehouse, etc.) and modules that care register a
listener via their own EventServiceProvider:
// ZerpPackages/product-service/src/Providers/EventServiceProvider.php
protected $listen = [
PostPurchaseInvoice::class => [
PostPurchaseInvoiceListener::class,
],
];
// ZerpPackages/product-service/src/Listeners/PostPurchaseInvoiceListener.php
class PostPurchaseInvoiceListener
{
public function handle(PostPurchaseInvoice $event)
{
// e.g. adjust WarehouseStock based on the posted invoice
}
}
This is how product-service adjusts warehouse stock when an invoice is
posted, how calendar/slack/telegram react to the same invoice events
to create calendar entries or send notifications, and so on — check
app/Events/ in the core app for the full list of events you can listen
for before adding a new one.
Permissions and plan gating
Zerp uses Spatie's laravel-permission for roles/permissions, plus a plan/module-enable layer on top:
AddOn+UserActiveModule(seeded byPackageSeeder, see above) determine whether a module is enabled for a given company at all.App\Classes\Module::isEnabled($name)/allEnabled()check this at runtime — gate any module-specific route/controller/view behind it if the module can be toggled off.- Regular Spatie roles/permissions (
client,staff, custom roles) then control what an individual user can do within an enabled module.
Publishing your module
Once a module works locally against its path repository, cut a tagged release so the core app (and anyone else) can depend on a real version instead of a local symlink — see Publishing a Module Package.