Skip to main content
Wintertrace
Browse the documentation

Building modules

Portal & custom dashboards

Extend the customer portal and build custom dashboards from a module, including the registry calls and what each surface can render.

The application ships with a customer portal — a separate, authenticated area that runs independently of the admin area. This portal is also the reference pattern to follow when a module needs its own role-specific area, such as a dashboard for accounting or warehouse staff.

The customer portal pattern

The customer portal is a separate authenticated area at /portal with its own guard, middleware, layout, and controllers. It is deliberately decoupled from the admin area — a customer logs in at a different entry point and sees a different interface from an admin user.

/portal/login          → PortalAuthController (customer guard)
/portal/               → PortalDashboardController
/portal/jobs           → PortalJobController
/portal/profile        → PortalProfileController
/portal/notifications  → PortalNotificationController
/portal/reports        → PortalPdfController

The key components:

  • Guard: customer — separate from the web guard, so customer and staff sessions never mix.
  • Middleware: EnsureCustomer — validates customer authentication, checks the portal_enabled flag, and applies the customer’s locale (customers.locale, if registered in the LocaleRegistry).
  • Layout: layouts/portal.blade.php — independent from the admin layout.
  • Model: Customer — acts as the “user” for this guard.
  • Session: Laravel’s standard session, bound to the customer guard.

Why a separate guard: customers are not staff. A separate guard keeps the two authentication paths cleanly apart — an authenticated customer never accidentally gains access to admin routes, and vice versa.

Portal feature flags per customer

What the portal shows each customer is controlled by four flags. This way each customer sees only what has been enabled for them — for example without driver names if that is required for privacy reasons:

SettingTypeEffect
portal_enabledboolEnable/disable portal access
portal_show_gpsboolShow GPS tracks in job details
portal_show_photosboolShow job photos
portal_show_driver_nameboolShow driver name (privacy)

Building a role-specific dashboard module

If you need a standalone dashboard area — for example /buchhaltung for accounting or /lager for a warehouse — follow the same pattern as the portal. The steps below walk through an accounting dashboard.

Step 1: Define the role

Option A — use the existing permission system with a custom role. This is the simpler path when your users are standard application users with a special role:

// In your ServiceProvider boot()
$templates = app(RoleTemplateRegistry::class);
$templates->registerTemplate(
    slug: 'accountant',
    name: 'Accounting',
    description: 'Access to the accounting dashboard',
    permissions: ['dashboard.view', 'documents.view', 'reports.view'],
    module: 'accounting-dashboard',
);

Option B — a fully separate authentication area (like the customer portal). Create a custom guard and middleware. Choose this path only when the user group truly needs to be separate from ordinary application users.

Step 2: Middleware

The middleware is the gatekeeper of the area. It checks on every request whether the authenticated user has the correct role — otherwise it redirects to the login page:

namespace Schneespur\Module\AccountingDashboard\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class EnsureAccountant
{
    public function handle(Request $request, Closure $next)
    {
        $user = $request->user();

        if (!$user || !$user->hasRole('accountant')) {
            return redirect()->route('login');
        }

        return $next($request);
    }
}

Register the middleware under an alias in your ServiceProvider so it can be referenced concisely in routes:

app('router')->aliasMiddleware('ensure.accountant', EnsureAccountant::class);

Step 3: Routes

Group your routes under a shared prefix and protect them with the middleware registered above. The protection then applies to the whole group — you do not need to repeat it on each route:

Route::middleware(['web', 'auth', 'ensure.accountant'])
    ->prefix('buchhaltung')
    ->name('accounting.')
    ->group(function () {
        Route::get('/', [AccountingDashboardController::class, 'index'])->name('dashboard');
        Route::get('/documents', [AccountingDocumentController::class, 'index'])->name('documents');
        Route::get('/invoices', [AccountingInvoiceController::class, 'index'])->name('invoices');
        Route::get('/exports', [AccountingExportController::class, 'index'])->name('exports');
    });

Step 4: Layout

A dedicated area deserves a dedicated layout — with its own navigation and appearance, separate from the admin layout. The @extensionSlot positions leave room for other modules to hook in at defined points later:

{{-- resources/views/layouts/accounting.blade.php --}}
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{{ brand() }} — Accounting</title>
    @vite(['resources/css/app.css'])
    @moduleAssets
    @extensionSlot('accounting.head.after')
</head>
<body class="bg-gray-50">
    <nav class="bg-white shadow">
        {{-- Accounting-specific navigation --}}
        <div class="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
            <span class="font-bold text-lg">{{ brand() }} Accounting</span>
            <div class="flex gap-4">
                <a href="{{ route('accounting.documents') }}" class="text-gray-700 hover:text-blue-600">Documents</a>
                <a href="{{ route('accounting.invoices') }}" class="text-gray-700 hover:text-blue-600">Invoices</a>
                <a href="{{ route('accounting.exports') }}" class="text-gray-700 hover:text-blue-600">Exporte</a>
            </div>
            <form method="POST" action="{{ route('logout') }}">
                @csrf
                <button type="submit" class="text-gray-500 hover:text-red-600">Sign out</button>
            </form>
        </div>
    </nav>

    <main class="max-w-7xl mx-auto px-4 py-8">
        @extensionSlot('accounting.content.before')
        {{ $slot }}
        @extensionSlot('accounting.content.after')
    </main>
</body>
</html>

Step 5: Dashboard controller

The controller supplies data to the view. Keep it lean — it fetches the values and passes them on:

namespace Schneespur\Module\AccountingDashboard\Http\Controllers;

use Illuminate\Http\Request;

class AccountingDashboardController
{
    public function index(Request $request)
    {
        return view('accounting-dashboard::dashboard', [
            'recentDocuments' => Document::latest()->take(10)->get(),
            'pendingInvoices' => Invoice::where('status', 'pending')->count(),
        ]);
    }
}

Step 6: Post-login redirect

To land a user directly in their area after login, hook into the login event and set the redirect destination based on the role:

// Or listen to the UserLoggedIn event
$this->app['events']->listen(UserLoggedIn::class, function (UserLoggedIn $event) {
    if ($event->user->hasRole('accountant')) {
        session(['url.intended' => route('accounting.dashboard')]);
    }
});

Extending the existing portal

Often a module does not need a new area at all — it just needs to add something to the existing customer portal, such as a document list. For this, there are lighter-weight approaches.

Adding a portal navigation item

Use the PortalNavigationRegistry (since 1.1.3). It renders the item in both menus — the desktop navigation and the mobile menu — and handles the active state:

$nav = app(\App\Services\Extension\PortalNavigationRegistry::class);

$nav->addItem(
    slug: 'documents',
    label: 'my-module::portal.nav_documents', // translation key, resolved at render
    route: 'portal.documents.index',
    order: 25,
    activePattern: 'portal.documents.*',
    condition: fn (\App\Models\Customer $c) => true, // optional per-customer visibility
);

The condition closure is optional. It receives the authenticated customer and decides per customer whether the item is visible — fn (\App\Models\Customer $c): bool.

The older approach — appending a Blade partial to the portal.nav.after slot — still works but only renders in the desktop header. The mobile menu has no such slot, and you would have to build the markup and active state yourself. Prefer the registry for menu links; reserve portal.nav.after for non-link content. See more under Registries and Navigation & dashboard.

Adding portal routes

Protect your portal routes with the same guard and middleware as the portal itself — that way the same access rules apply:

Route::middleware(['web', 'auth:customer', EnsureCustomer::class])
    ->prefix('portal/documents')
    ->name('portal.documents.')
    ->group(function () {
        Route::get('/', [PortalDocumentController::class, 'index'])->name('index');
        Route::get('/{document}/download', [PortalDocumentController::class, 'download'])->name('download');
    });

Accessing the authenticated customer

Within portal logic, retrieve the authenticated customer through the customer guard:

$customer = auth('customer')->user(); // Returns Customer model

Architecture recommendation

If you are planning multiple role-specific dashboards, a shared foundation is worthwhile rather than several nearly identical areas. A single “role dashboards” framework module is recommended, providing:

  1. A reusable RoleDashboard base class with URL prefix, middleware, layout slots, and widget support.
  2. Each additional role dashboard registers into this framework.
  3. Duplicate code for authentication, layout, and widgets across multiple modules is avoided.

The customer portal already demonstrates this pattern — though hard-coded. A generic version would accept a role slug, URL prefix, layout template, and allowed permission groups, and in return provide login redirection, a layout with slots, widget rendering, and navigation building. Such a framework module is not part of the current distribution; the portal serves as the template to follow until one exists.