Module System
Architecture decision
The rationale for using a modular monolith and domain-focused modules is described in ADR 0001: Modular monolith architecture.
Overview
Wedissimo uses a modular monolith architecture where features are organized into self-contained modules in the /modules/ directory. Each module is a Laravel package with its own structure but shares the application's dependencies.
Module Structure
modules/YourModule/
├── Config/
│ └── config.php # Module configuration
├── Database/
│ ├── Migrations/ # Module-specific migrations
│ └── Seeders/ # Module-specific seeders
├── Http/
│ ├── Controllers/ # Module controllers
│ ├── Requests/ # Form validation
│ └── Resources/ # API resources
├── Models/ # Module models
├── Notifications/ # Module notifications
├── Providers/
│ └── YourModuleServiceProvider.php
├── Routes/
│ └── api.php # Module routes
├── Services/ # Business logic
└── Tests/
├── Feature/ # Integration tests
└── Unit/ # Unit testsModule Loading
Modules are auto-loaded via Modules\common\Providers\ModuleServiceProvider:
- Reads enabled modules from
config/platform.common.modules.enabled - Registers each module's ServiceProvider
- ServiceProviders load routes, configs, and migrations
Configuration
// config/platform.common.modules
return [
'enabled' => [
'MagicLink',
'YourModule',
],
'classes' => [
'MagicLink' => MagicLinkServiceProvider::class,
'YourModule' => YourModuleServiceProvider::class,
],
];Creating a New Module
Using the make:module Command (Recommended)
The easiest way to create a new module is using the built-in Artisan command:
docker exec wedissimo-api php artisan make:module YourModuleThis command automatically:
- Creates the complete module directory structure
- Generates a ServiceProvider with Livewire support
- Creates API and web route files with proper middleware
- Sets up permission seeder with plural naming conventions
- Creates navigation partial for admin sidebar
- Generates OpenAPI documentation stub
- Registers the module in
config/platform.common.modules
Generated Structure:
modules/YourModule/
├── Config/
│ ├── config.php # Module configuration
│ └── scout.php # Optional Scout/Typesense config
├── Database/
│ ├── Migrations/ # Module-specific migrations
│ └── Seeders/
│ └── YourModulePermissionSeeder.php
├── Http/
│ ├── Controllers/ # Module controllers
│ ├── Requests/ # Form validation
│ └── Resources/ # API resources
├── Livewire/ # Livewire components
├── Models/ # Module models
├── Notifications/ # Module notifications
├── Providers/
│ └── YourModuleServiceProvider.php
├── Resources/
│ └── views/
│ ├── livewire/ # Livewire views
│ └── partials/
│ └── nav.blade.php # Admin navigation items
├── Routes/
│ ├── api.php # API routes
│ └── web.php # Web routes with session support
├── Services/ # Business logic
├── Tests/
│ ├── Feature/ # Integration tests
│ └── Unit/ # Unit tests
└── openapi.yaml # OpenAPI documentationImportant Notes:
- Web Routes: Automatically wrapped in
webmiddleware for session/cookie support - Permissions: Uses plural naming (e.g.,
view vendors,create vendors) - Navigation: Auto-discovered and injected into admin sidebar
- Livewire: Components are registered and views have proper namespace
After Generation: The module is immediately ready to use. You can start adding:
- Models in
modules/YourModule/Models/ - Controllers in
modules/YourModule/Http/Controllers/ - Livewire components in
modules/YourModule/Livewire/ - Migrations using:
php artisan make:migration create_your_table --path=modules/YourModule/Database/Migrations
Module Navigation & Permissions
Auto-Discovered Navigation
Modules can contribute navigation items to the admin sidebar using the @push directive. Navigation is automatically loaded when the main nav renders.
Create navigation partial (modules/YourModule/Resources/views/partials/nav.blade.php):
@push('module-navigation')
@can('view your_resources')
<flux:sidebar.item
icon="cube"
href="{{ route('admin.your-resources.index') }}"
:current="request()->routeIs('admin.your-resources.index')">
{{ __('Your Resources') }}
</flux:sidebar.item>
@endcan
@endpushRegister in ServiceProvider:
protected function registerNavigation(): void
{
View::composer('layouts.nav', function () {
if (view()->exists("{$this->moduleNameLower}::partials.nav")) {
view("{$this->moduleNameLower}::partials.nav")->render();
}
});
}The navigation item will automatically appear in the admin sidebar when:
- The user has the required permission (
view your_resources) - The view composer renders the navigation partial
Module Permissions
Modules should define their own permissions using a dedicated seeder.
Create permission seeder (modules/YourModule/Database/Seeders/YourModulePermissionSeeder.php):
<?php
namespace Modules\YourModule\Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
class YourModulePermissionSeeder extends Seeder
{
public function run(): void
{
$permissions = [
'manage your_resources',
'view your_resources',
'create your_resources',
'update your_resources',
'delete your_resources',
];
foreach ($permissions as $permission) {
Permission::updateOrCreate(['name' => $permission]);
}
}
}Important: Use plural naming for permissions (e.g., view vendors, not view vendor). This follows Laravel's convention.
Auto-discovery: The core PermissionSeeder automatically discovers and calls module permission seeders:
// database/seeders/PermissionSeeder.php
protected function callModulePermissionSeeders(): void
{
$moduleConfig = Config::get('platform.common.modules');
foreach ($moduleConfig['enabled'] as $module) {
$seederClass = "Modules\\{$module}\\Database\\Seeders\\{$module}PermissionSeeder";
if (class_exists($seederClass)) {
$this->call($seederClass);
}
}
}Run the seeder:
docker exec wedissimo-api php artisan db:seed --class=PermissionSeederGate Authorization
The super_admin role has access to all permissions via Gate::before() in AuthServiceProvider:
Gate::before(function ($user) {
return $user->hasRole(Config::get('constants.roles.super_admin')) ? true : null;
});This means super_admin will pass all @can() checks and permission middleware without needing explicit permission assignments.
Existing Modules
See the Domains documentation for detailed information about each implemented module:
- Vendors - Vendor profiles, onboarding, search, and management
- Listings - Service listings and marketplace functionality
- Payments - Payment processing and transactions (planned)
- Communications - Messaging and notifications (planned)
For authentication-related modules (MagicLink, Profile, UserInvitation), see the Authentication guide.
Module Best Practices
1. Self-Contained
Each module should be as independent as possible:
// GOOD - Module handles its own logic
$service = app(YourModuleService::class);
$result = $service->process($data);
// BAD - Tight coupling to other modules
$otherModuleService = app(OtherModuleService::class);2. Service Layer
Use services for business logic:
namespace Modules\YourModule\Services;
class YourModuleService
{
public function process(array $data): Model
{
// Business logic here
return YourModel::create($data);
}
}3. API Resources
Always use resources for API responses:
namespace Modules\YourModule\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class YourResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'created_at' => $this->created_at,
];
}
}4. Form Requests
Use form request classes for validation:
namespace Modules\YourModule\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreYourModelRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'email' => 'required|email|unique:your_models',
];
}
}5. Testing
Test at the module level:
// modules/YourModule/Tests/Feature/YourFeatureTest.php
use Tests\TestCase;
test('can create your model', function () {
$user = createUser();
$response = $this->actingAs($user)
->postJson('/api/v1/your-resource', [
'name' => 'Test Name',
]);
$response->assertStatus(201)
->assertJsonStructure(['data' => ['id', 'name']]);
});Module Communication
1. Events & Listeners
// Module A dispatches event
event(new YourModuleEvent($data));
// Module B listens
class YourModuleListener
{
public function handle(YourModuleEvent $event): void
{
// Handle event
}
}2. Service Contracts
// Define interface
interface YourModuleServiceInterface
{
public function process(array $data): Model;
}
// Bind in service provider
$this->app->bind(
YourModuleServiceInterface::class,
YourModuleService::class
);3. Eloquent Relationships
// Cross-module relationships are fine
class ModuleAModel extends Model
{
public function moduleBRelation()
{
return $this->hasMany(ModuleBModel::class);
}
}Database Migrations
Creating Module Migrations
docker exec wedissimo-api php artisan make:migration create_your_table --path=modules/YourModule/Database/MigrationsMigration Pattern
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('your_table', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('name');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('your_table');
}
};Module Configuration
Accessing Module Config
// Get module config
$value = config('yourmodule.key');
// Set at runtime
config(['yourmodule.key' => 'value']);Publishing Module Config
// In ServiceProvider
$this->publishes([
__DIR__ . '/../Config/config.php' => config_path('yourmodule.php'),
], 'config');Testing Modules
Running Module Tests
# All module tests
docker exec wedissimo-api vendor/bin/pest modules/YourModule/Tests/
# Specific test file
docker exec wedissimo-api vendor/bin/pest modules/YourModule/Tests/Feature/YourFeatureTest.php
# With filter
docker exec wedissimo-api vendor/bin/pest modules/YourModule/Tests/ --filter="your test"Module Test Setup
// modules/YourModule/Tests/Pest.php
uses(Tests\TestCase::class)
->beforeEach(function () {
// Module-specific setup
})
->in('Feature', 'Unit');Troubleshooting
Module Not Loading
Check
config/platform.common.modules:- Module listed in
enabled - ServiceProvider in
classes
- Module listed in
Clear caches:
bashdocker exec wedissimo-api php artisan config:clear docker exec wedissimo-api php artisan cache:clearVerify ServiceProvider namespace matches directory structure
Routes Not Found
- Check route file location:
modules/YourModule/Routes/api.phporweb.php - Verify
loadRoutesFrom()in ServiceProvider - Check route middleware and prefixes
- Run
php artisan route:list --name=your.routeto verify registration
Authenticated Users Redirected to Login
If authenticated users are being redirected to /admin/login when accessing module routes:
Cause: Module web routes are missing the web middleware wrapper.
Solution: Wrap all module web routes in Route::middleware('web')->group():
// modules/YourModule/Routes/web.php
// WRONG - Missing web middleware
Route::prefix('/admin')->middleware(['auth', 'verified'])->group(function () {
Route::get('/your-resource', YourComponent::class);
});
// CORRECT - Wrapped in web middleware
Route::middleware('web')->group(function () {
Route::prefix('/admin')->middleware(['auth', 'verified'])->group(function () {
Route::get('/your-resource', YourComponent::class);
});
});Why this happens: Module routes loaded via loadRoutesFrom() don't automatically inherit the web middleware group. Without it, session-based authentication cannot function, causing the auth middleware to fail and redirect users to login.
Debugging steps:
Check if route has
webmiddleware:php artisan route:list --name=your.routeClear route cache:
php artisan route:clearTest with
tinker:php$route = Route::getRoutes()->getByName('your.route'); dd($route->middleware()); // Should include 'web'
Migrations Not Running
- Verify path:
modules/YourModule/Database/Migrations/ - Check
registerMigrations()in ServiceProvider - Run
php artisan migrate:statusto see pending migrations