11 Middlewares and 6 Traits That Power a Multi-Tenant Laravel SaaS

php dev.to

Every request in a multi-tenant SaaS needs to answer a dozen questions before your controller runs. Which company context? Is the session valid? Is the user banned? Has the subscription expired? Should the screen be locked? What language?

I built Kohana.io - a production CRM/ERP - and ended up with 11 custom middlewares and 6 traits. Not because I wanted to over-engineer, but because each one solved a recurring problem. Now I'm extracting them into LaraFoundry, an open-source SaaS framework for Laravel.

This post covers the full middleware stack, all custom traits, and the design decisions behind them.

The Middleware Stack

Order matters. Here's the complete stack with execution sequence:

 1. HandleInertiaRequests         → shares props with Vue frontend
 2. SetActiveCompanyMiddleware    → resolves company context
 3. UpdateLastSessionActivity     → tracks activity + device info
 4. AddLinkHeadersForPreloadedAssets → HTTP/2 push
 5. StoreIntendedUrl              → saves URL for post-auth redirects
 6. SetLocale                     → detects language
 7. EnsureEmailIsVerified         → gates unverified users
 8. CheckPinLockMiddleware        → locks after inactivity
 9. CheckAccessMiddleware         → user bans, owner bans, payment
10. CheckSessionExists            → validates session in DB
11. CheckForSessionValidity       → regenerates token if needed
Enter fullscreen mode Exit fullscreen mode

Why order matters:

  • SetActiveCompanyMiddleware MUST run before CheckAccessMiddleware - access checks need company context to verify payment status
  • UpdateLastSessionActivity MUST run before CheckPinLockMiddleware - PIN timeout calculated from last_activity timestamp
  • SetLocale MUST run before EnsureEmailIsVerified - verification page needs correct language

In Laravel 12, middlewares are configured in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        HandleInertiaRequests::class,
        SetActiveCompanyMiddleware::class,
        UpdateLastSessionActivityMiddleware::class,
        AddLinkHeadersForPreloadedAssets::class,
        StoreIntendedUrl::class,
        SetLocale::class,
        EnsureEmailIsVerified::class,
        CheckPinLockMiddleware::class,
        CheckAccessMiddleware::class,
        CheckSessionExists::class,
        CheckForSessionValidityMiddleware::class,
    ]);
})
Enter fullscreen mode Exit fullscreen mode

One declaration. Full picture. No Kernel.php scattering.

Middleware Deep-Dives

SetActiveCompanyMiddleware - Company Context Resolution

A user can own Company A and be an employee at Company B simultaneously. This middleware determines which company context each request runs in.

public function handle(Request $request, Closure $next)
{
    if (!auth()->check() || !$request->user()->hasVerifiedEmail()) {
        return $next($request);
    }

    $user = $request->user();
    $sessionCompanyId = session('active_company_id');

    // Validate: does user still belong to this company?
    if ($sessionCompanyId && !$user->companies->contains('id', $sessionCompanyId)) {
        session()->forget('active_company_id');
        $sessionCompanyId = null;
    }

    // Priority: owned company, then employee company
    $companies = $user->companies()->whereNull('deleted_at')->get();
    $activeCompany = $companies->firstWhere('pivot.is_owner', true)
                  ?? $companies->first();

    $this->setActiveCompany($activeCompany);
    return $next($request);
}
Enter fullscreen mode Exit fullscreen mode

Resolution priority:

  1. Valid session company (user switched manually) → keep it
  2. First company where user is owner
  3. First company where user is employee

Edge cases handled: deleted companies filtered. Invalid session values cleared. Users who lost company access don't keep stale context.

CheckPinLockMiddleware - Inactivity Screen Lock

After 30 minutes without activity, the screen locks. PIN code required to continue.

public function handle(Request $request, Closure $next)
{
    $session = UserSession::where('session_id', session()->getId())->first();

    if (!$session || !$session->last_activity) {
        $session?->update(['last_activity' => now()]);
        return $next($request);
    }

    $secondsSinceActivity = now()->diffInSeconds($session->last_activity);

    if ($secondsSinceActivity >= config('security.pin_lock_timeout', 1800)) {
        $session->update(['pin_locked' => true]);

        if ($request->expectsJson()) {
            return response()->json(['message' => 'session_locked'], 423);
        }

        return redirect()->route('pin.enter');
    }

    return $next($request);
}
Enter fullscreen mode Exit fullscreen mode

Design decisions:

  • Database-backed state - PIN lock stored in user_sessions.pin_locked, not in PHP session. Can't bypass by manipulating cookies.
  • HTTP 423 for APIs - JSON requests get a proper status code, not a redirect to an HTML page.
  • Configurable timeout - config('security.pin_lock_timeout') defaults to 1800 seconds (30 min).
  • Unlock redirects back - After entering PIN, user returns to last_route_name stored by UpdateLastSessionActivity.

CheckAccessMiddleware - Three-Level Access Control

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

    // Level 1: User ban
    if ($user->user_blocked_at !== null) {
        $allowed = ['user.blocked', 'logout', 'notifications.*', 'tickets.*'];
        if (!$request->routeIs(...$allowed)) {
            return redirect()->route('user.blocked');
        }
    }

    // Level 2: Company owner ban
    $company = activeCompany();
    if ($company && $company->owner->user_blocked_at !== null) {
        return redirect()->route('company.payment.blocked', ['type' => 'owner_banned']);
    }

    // Level 3: Payment status
    if ($company && !$company->isInSetup() && !$company->hasAccess()) {
        $allowed = ['new_company.*', 'my_company.service_payment', 'company.payment.blocked'];
        if (!$request->routeIs(...$allowed)) {
            return redirect()->route('company.payment.blocked');
        }
    }

    return $next($request);
}
Enter fullscreen mode Exit fullscreen mode

Three levels with specific route whitelists:

Level Condition Allowed Routes Everything Else
User ban user_blocked_at != null logout, notifications, tickets, tutorials → user.blocked
Owner ban Company owner is banned (none for employees) → company.payment.blocked
Payment Trial/subscription expired payment pages, company settings → company.payment.blocked

Banned users can still reach support. Expired subscriptions still allow reaching payment pages. Practical, not punitive.

SetLocale - Language Detection Chain

// Authenticated user detection chain
private function detectAuthenticatedLocale(User $user, Request $request): string
{
    // 1. User profile preference
    if ($user->locale && in_array($user->locale, $availableLanguages)) {
        return $user->locale;
    }

    // 2. Session locale
    if (session('locale') && in_array(session('locale'), $availableLanguages)) {
        return session('locale');
    }

    // 3. Browser Accept-Language header
    $browserLocale = $this->detectFromBrowser($request);
    if ($browserLocale) return $browserLocale;

    // 4. IP geolocation (2s timeout)
    $geoLocale = $this->detectFromIp($request->ip());
    if ($geoLocale) return $geoLocale;

    // 5. App default
    return config('app.locale');
}
Enter fullscreen mode Exit fullscreen mode

For guests, the chain uses cookies (10-year lifetime) instead of user profile. IP geolocation calls ip-api.com with a 2-second timeout - if it fails, we silently fall back. No broken pages from API downtime.

Config-driven mappings:

// config/app.php
'browser_locale_map' => ['de' => 'de', 'uk' => 'ua', 'ru' => 'ru'],
'country_locale_map' => ['DE' => 'de', 'UA' => 'ua', 'US' => 'en'],
Enter fullscreen mode Exit fullscreen mode

Adding a new language = two array entries. No code changes.

UpdateLastSessionActivityMiddleware - Activity + Device Tracking

public function handle(Request $request, Closure $next)
{
    $response = $next($request);

    if (!auth()->check()) return $response;

    // Skip non-trackable requests
    if ($this->shouldSkip($request, $response)) return $response;

    $session = UserSession::where('session_id', session()->getId())->first();
    if (!$session) return $response;

    $agent = new Agent();

    $session->update([
        'last_activity' => now(),
        'last_route_name' => $request->route()?->getName(),
        'device_type' => $agent->isDesktop() ? 'desktop' : ($agent->isTablet() ? 'tablet' : 'mobile'),
        'device_name' => $agent->device(),
        'os' => $agent->platform(),
        'browser' => $agent->browser(),
    ]);

    $request->user()->update(['last_activity_at' => now()]);

    return $response;
}
Enter fullscreen mode Exit fullscreen mode

Skipped for: PIN routes (would reset timeout), non-HTML requests, redirects, excluded routes (profile, notifications, API).

Result: admin panel shows "User X on Chrome/Windows/Desktop, last active 3 minutes ago on orders page." Useful for support, analytics, and security monitoring.

Session Validation - Anti-Hijacking

Two middlewares work together:

CheckSessionExists - validates session for all requests:

$exists = UserSession::where('user_id', $user->id)
    ->where('session_id', session()->getId())
    ->exists();

if (!$exists) {
    Auth::logout();
    $request->session()->invalidate();

    return $request->expectsJson()
        ? response()->json(['message' => 'session_expired'], 401)
        : redirect('/');
}
Enter fullscreen mode Exit fullscreen mode

CheckForSessionValidity - additional cleanup for browser requests:

if (!$exists) {
    Cookie::queue(Cookie::forget('remember_web_*'));
    $request->session()->invalidate();
    $request->session()->regenerateToken();
    return redirect('/');
}
Enter fullscreen mode Exit fullscreen mode

Why two? Different cleanup for different scenarios. The first handles JSON/API (returns 401). The second handles full browser cleanup (forget cookies, regenerate CSRF token).

Force logout from admin panel = delete the user_sessions row. Next request from that device = instant rejection.

Custom Traits

HasPagination

trait HasPagination
{
    protected function getPaginationData($paginator): array
    {
        if (!$paginator instanceof LengthAwarePaginator
            && !$paginator instanceof Paginator) {
            return [];
        }

        return [
            'current_page' => $paginator->currentPage(),
            'last_page'    => $paginator->lastPage(),
            'per_page'     => $paginator->perPage(),
            'total'        => $paginator->total(),
            'from'         => $paginator->firstItem(),
            'to'           => $paginator->lastItem(),
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Used in every controller that lists data. Type-safe - returns empty array if input isn't a paginator. The frontend PagePaginator component expects this exact structure. 15 list views, zero pagination code duplication.

Controller usage:

$paginated = $query->filter($filter)->paginate($perPage)->withQueryString();

return Inertia::render('warehouse/Products', [
    'products'   => ProductResource::collection($paginated),
    'pagination' => $this->getPaginationData($paginated),
]);
Enter fullscreen mode Exit fullscreen mode

.withQueryString() preserves all filter parameters in pagination links.

Filter Auto-Discovery

abstract class Filter
{
    protected Builder $builder;
    protected Request $request;

    public function apply(Builder $builder)
    {
        $this->builder = $builder;
        foreach ($this->request->all() as $name => $value) {
            if (method_exists($this, $name)) {
                call_user_func_array([$this, $name], [$value]);
            }
        }
        return $this->builder;
    }
}
Enter fullscreen mode Exit fullscreen mode

Request param country=DE → calls $filter->country('DE'). Method exists = filter applied. No routing config. No switch statements. No registration.

Concrete example:

class ContragentsFilter extends Filter
{
    public function search_string($value = null)
    {
        $words = preg_split('/\s+/', trim($value));
        return $this->builder->where(function ($query) use ($words) {
            foreach ($words as $word) {
                $query->whereRaw('LOWER(name) LIKE ?', ['%'.mb_strtolower($word).'%']);
            }
        });
    }

    public function sort_by($value = null)
    {
        $allowed = ['name', 'country', 'balance', 'created_at'];
        if (in_array($value, $allowed)) {
            $direction = $this->request->input('sort_direction', 'asc');
            return $this->builder->orderBy($value, $direction);
        }
        return $this->builder->orderBy('name', 'asc');
    }

    public function starts_with($value = null)
    {
        return $this->builder->where('name', 'LIKE', $value . '%');
    }

    public function country($value = null)
    {
        return $this->builder->where('country', $value);
    }
}
Enter fullscreen mode Exit fullscreen mode

Key security: whitelisted sort columns prevent SQL injection. Word-by-word search for partial matching. Alphabetical navigation via starts_with().

NotificationDataHandler

trait NotificationDataHandler
{
    protected function prepareNotificationData(array $validated, string $status = 'draft'): array
    {
        return [
            'code' => $validated['code'],
            'notification_type' => $validated['notification_type'],
            'status' => $status,
            // Multilingual content
            'title' => ['en' => $validated['title_en'], 'de' => $validated['title_de'] ?? null],
            'body' => ['en' => $validated['body_en'], 'de' => $validated['body_de'] ?? null],
            // Recipient filters
            'filter_country' => $validated['filter_country'] ?? null,
            'filter_sex' => $validated['filter_sex'] ?? null,
            'filter_age_from' => $validated['filter_age_from'] ?? null,
            'filter_age_to' => $validated['filter_age_to'] ?? null,
            'filter_registered' => $validated['filter_registered'] ?? null,
            'filter_activity' => $validated['filter_activity'] ?? null,
            'filter_email_verified' => $validated['filter_email_verified'] ?? null,
            'filter_phone_verified' => $validated['filter_phone_verified'] ?? null,
            // Visibility window
            'visible_from' => $validated['visible_from'] ?? null,
            'visible_until' => $validated['visible_until'] ?? null,
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Notifications in a SaaS can target user segments: users from Germany, aged 25-40, registered this month, with verified email. This trait centralizes data transformation so create and update controllers use the same mapping. No drift between them.

HasUserFilterRules (Companion to NotificationDataHandler)

trait HasUserFilterRules
{
    protected function getUserFilterRules(): array
    {
        return [
            'filter_country' => ['nullable', Rule::in(config('app.available_countries'))],
            'filter_sex' => ['nullable', Rule::in(['m', 'f'])],
            'filter_age_from' => ['nullable', 'integer', 'min:16', 'max:100'],
            'filter_age_to' => ['nullable', 'integer', 'min:16', 'max:100'],
            'filter_registered' => ['nullable', Rule::in(['all', 'today', 'this_month', 'this_year'])],
            'filter_activity' => ['nullable', Rule::in(['recently_active', 'inactive'])],
            'filter_email_verified' => ['nullable', 'boolean'],
            'filter_phone_verified' => ['nullable', 'boolean'],
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Used in Form Requests for notification create/update. Validation rules defined once, shared across endpoints. Country list from config, age range constrained, activity levels enumerated.

LogsActivity (Audit Trail)

trait LogsActivity
{
    public static function bootLogsActivity(): void
    {
        foreach (['created', 'updated', 'deleted'] as $event) {
            static::$event(function ($model) use ($event) {
                static::logModelEvent($model, $event);
            });
        }
    }

    protected static function logModelEvent($model, $event): void
    {
        CustomActivity::create([
            'log_name' => 'model_changes',
            'description' => $event,
            'subject_type' => get_class($model),
            'subject_id' => $model->id,
            'causer_type' => auth()->check() ? get_class(auth()->user()) : null,
            'causer_id' => auth()->id(),
            'properties' => [
                'old' => $event === 'created' ? [] : $model->getOriginal(),
                'attributes' => $model->getDirty(),
            ],
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Built on top of Spatie's ActivityLog. Records who changed what, when, and the exact before/after values. Only logs dirty attributes - no noise from unchanged fields. Add use LogsActivity to any model and every create/update/delete gets audit-logged automatically.

How They Work Together

HTTP Request arrives
    │
    ├── HandleInertiaRequests: share frontend props
    ├── SetActiveCompanyMiddleware: resolve company context
    │       └── uses BelongsToCompany trait on models
    ├── UpdateLastSessionActivity: record timestamp + device
    ├── SetLocale: detect language
    ├── EnsureEmailIsVerified: gate check
    ├── CheckPinLockMiddleware: uses last_activity from step 3
    ├── CheckAccessMiddleware: uses company from step 2
    ├── CheckSessionExists: validates session in DB
    │
    v
Controller runs
    ├── HasPagination: format pagination data
    ├── Filter auto-discovery: apply query filters
    ├── NotificationDataHandler: prepare notification data
    │
    v
Model operations
    ├── BelongsToCompany: automatic tenant isolation
    ├── HasRolesAndPermissions: permission checks
    └── LogsActivity: audit trail
Enter fullscreen mode Exit fullscreen mode

Middleware handles the request lifecycle. Traits handle the business logic. Each layer independent, each layer tested separately.

Testing

Behavior-driven tests using Pest:

// Middleware: PIN lock
it('locks session after pin timeout', function () {
    $user = User::factory()->withPinCode()->create();
    UserSession::factory()->create([
        'user_id' => $user->id,
        'last_activity' => now()->subMinutes(31),
    ]);

    actingAs($user)->get('/dashboard')
        ->assertRedirect(route('pin.enter'));
});

// Middleware: Access control
it('blocks banned user but allows support tickets', function () {
    $user = User::factory()->blocked()->create();

    actingAs($user)->get('/dashboard')
        ->assertRedirect(route('user.blocked'));

    actingAs($user)->get('/tickets')
        ->assertOk();
});

// Middleware: Session validation
it('rejects invalid session with 401 for API', function () {
    $user = User::factory()->create();

    actingAs($user)->getJson('/api/data')
        ->assertStatus(401)
        ->assertJson(['message' => 'session_expired']);
});

// Trait: Filter auto-discovery
it('filters by country via query param', function () {
    Contragent::factory()->create(['country' => 'DE', 'company_id' => $company->id]);
    Contragent::factory()->create(['country' => 'US', 'company_id' => $company->id]);

    actingAs($user)->get('/contragents/customers?country=DE')
        ->assertInertia(fn ($page) => $page->has('contragents', 1));
});

// Trait: Pagination
it('returns consistent pagination structure', function () {
    Contragent::factory(30)->create(['company_id' => $company->id]);

    actingAs($user)->get('/contragents/customers?page=2')
        ->assertInertia(fn ($page) =>
            $page->has('pagination', fn ($p) =>
                $p->where('current_page', 2)
                   ->where('per_page', 20)
            )
        );
});
Enter fullscreen mode Exit fullscreen mode

No middleware mocking. Real HTTP requests. Assert what users experience. If someone reorders the middleware stack but behavior stays the same - tests pass. If someone removes a middleware - tests catch it.

Design Principles

  1. Middleware order is explicit - documented, tested, justified. Each position depends on what runs before it.
  2. Traits are composable - BelongsToCompany + HasRolesAndPermissions + LogsActivity on any model = full tenant isolation + RBAC + audit trail.
  3. Security is layered - Session validation → PIN lock → access control → permission checks. Multiple barriers, not one big gate.
  4. Behavior over implementation - Tests verify user experience, not internal code paths.
  5. Config over code - Locale mappings, timeout values, sort whitelists - all configurable without touching logic.

LaraFoundry is an open-source Laravel SaaS framework, being built in public and extracted from a production CRM/ERP.

laravel #php #saas #middleware #larafoundry

Source: dev.to

arrow_back Back to Tutorials