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:

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:make command 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 /vendor directory from version control.

Both files are generated from stubs at stubs/laradashboard/module.claude.md.stub. If a CLAUDE.md already 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/yourmodule or https://github.com/yourname/yourmodule/wiki
  • Support Forum: https://discord.gg/your-server or https://community.yoursite.com
  • Issues: https://github.com/yourname/yourmodule/issues

These links can be:

  1. Set automatically from your module.json when you upload your module
  2. Updated manually in your module's Settings page after submission
  3. 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:

  1. Extracted from the ZIP file
  2. Stored in storage/app/public/module-assets/{module-slug}/
  3. 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:

  1. Installs Composer dependencies with --no-dev (if composer.json exists)
  2. Installs npm dependencies (if package.json exists)
  3. Compiles assets to modules/YourModule/dist/build-yourmodule/
  4. Verifies marketplace-assets/ folder exists
  5. 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:

  1. Go to the Module Marketplace
  2. Click "Submit Module" or just go to Submit Module
  3. Upload your yourmodule-v1.0.0.zip file
  4. Fill in additional marketplace metadata
  5. Submit for review

Files Automatically Excluded

The packaging command automatically excludes:

  • .git/ - Version control
  • node_modules/ - NPM dependencies
  • .env - Environment files
  • vendor/ - 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

  1. Check module.json syntax
  2. Verify service provider namespace
  3. Run composer dump-autoload
  4. 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

/