Module Development
Complete guide to developing custom modules for LaraDashboard, from project setup to publishing and distribution. Learn hooks, Livewire integration, and best practices.
Module Development
This guide walks you through creating custom modules for LaraDashboard. Modules are self-contained packages that extend functionality without modifying core code.
Prerequisites
Before developing modules, ensure you understand:
- Laravel fundamentals (controllers, models, migrations)
- Livewire 3 basics
- LaraDashboard's architecture
- The module system
Creating a New Module
Using Artisan Command
Generate a new module scaffold:
php artisan module:make YourModule
This creates the module structure at modules/YourModule/.
LaraDashboard enhancement: The
module:makecommand is extended to automatically generate two extra files in every new module:
CLAUDE.md— Module-specific architecture guidance, CRUD recipes, and coding conventions pre-filled for Claude Code (AI assistant context)..gitignore— Pre-configured to exclude the module's/vendordirectory from version control.Both files are generated from stubs at
stubs/laradashboard/module.claude.md.stub. If aCLAUDE.mdalready exists it is skipped unless you pass--force.
Module Structure
modules/YourModule/
├── app/
│ ├── Http/
│ │ ├── Controllers/
│ │ │ └── YourModuleController.php
│ │ ├── Middleware/
│ │ └── Requests/
│ ├── Livewire/
│ │ └── Admin/
│ │ └── Dashboard.php
│ ├── Models/
│ ├── Providers/
│ │ ├── YourModuleServiceProvider.php
│ │ └── RouteServiceProvider.php
│ └── Services/
├── config/
│ └── config.php
├── database/
│ ├── migrations/
│ ├── seeders/
│ └── factories/
├── marketplace-assets/ # Marketplace branding assets
│ ├── logo.png # Module logo (recommended: 128x128 or 256x256)
│ ├── banner.png # Optional banner image (1200x400 recommended)
│ └── screenshots/ # Optional screenshots folder
│ ├── 01-dashboard.png
│ └── 02-settings.png
├── resources/
│ ├── assets/
│ └── views/
├── routes/
│ ├── web.php
│ └── api.php
├── tests/
├── composer.json
├── module.json
└── README.md
Module Configuration
module.json
The module manifest file defines metadata:
{
"name": "YourModule",
"alias": "yourmodule",
"description": "A custom module for LaraDashboard",
"keywords": ["custom", "feature"],
"priority": 0,
"providers": [
"Modules\\YourModule\\Providers\\YourModuleServiceProvider"
],
"files": [],
"requires": []
}
Extended module.json (Marketplace-Ready)
For modules distributed via the marketplace, include additional metadata:
{
"name": "yourmodule",
"title": "Your Module Title",
"description": "A comprehensive description of your module",
"keywords": ["custom", "feature", "extension"],
"category": "utilities",
"priority": 20,
"icon": "lucide:box",
"logo_image": "logo.png",
"banner_image": "banner.png",
"version": "1.0.0",
"author": "Your Name",
"author_url": "https://yoursite.com",
"documentation_url": "https://docs.yoursite.com/yourmodule",
"support_forum_url": "https://community.yoursite.com/yourmodule",
"issues_url": "https://github.com/yourname/yourmodule/issues",
"demo_url": "https://demo.yoursite.com/yourmodule",
"pricing": "free",
"providers": [
"Modules\\YourModule\\Providers\\YourModuleServiceProvider"
],
"files": []
}
| Field | Description |
|---|---|
name |
Lowercase module identifier (used for status tracking) |
title |
Display title for marketplace |
category |
Module category (core, utilities, addon, tools, etc.) |
icon |
Iconify icon identifier (fallback when no logo) |
logo_image |
Logo filename in marketplace-assets/ folder |
banner_image |
Banner filename in marketplace-assets/ folder |
version |
Semantic version number |
author |
Author name for attribution |
author_url |
Link to author website |
documentation_url |
Link to module documentation (displayed on module detail page) |
support_forum_url |
Link to support forum, Discord, or community page |
issues_url |
Link to issue tracker (GitHub Issues, GitLab, etc.) |
demo_url |
Link to live demo (displays a "Live Demo" button on module page) |
pricing |
Pricing model: free, paid, or both |
Support Links
The support links (documentation_url, support_forum_url, issues_url) are displayed in the "Support" section of your module's detail page on the marketplace. These help users find help and report issues.
Example URLs:
- Documentation:
https://docs.yoursite.com/yourmoduleorhttps://github.com/yourname/yourmodule/wiki - Support Forum:
https://discord.gg/your-serverorhttps://community.yoursite.com - Issues:
https://github.com/yourname/yourmodule/issues
These links can be:
- Set automatically from your
module.jsonwhen you upload your module - Updated manually in your module's Settings page after submission
- Updated when you upload a new version (new values in module.json will update the stored links)
Live Demo URL
If your module has a live demo, add the demo_url field to your module.json:
{
"demo_url": "https://demo.yoursite.com/yourmodule"
}
When set, a "Live Demo" button will appear next to the download/buy button on your module's marketplace page, allowing potential users to try your module before purchasing or installing.
Marketplace Assets
Place your module's branding assets in the marketplace-assets/ folder:
modules/YourModule/
└── marketplace-assets/
├── logo.png # Required: Module logo (128x128 or 256x256 recommended)
├── banner.png # Optional: Banner image for module detail page header
├── banner.svg # Optional: SVG banner (also supported)
└── screenshots/ # Optional: Screenshots folder for gallery
├── 01-dashboard.png
├── 02-settings.png
└── 03-feature.png
Logo Guidelines:
- Format: PNG with transparent background preferred, SVG also supported
- Size: 128x128 or 256x256 pixels recommended
- Square aspect ratio works best
- Filename must contain "logo" (e.g.,
logo.png,my-logo.svg)
Banner Guidelines:
- Format: PNG, JPG, or SVG
- Size: 1200x400 pixels recommended
- Used as the header background on module detail pages
- Filename must contain "banner" (e.g.,
banner.png,banner.svg)
Screenshots Guidelines:
- Place all screenshots in the
marketplace-assets/screenshots/folder - Format: PNG, JPG, or WebP recommended
- Size: 1280x720 or 1920x1080 pixels recommended (16:9 aspect ratio)
- Name files with numeric prefixes for ordering (e.g.,
01-dashboard.png,02-settings.png) - Screenshots are displayed in a responsive grid gallery on the module detail page
- Clicking a screenshot opens a lightbox for full-size viewing
When the module is uploaded, these assets are automatically:
- Extracted from the ZIP file
- Stored in
storage/app/public/module-assets/{module-slug}/ - Made accessible via public URLs
In module.json, reference just the filename:
{
"logo_image": "logo.png",
"banner_image": "banner.png"
}
Supported path formats in module.json:
| Format | Example | Result |
|---|---|---|
| Simple filename | "logo.png" |
Looks in marketplace-assets/logo.png |
| Absolute path | "/images/custom/logo.png" |
Uses path directly from public |
| Full URL | "https://cdn.example.com/logo.png" |
Uses external URL |
composer.json
Define package information and autoloading:
{
"name": "yourvendor/yourmodule",
"description": "Your module description",
"type": "laradashboard-module",
"license": "MIT",
"require": {
"php": "^8.3"
},
"autoload": {
"psr-4": {
"Modules\\YourModule\\": ""
}
},
"extra": {
"laravel": {
"providers": [
"Modules\\YourModule\\App\\Providers\\YourModuleServiceProvider"
]
}
},
"minimum-stability": "dev",
"prefer-stable": true
}
config/config.php
Module configuration:
<?php
return [
'name' => 'YourModule',
/*
|--------------------------------------------------------------------------
| Feature Flags
|--------------------------------------------------------------------------
*/
'features' => [
'api_enabled' => env('YOURMODULE_API_ENABLED', true),
'webhooks' => env('YOURMODULE_WEBHOOKS', false),
],
/*
|--------------------------------------------------------------------------
| Limits
|--------------------------------------------------------------------------
*/
'limits' => [
'max_items' => env('YOURMODULE_MAX_ITEMS', 1000),
'rate_limit' => env('YOURMODULE_RATE_LIMIT', 60),
],
];
Service Provider
Main Service Provider
<?php
namespace Modules\YourModule\App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Blade;
use Livewire\Livewire;
class YourModuleServiceProvider extends ServiceProvider
{
protected string $moduleName = 'YourModule';
protected string $moduleNameLower = 'yourmodule';
/**
* Boot the module services.
*/
public function boot(): void
{
$this->registerCommands();
$this->registerCommandSchedules();
$this->registerTranslations();
$this->registerConfig();
$this->registerViews();
$this->registerLivewireComponents();
$this->loadMigrationsFrom(module_path($this->moduleName, 'database/migrations'));
}
/**
* Register the module services.
*/
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->app->register(EventServiceProvider::class);
// Register bindings
$this->registerBindings();
}
/**
* Register service bindings.
*/
protected function registerBindings(): void
{
$this->app->singleton(YourModuleService::class, function ($app) {
return new YourModuleService(
$app->make(CacheService::class)
);
});
}
/**
* Register Livewire components.
*/
protected function registerLivewireComponents(): void
{
Livewire::component(
'yourmodule::admin.dashboard',
\Modules\YourModule\App\Livewire\Admin\Dashboard::class
);
Livewire::component(
'yourmodule::admin.settings',
\Modules\YourModule\App\Livewire\Admin\Settings::class
);
}
/**
* Register config.
*/
protected function registerConfig(): void
{
$this->publishes([
module_path($this->moduleName, 'config/config.php')
=> config_path($this->moduleNameLower . '.php'),
], 'config');
$this->mergeConfigFrom(
module_path($this->moduleName, 'config/config.php'),
$this->moduleNameLower
);
}
/**
* Register views.
*/
protected function registerViews(): void
{
$viewPath = resource_path('views/modules/' . $this->moduleNameLower);
$sourcePath = module_path($this->moduleName, 'resources/views');
$this->publishes([
$sourcePath => $viewPath,
], ['views', $this->moduleNameLower . '-module-views']);
$this->loadViewsFrom(array_merge([
$sourcePath,
], $this->getPublishableViewPaths()), $this->moduleNameLower);
// Register Blade components
Blade::componentNamespace(
'Modules\\YourModule\\View\\Components',
$this->moduleNameLower
);
}
/**
* Register translations.
*/
protected function registerTranslations(): void
{
$langPath = resource_path('lang/modules/' . $this->moduleNameLower);
if (is_dir($langPath)) {
$this->loadTranslationsFrom($langPath, $this->moduleNameLower);
$this->loadJsonTranslationsFrom($langPath);
} else {
$this->loadTranslationsFrom(
module_path($this->moduleName, 'resources/lang'),
$this->moduleNameLower
);
$this->loadJsonTranslationsFrom(
module_path($this->moduleName, 'resources/lang')
);
}
}
/**
* Register commands.
*/
protected function registerCommands(): void
{
if ($this->app->runningInConsole()) {
$this->commands([
\Modules\YourModule\App\Console\SyncCommand::class,
]);
}
}
/**
* Register command schedules.
*/
protected function registerCommandSchedules(): void
{
$this->app->booted(function () {
$schedule = $this->app->make(\Illuminate\Console\Scheduling\Schedule::class);
$schedule->command('yourmodule:sync')->daily();
});
}
private function getPublishableViewPaths(): array
{
$paths = [];
foreach (config('view.paths') as $path) {
if (is_dir($path . '/modules/' . $this->moduleNameLower)) {
$paths[] = $path . '/modules/' . $this->moduleNameLower;
}
}
return $paths;
}
}
Routes
Web Routes
<?php
// routes/web.php
use Illuminate\Support\Facades\Route;
use Modules\YourModule\App\Http\Controllers\YourModuleController;
/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
*/
Route::middleware(['web', 'auth', 'verified'])
->prefix('admin/yourmodule')
->name('admin.yourmodule.')
->group(function () {
// Dashboard
Route::get('/', [YourModuleController::class, 'index'])
->name('index')
->middleware('can:yourmodule.view');
// Items CRUD
Route::resource('items', ItemController::class)
->middleware([
'index' => 'can:yourmodule.view',
'create' => 'can:yourmodule.create',
'store' => 'can:yourmodule.create',
'edit' => 'can:yourmodule.edit',
'update' => 'can:yourmodule.edit',
'destroy' => 'can:yourmodule.delete',
]);
// Settings
Route::get('settings', [SettingsController::class, 'index'])
->name('settings')
->middleware('can:yourmodule.settings');
Route::post('settings', [SettingsController::class, 'update'])
->name('settings.update')
->middleware('can:yourmodule.settings');
});
API Routes
<?php
// routes/api.php
use Illuminate\Support\Facades\Route;
use Modules\YourModule\App\Http\Controllers\Api\ItemController;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
*/
Route::middleware(['api', 'auth:sanctum'])
->prefix('api/v1/yourmodule')
->name('api.yourmodule.')
->group(function () {
Route::apiResource('items', ItemController::class);
Route::post('items/{item}/duplicate', [ItemController::class, 'duplicate'])
->name('items.duplicate');
});
Controllers
Web Controller
<?php
namespace Modules\YourModule\App\Http\Controllers;
use App\Http\Controllers\Controller;
use Modules\YourModule\App\Models\Item;
use Modules\YourModule\App\Services\YourModuleService;
use Modules\YourModule\App\Http\Requests\StoreItemRequest;
use Modules\YourModule\App\Http\Requests\UpdateItemRequest;
class ItemController extends Controller
{
public function __construct(
private YourModuleService $service
) {}
public function index()
{
return view('yourmodule::admin.items.index');
}
public function create()
{
return view('yourmodule::admin.items.create');
}
public function store(StoreItemRequest $request)
{
$item = $this->service->create($request->validated());
return redirect()
->route('admin.yourmodule.items.edit', $item)
->with('success', __('yourmodule::messages.item_created'));
}
public function edit(Item $item)
{
return view('yourmodule::admin.items.edit', compact('item'));
}
public function update(UpdateItemRequest $request, Item $item)
{
$this->service->update($item, $request->validated());
return redirect()
->route('admin.yourmodule.items.index')
->with('success', __('yourmodule::messages.item_updated'));
}
public function destroy(Item $item)
{
$this->service->delete($item);
return redirect()
->route('admin.yourmodule.items.index')
->with('success', __('yourmodule::messages.item_deleted'));
}
}
API Controller
<?php
namespace Modules\YourModule\App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Modules\YourModule\App\Models\Item;
use Modules\YourModule\App\Services\YourModuleService;
use Modules\YourModule\App\Http\Resources\ItemResource;
use Modules\YourModule\App\Http\Requests\Api\StoreItemRequest;
use Modules\YourModule\App\Http\Requests\Api\UpdateItemRequest;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class ItemController extends Controller
{
public function __construct(
private YourModuleService $service
) {}
public function index(): AnonymousResourceCollection
{
$items = Item::query()
->when(request('search'), fn($q, $s) => $q->search($s))
->when(request('status'), fn($q, $s) => $q->where('status', $s))
->latest()
->paginate(request('per_page', 15));
return ItemResource::collection($items);
}
public function store(StoreItemRequest $request): ItemResource
{
$item = $this->service->create($request->validated());
return new ItemResource($item);
}
public function show(Item $item): ItemResource
{
return new ItemResource($item->load('relations'));
}
public function update(UpdateItemRequest $request, Item $item): ItemResource
{
$item = $this->service->update($item, $request->validated());
return new ItemResource($item);
}
public function destroy(Item $item): \Illuminate\Http\Response
{
$this->service->delete($item);
return response()->noContent();
}
}
Models
Creating a Model
<?php
namespace Modules\YourModule\App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Models\User;
use Modules\YourModule\Database\Factories\ItemFactory;
class Item extends Model
{
use HasFactory, SoftDeletes;
protected $table = 'yourmodule_items';
protected $fillable = [
'user_id',
'name',
'description',
'status',
'settings',
'published_at',
];
protected function casts(): array
{
return [
'settings' => 'array',
'published_at' => 'datetime',
];
}
/*
|--------------------------------------------------------------------------
| Relationships
|--------------------------------------------------------------------------
*/
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(User::class);
}
public function entries(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Entry::class);
}
/*
|--------------------------------------------------------------------------
| Scopes
|--------------------------------------------------------------------------
*/
public function scopeActive($query)
{
return $query->where('status', 'active');
}
public function scopeSearch($query, string $search)
{
return $query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
/*
|--------------------------------------------------------------------------
| Factory
|--------------------------------------------------------------------------
*/
protected static function newFactory(): ItemFactory
{
return ItemFactory::new();
}
}
Livewire Components
Dedicated Livewire Service Provider
For better organization, create a separate service provider for Livewire components:
<?php
declare(strict_types=1);
namespace Modules\YourModule\Providers;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
use Modules\YourModule\Livewire\Admin\Dashboard;
use Modules\YourModule\Livewire\Admin\ItemList;
use Modules\YourModule\Livewire\Admin\ItemForm;
class YourModuleLivewireServiceProvider extends ServiceProvider
{
/**
* Register the service provider.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->registerLivewireComponents();
}
/**
* Register Livewire components.
*/
protected function registerLivewireComponents(): void
{
// Admin components
Livewire::component('yourmodule::admin.dashboard', Dashboard::class);
Livewire::component('yourmodule::admin.item-list', ItemList::class);
Livewire::component('yourmodule::admin.item-form', ItemForm::class);
// If you have frontend pages (like a theme module)
// Livewire::component('yourmodule::pages.home', Home::class);
}
}
Register in the main service provider:
public function register(): void
{
$this->app->register(EventServiceProvider::class);
$this->app->register(RouteServiceProvider::class);
$this->app->register(YourModuleLivewireServiceProvider::class);
}
Creating Livewire Components
Generate a Livewire component for your module:
php artisan module:make-livewire Admin/Dashboard YourModule
Admin Dashboard Component
<?php
declare(strict_types=1);
namespace Modules\YourModule\Livewire\Admin;
use Livewire\Component;
use Livewire\WithPagination;
use Modules\YourModule\App\Models\Item;
class Dashboard extends Component
{
use WithPagination;
public string $search = '';
public string $status = '';
protected $queryString = [
'search' => ['except' => ''],
'status' => ['except' => ''],
];
public function updatingSearch()
{
$this->resetPage();
}
public function deleteItem(int $id)
{
$item = Item::findOrFail($id);
$this->authorize('yourmodule.delete');
$item->delete();
$this->dispatch('notify', [
'type' => 'success',
'message' => __('yourmodule::messages.item_deleted'),
]);
}
public function render()
{
$items = Item::query()
->when($this->search, fn($q) => $q->search($this->search))
->when($this->status, fn($q) => $q->where('status', $this->status))
->latest()
->paginate(10);
return view('yourmodule::livewire.admin.dashboard', [
'items' => $items,
])->layout('backend.layouts.app');
}
}
Livewire View
{{-- resources/views/livewire/admin/dashboard.blade.php --}}
<div>
<x-backend.page-header title="Your Module">
<x-slot:actions>
<a href="{{ route('admin.yourmodule.items.create') }}"
class="btn btn-primary">
<i class="bi bi-plus"></i> New Item
</a>
</x-slot:actions>
</x-backend.page-header>
<div class="card">
<div class="card-header">
<div class="row g-3">
<div class="col-md-4">
<input type="text"
wire:model.live.debounce.300ms="search"
class="form-control"
placeholder="Search...">
</div>
<div class="col-md-3">
<select wire:model.live="status" class="form-select">
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</div>
</div>
<div class="card-body">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@forelse($items as $item)
<tr wire:key="item-{{ $item->id }}">
<td>{{ $item->name }}</td>
<td>
<span class="badge bg-{{ $item->status === 'active' ? 'success' : 'secondary' }}">
{{ ucfirst($item->status) }}
</span>
</td>
<td>{{ $item->created_at->format('M d, Y') }}</td>
<td>
<a href="{{ route('admin.yourmodule.items.edit', $item) }}"
class="btn btn-sm btn-outline-primary">
Edit
</a>
<button wire:click="deleteItem({{ $item->id }})"
wire:confirm="Are you sure?"
class="btn btn-sm btn-outline-danger">
Delete
</button>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="text-center text-muted py-4">
No items found.
</td>
</tr>
@endforelse
</tbody>
</table>
{{ $items->links() }}
</div>
</div>
</div>
Database
Creating Migrations
php artisan module:make-migration create_yourmodule_items_table YourModule
<?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('yourmodule_items', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->text('description')->nullable();
$table->string('status')->default('active');
$table->json('settings')->nullable();
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['status', 'published_at']);
});
}
public function down(): void
{
Schema::dropIfExists('yourmodule_items');
}
};
Creating Seeders
<?php
namespace Modules\YourModule\Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
class YourModuleDatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
PermissionsSeeder::class,
DefaultDataSeeder::class,
]);
}
}
Permissions via Hook (Recommended)
Register permissions via the hook system for automatic inclusion when roles are created:
<?php
declare(strict_types=1);
namespace Modules\YourModule\Services;
use App\Enums\Hooks\PermissionFilterHook;
use App\Support\Facades\Hook;
class YourModuleService
{
public function bootstrap(): void
{
// Register permissions via hook
Hook::addFilter(
PermissionFilterHook::PERMISSION_GROUPS,
[$this, 'addPermissions']
);
}
/**
* Add module permissions to the core permission groups.
*/
public function addPermissions(array $groups): array
{
return array_merge($groups, [
[
'group_name' => 'yourmodule',
'permissions' => [
'yourmodule.view',
'yourmodule.create',
'yourmodule.edit',
'yourmodule.delete',
'yourmodule.settings',
],
],
// Add more permission groups as needed
[
'group_name' => 'yourmodule_items',
'permissions' => [
'yourmodule_item.create',
'yourmodule_item.view',
'yourmodule_item.edit',
'yourmodule_item.delete',
'yourmodule_item.view_all',
'yourmodule_item.view_own',
],
],
]);
}
}
Permissions Seeder (Alternative)
If you need to seed permissions manually:
<?php
namespace Modules\YourModule\Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
class PermissionsSeeder extends Seeder
{
public function run(): void
{
$permissions = [
'yourmodule.view',
'yourmodule.create',
'yourmodule.edit',
'yourmodule.delete',
'yourmodule.settings',
];
foreach ($permissions as $permission) {
Permission::firstOrCreate([
'name' => $permission,
'guard_name' => 'web',
]);
}
}
}
Menu Integration
Adding to Sidebar via Hooks
LaraDashboard uses an enum-based hook system for menu registration. Create a dedicated menu service:
<?php
declare(strict_types=1);
namespace Modules\YourModule\Services;
use App\Enums\Hooks\AdminFilterHook;
use App\Services\MenuService\AdminMenuItem;
use App\Support\Facades\Hook;
class YourModuleMenuService
{
/**
* Register the module menu.
*/
public function register(): void
{
Hook::addFilter(
AdminFilterHook::ADMIN_MENU_GROUPS_BEFORE_SORTING,
[$this, 'addMenu']
);
}
/**
* Add menu items to the admin sidebar.
*
* @param array $menuGroups Existing menu groups
* @return array Modified menu groups
*/
public function addMenu(array $menuGroups): array
{
$menuGroups['yourmodule'] = AdminMenuItem::make('yourmodule', [
'title' => __('yourmodule::menu.title'),
'icon' => 'lucide:box',
'route' => 'admin.yourmodule.index',
'permission' => 'yourmodule.view',
'order' => 50,
'children' => [
AdminMenuItem::make('items', [
'title' => __('yourmodule::menu.items'),
'route' => 'admin.yourmodule.items.index',
'permission' => 'yourmodule.view',
]),
AdminMenuItem::make('settings', [
'title' => __('yourmodule::menu.settings'),
'route' => 'admin.yourmodule.settings',
'permission' => 'yourmodule.settings',
]),
],
]);
return $menuGroups;
}
}
Bootstrap Pattern
Create a bootstrap service to register all hooks:
<?php
declare(strict_types=1);
namespace Modules\YourModule\Services;
use App\Enums\Hooks\AdminFilterHook;
use App\Enums\Hooks\PermissionFilterHook;
use App\Support\Facades\Hook;
class YourModuleService
{
public function __construct(
private readonly YourModuleMenuService $menuService
) {}
/**
* Bootstrap the module - register all hooks and services.
*/
public function bootstrap(): void
{
// Register admin menu
Hook::addFilter(
AdminFilterHook::ADMIN_MENU_GROUPS_BEFORE_SORTING,
[$this->menuService, 'addMenu']
);
// Register permissions
Hook::addFilter(
PermissionFilterHook::PERMISSION_GROUPS,
[$this, 'addPermissions']
);
}
/**
* Add module permissions to the core permission groups.
*/
public function addPermissions(array $groups): array
{
return array_merge($groups, [
[
'group_name' => 'yourmodule',
'permissions' => [
'yourmodule.view',
'yourmodule.create',
'yourmodule.edit',
'yourmodule.delete',
'yourmodule.settings',
],
],
]);
}
}
Register in Service Provider
public function boot(): void
{
// ... other boot code
// Bootstrap module hooks
$this->app->booted(function () {
app(YourModuleService::class)->bootstrap();
});
}
Creating Custom Module Hooks
Modules can define their own hooks using PHP enums, allowing other modules or user code to extend your module's functionality.
Filter Hook Enum
Create app/Enums/Hooks/YourModuleFilterHook.php:
<?php
declare(strict_types=1);
namespace Modules\YourModule\Enums\Hooks;
enum YourModuleFilterHook: string
{
// Item hooks
case ITEM_QUERY = 'yourmodule.filter.item_query';
case ITEM_BEFORE_SAVE = 'yourmodule.filter.item_before_save';
case ITEM_DISPLAY_COLUMNS = 'yourmodule.filter.item_display_columns';
// Navigation hooks
case NAV_ITEMS = 'yourmodule.filter.nav_items';
case SIDEBAR_WIDGETS = 'yourmodule.filter.sidebar_widgets';
// Layout hooks
case HEAD_BEFORE = 'yourmodule.filter.head_before';
case HEAD_AFTER = 'yourmodule.filter.head_after';
case CONTENT_BEFORE = 'yourmodule.filter.content_before';
case CONTENT_AFTER = 'yourmodule.filter.content_after';
}
Action Hook Enum
Create app/Enums/Hooks/YourModuleActionHook.php:
<?php
declare(strict_types=1);
namespace Modules\YourModule\Enums\Hooks;
enum YourModuleActionHook: string
{
// Item lifecycle
case ITEM_CREATED = 'yourmodule.action.item_created';
case ITEM_UPDATED = 'yourmodule.action.item_updated';
case ITEM_DELETED = 'yourmodule.action.item_deleted';
// Status changes
case ITEM_PUBLISHED = 'yourmodule.action.item_published';
case ITEM_ARCHIVED = 'yourmodule.action.item_archived';
}
Using Your Hooks
In your module code, use the hooks:
use App\Support\Facades\Hook;
use Modules\YourModule\Enums\Hooks\YourModuleFilterHook;
use Modules\YourModule\Enums\Hooks\YourModuleActionHook;
// Apply a filter hook
$query = Item::query()->active();
$query = Hook::applyFilter(YourModuleFilterHook::ITEM_QUERY, $query);
// Fire an action hook
Hook::doAction(YourModuleActionHook::ITEM_CREATED, $item);
// In a view - apply filter for content
$extraContent = Hook::applyFilter(YourModuleFilterHook::CONTENT_AFTER, '');
Allowing Others to Extend
Other modules can hook into your module:
use App\Support\Facades\Hook;
use Modules\YourModule\Enums\Hooks\YourModuleFilterHook;
use Modules\YourModule\Enums\Hooks\YourModuleActionHook;
// Filter: Modify item query
Hook::addFilter(YourModuleFilterHook::ITEM_QUERY, function ($query) {
return $query->where('is_featured', true);
});
// Action: React to item creation
Hook::addAction(YourModuleActionHook::ITEM_CREATED, function ($item) {
Notification::send($admins, new ItemCreatedNotification($item));
});
Testing
Feature Tests
<?php
namespace Modules\YourModule\Tests\Feature;
use Tests\TestCase;
use App\Models\User;
use Modules\YourModule\App\Models\Item;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ItemTest extends TestCase
{
use RefreshDatabase;
protected User $admin;
protected function setUp(): void
{
parent::setUp();
$this->admin = User::factory()->create();
$this->admin->givePermissionTo([
'yourmodule.view',
'yourmodule.create',
'yourmodule.edit',
'yourmodule.delete',
]);
}
public function test_can_list_items(): void
{
Item::factory()->count(5)->create();
$this->actingAs($this->admin)
->get(route('admin.yourmodule.items.index'))
->assertStatus(200)
->assertSee('Items');
}
public function test_can_create_item(): void
{
$this->actingAs($this->admin)
->post(route('admin.yourmodule.items.store'), [
'name' => 'Test Item',
'description' => 'Test description',
])
->assertRedirect();
$this->assertDatabaseHas('yourmodule_items', [
'name' => 'Test Item',
]);
}
public function test_unauthorized_user_cannot_access(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->get(route('admin.yourmodule.items.index'))
->assertForbidden();
}
}
Running Tests
# Run all module tests
php artisan test --filter=YourModule
# Run specific test
php artisan test modules/YourModule/tests/Feature/ItemTest.php
Frontend Theme Modules
Modules can serve as frontend themes, providing public-facing pages. The Starter26 module demonstrates this pattern.
Theme Module Structure
modules/YourTheme/
├── app/
│ ├── Livewire/
│ │ └── Pages/
│ │ ├── Home.php
│ │ ├── About.php
│ │ ├── Posts.php
│ │ └── SinglePost.php
│ ├── View/
│ │ └── Components/
│ │ ├── Navbar.php
│ │ └── Footer.php
│ └── Providers/
│ └── YourThemeLivewireServiceProvider.php
├── resources/
│ └── views/
│ ├── layouts/
│ │ └── app.blade.php
│ ├── livewire/
│ │ └── pages/
│ │ ├── home.blade.php
│ │ └── about.blade.php
│ └── components/
│ ├── navbar.blade.php
│ └── footer.blade.php
└── routes/
└── web.php
Theme Routes
<?php
use Illuminate\Support\Facades\Route;
use Modules\YourTheme\Livewire\Pages\Home;
use Modules\YourTheme\Livewire\Pages\About;
use Modules\YourTheme\Livewire\Pages\Posts;
use Modules\YourTheme\Livewire\Pages\SinglePost;
// Optional route prefix from config
$prefix = config('yourtheme.route_prefix', '');
Route::prefix($prefix)->group(function () {
Route::get('/', Home::class)->name('yourtheme.home');
Route::get('/about', About::class)->name('yourtheme.about');
Route::get('/posts', Posts::class)->name('yourtheme.posts');
Route::get('/post/{slug}', SinglePost::class)->name('yourtheme.post');
});
Theme Page Component
<?php
declare(strict_types=1);
namespace Modules\YourTheme\Livewire\Pages;
use App\Models\Post;
use Illuminate\View\View;
use Livewire\Component;
class Home extends Component
{
public $featuredPosts = [];
public $recentPosts = [];
public function mount(): void
{
$this->featuredPosts = Post::query()
->where('status', 'published')
->orderByDesc('created_at')
->limit(3)
->get();
$this->recentPosts = Post::query()
->where('status', 'published')
->orderByDesc('created_at')
->limit(6)
->get();
}
public function render(): View
{
return view('yourtheme::livewire.pages.home')
->layout('yourtheme::layouts.app', [
'title' => __('Home') . ' - ' . config('app.name'),
'description' => __('Welcome to our website.'),
]);
}
}
Theme Layout
{{-- resources/views/layouts/app.blade.php --}}
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $title ?? config('app.name') }}</title>
<meta name="description" content="{{ $description ?? '' }}">
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head>
<body class="antialiased">
<x-yourtheme::navbar />
<main>
{{ $slot }}
</main>
<x-yourtheme::footer />
@livewireScripts
</body>
</html>
Disabling Admin-Only Mode
Theme modules typically need to disable admin-only mode:
use App\Enums\Hooks\AdminFilterHook;
public function boot(): void
{
// ... other boot code
// Allow public frontend access
if (function_exists('ld_add_filter')) {
ld_add_filter(AdminFilterHook::ADMIN_SITE_ONLY, function () {
return false;
});
}
}
Packaging for Distribution
Prepare module.json
Ensure your module.json has all required metadata for marketplace distribution:
{
"name": "YourModule",
"alias": "yourmodule",
"title": "Your Module Title",
"description": "Your module description",
"version": "1.0.0",
"keywords": ["custom", "feature"],
"category": "utilities",
"icon": "lucide:box",
"homepage": "https://yoursite.com/modules/yourmodule",
"author": {
"name": "Your Name",
"email": "your@email.com"
},
"priority": 0,
"providers": [
"Modules\\YourModule\\App\\Providers\\YourModuleServiceProvider"
]
}
Build Process
LaraDashboard provides Artisan commands to compile and package modules for distribution.
One-Step Build & Package (Recommended)
The simplest way to build and package a module:
php artisan module:zip YourModule
This single command does everything:
- Installs Composer dependencies with
--no-dev(ifcomposer.jsonexists) - Installs npm dependencies (if
package.jsonexists) - Compiles assets to
modules/YourModule/dist/build-yourmodule/ - Verifies
marketplace-assets/folder exists - Creates a versioned ZIP file:
modules/yourmodule-v1.0.0.zip
Options:
php artisan module:zip YourModule --skip-composer # Skip composer install
php artisan module:zip YourModule --skip-npm # Skip npm install
php artisan module:zip YourModule --skip-compile # Skip asset compilation
php artisan module:zip YourModule --no-minify # Don't minify assets
php artisan module:zip YourModule --no-vendor # Exclude vendor directory
Manual Steps (Alternative)
If you prefer manual control:
Step 1: Compile Assets
php artisan module:compile-css YourModule --dist --minify
This compiles assets to modules/YourModule/dist/build-yourmodule/ for distribution.
Step 2: Package the Module
php artisan module:package YourModule
This creates a versioned ZIP file in the modules directory:
modules/yourmodule-v1.0.0.zip
The version number is automatically read from your module.json file.
Example: Packaging the CRM Module
# One-step build and package (recommended)
php artisan module:zip Crm
# Output: modules/crm-v1.0.0.zip
Or manual steps:
# Step 1: Compile CRM module assets for distribution
php artisan module:compile-css Crm --dist --minify
# Step 2: Package for distribution
php artisan module:package Crm
# Output: modules/crm-v1.0.0.zip
Upload to Marketplace
Once packaged, upload the generated ZIP file to the LaraDashboard Module Marketplace:
- Go to the Module Marketplace
- Click "Submit Module" or just go to Submit Module
- Upload your
yourmodule-v1.0.0.zipfile - Fill in additional marketplace metadata
- Submit for review
Files Automatically Excluded
The packaging command automatically excludes:
.git/- Version controlnode_modules/- NPM dependencies.env- Environment filesvendor/- Composer dependencies (if separate)tests/- Test files (optional, configurable).DS_Store- macOS system files
Manual ZIP Creation (Alternative)
If you prefer manual packaging:
cd modules
zip -r YourModule.zip YourModule -x "*.git*" -x "*node_modules*" -x "*.env*" -x "*vendor/*" -x "*.DS_Store"
Best Practices
Coding Standards
- Follow PSR-12 coding style
- Use strict types:
declare(strict_types=1); - Type hint all parameters and returns
- Document complex logic
Naming Conventions
| Type | Convention | Example |
|---|---|---|
| Module | PascalCase | YourModule |
| Tables | snake_case with prefix | yourmodule_items |
| Models | PascalCase singular | Item |
| Controllers | PascalCase + Controller | ItemController |
| Views | kebab-case | item-list.blade.php |
Security
- Always validate input via Form Requests
- Check permissions on every action
- Sanitize output in views
- Use prepared statements (Eloquent handles this)
Performance
- Use eager loading for relationships
- Cache expensive queries
- Index frequently queried columns
- Use pagination for lists
Troubleshooting
Module Not Loading
- Check
module.jsonsyntax - Verify service provider namespace
- Run
composer dump-autoload - Clear caches:
php artisan optimize:clear
Views Not Found
php artisan view:clear
php artisan config:clear
Permissions Not Working
php artisan permission:cache-reset
Next Steps
- Admin Menu System - Adding sidebar menus from modules
- Settings System - Adding settings tabs and sections
- Settings API - Settings CRUD operations
- Hooks Reference - Extend core functionality
- API Development - Build module APIs
- Testing Guide - Write comprehensive tests