Middleware in Laravel is one of those things that looks deceptively simple when you first encounter it. You register it, you attach it to routes, and off you go. But after building and maintaining SaaS products for a good few years now, I've learned that how you organise and structure your middleware can make a real difference to how maintainable your app becomes — especially once you're past the MVP stage and things start getting complicated.
This post is about the patterns I've settled on. They're not groundbreaking, but they've saved me from messy codebases more than once, and I think they're worth sharing.
The Problem With "Just Registering Stuff"
When you're moving fast on a new product, it's tempting to shove middleware into bootstrap/app.php and call it done. A check here, a redirect there — it works fine at first. But once you've got subscription checks, tenant resolution, role enforcement, API rate limiting, and onboarding flow guards all running through the same pipeline, things get muddy fast.
I've inherited codebases where middleware was doing too much: querying the database for three different things, making decisions that belonged in policies, and — worst of all — silently failing in ways that were almost impossible to debug.
Rule One: Middleware Should Do One Thing
This sounds obvious, but it's worth saying out loud. Middleware is responsible for inspecting the request and deciding whether to let it through. That's it. It shouldn't be resolving your tenant and checking their subscription and logging the request all in one class.
I break these concerns into focused, single-purpose classes:
ResolveTenant— figures out which tenant owns this requestEnsureSubscriptionIsActive— checks the resolved tenant has a valid subscriptionEnforceOnboarding— redirects new users who haven't completed setupTrackLastSeen— lightweight, just updates a timestamp
Each of these is tiny. Each is testable. And crucially, each can be applied independently depending on the route group.
Using Middleware Groups Purposefully
In Laravel 12, the middleware stack in bootstrap/app.php gives you a clean way to define your own groups. I typically have something like:
->withMiddleware(function (Middleware $middleware) {
$middleware->appendToGroup('tenant', [
\App\Http\Middleware\ResolveTenant::class,
\App\Http\Middleware\EnsureSubscriptionIsActive::class,
]);
$middleware->appendToGroup('onboarding', [
\App\Http\Middleware\EnforceOnboarding::class,
]);
})
Then in my routes, I'm applying groups rather than individual middleware classes everywhere. This keeps route files clean and means I can adjust the group without touching every route definition.
Tenant Resolution Belongs Early
If your SaaS is multi-tenant — even loosely — you want tenant resolution happening as early as possible in the pipeline. Everything downstream may depend on knowing which tenant is active: your database connection, your config values, your cache prefix.
I resolve the tenant from the subdomain or a header, store it on a simple TenantContext singleton that gets bound in the service container, and then everything else just asks the container for it. Simple, predictable, and easy to mock in tests.
class ResolveTenant
{
public function handle(Request $request, Closure $next): Response
{
$tenant = Tenant::whereSubdomain($request->getHost())->firstOrFail();
app()->instance(TenantContext::class, new TenantContext($tenant));
return $next($request);
}
}
Yes, this hits the database on every request. In practice, this is a prime candidate for caching — I'll typically wrap that firstOrFail() with a short-lived cache keyed on the subdomain. For most SaaS apps, tenant data doesn't change often enough to worry about stale reads on a 60-second cache.
Testing Middleware Without Losing the Plot
One of the biggest wins from keeping middleware focused is how much easier they become to test. I write unit tests for the middleware class itself, and integration tests for the route groups.
For unit tests, Laravel's $this->withoutMiddleware() is useful for isolating controller logic, but I also write tests that specifically exercise middleware:
/** @test */
public function it_redirects_inactive_subscribers_to_billing()
{
$tenant = Tenant::factory()->create(['subscription_status' => 'cancelled']);
$this->actingAs($tenant->owner)
->get(route('dashboard'))
->assertRedirect(route('billing'));
}
These tests are fast and focused. They tell me exactly what the middleware does without testing the entire application stack.
A Note on Ordering
Middleware order matters more than most people realise. If your subscription check runs before tenant resolution, it has nothing to check against. If your CSRF middleware runs before authentication in an API context, you'll get confusing failures.
I keep a simple comment block at the top of any complex middleware group listing the expected order and the reason for it. Future me — and any other developer on the project — is always grateful for that.
Don't Over-Abstract
One trap I fell into early on was creating a base middleware class with shared logic that other middleware extended. It felt elegant at the time. It became a maintenance headache when requirements diverged and I was contorting the base class to accommodate both children.
Keep them flat. Share logic through injected services or traits if you must, but middleware inheritance is rarely the right call.
Wrapping Up
Middleware isn't the most glamorous part of a Laravel application, but getting it right pays dividends throughout the entire project lifecycle. Focused classes, intentional grouping, early tenant resolution, and proper testing have collectively saved me hours of debugging and refactoring on production SaaS apps.
If you're working on a Laravel SaaS product and your middleware is starting to feel tangled, I'd encourage you to take an hour and audit what each class is actually doing. You might be surprised how much you can simplify — and how much clearer your routes become as a result.
As always, if you've got a different approach or something that's worked well for you, I'd love to hear about it. Drop a comment below or find me on Twitter/X.