Browse the documentation
Building modules
Quick start: a module in 15 minutes
Build a working Wintertrace module from scratch — ServiceProvider, a navigation item, a dashboard widget, a route, settings and translations — step by step.
This guide builds a complete, working module from scratch. It is the best starting point if you want to see the concepts in action — by the end you have a module with a navigation item, a stored setting, a dashboard widget, its own database table, and translations.
What we will build
A simple “Notes” module that:
- adds a settings page to the admin sidebar,
- stores a configurable welcome message,
- shows a dashboard widget,
- has its own database table,
- and includes both German and English translations.
Step 1: Create the directory structure
mkdir -p modules/notes/src/Http/Controllers
mkdir -p modules/notes/resources/views/widgets
mkdir -p modules/notes/database/migrations
mkdir -p modules/notes/lang/de
mkdir -p modules/notes/lang/en
Step 2: Write module.json
{
"name": {
"de": "Notizen",
"en": "Notes"
},
"version": "1.0.0",
"namespace": "Schneespur\\Module\\Notes",
"service_provider": "Schneespur\\Module\\Notes\\NotesServiceProvider",
"description": {
"de": "Einfaches Notiz-System für Einsätze.",
"en": "Simple notes system for service jobs."
},
"min_schneespur_version": "1.0.0",
"requires_permissions": [],
"default_enabled": true,
"requires": {},
"conflicts": []
}
The fields are explained in detail in the Module manifest reference. Here default_enabled is set to true so the example works immediately after migration; production modules typically leave this as false.
Step 3: Write the ServiceProvider
modules/notes/src/NotesServiceProvider.php:
<?php
namespace Schneespur\Module\Notes;
use App\Services\Extension\DashboardWidgetRegistry;
use App\Services\Extension\NavigationRegistry;
use App\Services\ModuleManager;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
class NotesServiceProvider extends ServiceProvider
{
public function register(): void {}
public function boot(): void
{
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'notes');
// Default settings
app(ModuleManager::class)->registerSettings('notes', [
'welcome_message' => 'Welcome to the notes system!',
]);
// Navigation
app(NavigationRegistry::class)->addItem(
group: 'system',
slug: 'notes-settings',
label: __('notes::messages.nav_label'),
route: 'admin.notes.settings',
icon: 'heroicon-o-document-text',
order: 195,
);
// Dashboard widget
app(DashboardWidgetRegistry::class)->registerWidget('notes-count', [
'label' => __('notes::messages.widget_label'),
'view' => 'notes::widgets.count',
'dataCallback' => function () {
return [
'count' => \DB::table('mod_notes_entries')->count(),
];
},
'order' => 180,
'size' => 'half',
]);
// Routes
Route::middleware(['web', 'auth'])
->prefix('admin/notes')
->name('admin.notes.')
->group(function () {
Route::get('settings', [Http\Controllers\NotesSettingsController::class, 'index'])
->name('settings');
Route::post('settings', [Http\Controllers\NotesSettingsController::class, 'update'])
->name('settings.update');
});
}
}
Everything happens in boot() — that is the phase in which the registries are guaranteed to be available. The register() method stays empty; binding registries there would run too early. See the ServiceProvider reference for the distinction between register and boot.
Step 4: Write the controller
modules/notes/src/Http/Controllers/NotesSettingsController.php:
<?php
namespace Schneespur\Module\Notes\Http\Controllers;
use App\Models\Setting;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class NotesSettingsController
{
public function index()
{
Gate::authorize('settings.view');
return view('notes::settings', [
'welcomeMessage' => Setting::get('notes.welcome_message', ''),
]);
}
public function update(Request $request)
{
Gate::authorize('settings.edit');
$validated = $request->validate([
'welcome_message' => 'required|string|max:500',
]);
Setting::set('notes.welcome_message', $validated['welcome_message']);
return redirect()->route('admin.notes.settings')
->with('success', __('notes::messages.settings_saved'));
}
}
The Gate::authorize() calls use the core permissions settings.view and settings.edit. The module respects the existing permission model from the start, without defining its own access rules.
Step 5: Write the views
modules/notes/resources/views/settings.blade.php:
<x-app-layout>
<x-slot name="header">
<h2 class="text-xl font-semibold text-gray-800">
{{ __('notes::messages.nav_label') }}
</h2>
</x-slot>
<div class="max-w-2xl mx-auto py-6">
@if(session('success'))
<div class="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg text-green-800">
{{ session('success') }}
</div>
@endif
<form method="POST" action="{{ route('admin.notes.settings.update') }}"
class="bg-white shadow rounded-lg p-6">
@csrf
<div class="mb-4">
<label for="welcome_message" class="block text-sm font-medium text-gray-700 mb-1">
{{ __('notes::messages.welcome_message_label') }}
</label>
<textarea id="welcome_message" name="welcome_message" rows="3"
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
>{{ old('welcome_message', $welcomeMessage) }}</textarea>
@error('welcome_message')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<button type="submit"
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md">
{{ __('notes::messages.save') }}
</button>
</form>
</div>
</x-app-layout>
modules/notes/resources/views/widgets/count.blade.php:
<div class="bg-white rounded-lg shadow p-4">
<h3 class="text-sm font-medium text-gray-500">{{ $label }}</h3>
<p class="mt-2 text-2xl font-bold text-gray-900">{{ $data['count'] ?? 0 }}</p>
<p class="text-xs text-gray-400 mt-1">{{ __('notes::messages.total_notes') }}</p>
</div>
Step 6: Write the migration
modules/notes/database/migrations/2026_06_01_000001_create_mod_notes_entries_table.php:
<?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('mod_notes_entries', function (Blueprint $table) {
$table->id();
$table->foreignId('job_id')->nullable()->constrained('service_jobs')->nullOnDelete();
$table->foreignId('user_id')->constrained('users');
$table->text('content');
$table->timestamps();
$table->index('job_id');
});
}
public function down(): void
{
Schema::dropIfExists('mod_notes_entries');
}
};
The mod_ table prefix makes it immediately clear that this table comes from a module, not from the core. All module-owned tables should follow this convention to avoid naming collisions.
Step 7: Write translations
modules/notes/lang/de/messages.php:
<?php
return [
'nav_label' => 'Notizen',
'widget_label' => 'Notizen',
'total_notes' => 'Notizen gesamt',
'welcome_message_label' => 'Willkommensnachricht',
'settings_saved' => 'Einstellungen gespeichert.',
'save' => 'Speichern',
];
modules/notes/lang/en/messages.php:
<?php
return [
'nav_label' => 'Notes',
'widget_label' => 'Notes',
'total_notes' => 'Total notes',
'welcome_message_label' => 'Welcome message',
'settings_saved' => 'Settings saved.',
'save' => 'Save',
];
Step 8: Run the migration and enable
php artisan migrate --path=modules/notes/database/migrations
Then enable the module through the admin panel under Settings → Modules.
The complete file tree
modules/notes/
├── module.json
├── src/
│ ├── NotesServiceProvider.php
│ └── Http/Controllers/
│ └── NotesSettingsController.php
├── resources/views/
│ ├── settings.blade.php
│ └── widgets/
│ └── count.blade.php
├── database/migrations/
│ └── 2026_06_01_000001_create_mod_notes_entries_table.php
└── lang/
├── de/messages.php
└── en/messages.php
What’s next
From here you can extend the module using the mechanisms covered in the other reference pages:
- Routes — list, create, and edit notes
- Event listeners — auto-create a note when a service job completes
- Permissions — define
notes.viewandnotes.editvia thePermissionRegistry - Notification channel — send notes via Telegram
- API endpoint — for external integrations
- Template slots — display notes on the job detail page
- Filter hooks — add a note count to the KPI dashboard
- Scheduled task — send a daily notes digest
The full registry API is documented in the Registries reference. A reference module is also included under modules/example/ in the application as a more complete example.