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
Why order matters:
-
SetActiveCompanyMiddlewareMUST run beforeCheckAccessMiddleware- access checks need company context to verify payment status -
UpdateLastSessionActivityMUST run beforeCheckPinLockMiddleware- PIN timeout calculated from last_activity timestamp -
SetLocaleMUST run beforeEnsureEmailIsVerified- 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,
]);
})
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);
}
Resolution priority:
- Valid session company (user switched manually) β keep it
- First company where user is owner
- 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);
}
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_namestored 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);
}
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');
}
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'],
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;
}
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('/');
}
CheckForSessionValidity - additional cleanup for browser requests:
if (!$exists) {
Cookie::queue(Cookie::forget('remember_web_*'));
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
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(),
];
}
}
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),
]);
.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;
}
}
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);
}
}
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,
];
}
}
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'],
];
}
}
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(),
],
]);
}
}
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
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)
)
);
});
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
- Middleware order is explicit - documented, tested, justified. Each position depends on what runs before it.
-
Traits are composable -
BelongsToCompany+HasRolesAndPermissions+LogsActivityon any model = full tenant isolation + RBAC + audit trail. - Security is layered - Session validation β PIN lock β access control β permission checks. Multiple barriers, not one big gate.
- Behavior over implementation - Tests verify user experience, not internal code paths.
- 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.
- GitHub: github.com/dmitryisaenko/larafoundry
- Website: larafoundry.com











