Skip to main content
Wintertrace
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:

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.