Plan-limit gate middleware
Route middleware that blocks a request when the team has used up its plan allowance for a named limit, with a clear upgrade response.
LaravelMiddlewareplanslimitsmiddlewaresaas
Code
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnforcePlanLimit
{
public function handle(Request $request, Closure $next, string $limit): Response
{
$team = $request->user()?->team;
abort_unless($team, 403);
$allowed = config("plans.{$team->plan}.{$limit}");
// Unlimited plans skip the count query entirely.
if ($allowed === null) {
return $next($request);
}
$used = $team->usage($limit);
if ($used >= $allowed) {
$payload = [
'message' => "Your {$team->plan} plan allows {$allowed} {$limit}.",
'limit' => $limit,
'used' => $used,
'allowed' => $allowed,
'upgrade_url' => route('billing.plans'),
];
return $request->expectsJson()
? response()->json($payload, 402)
: redirect()->route('billing.plans')->with('error', $payload['message']);
}
return $next($request);
}
}
<?php
// Allowances per plan. null means unlimited.
return [
'starter' => ['contacts' => 1000, 'campaigns' => 5, 'seats' => 2],
'pro' => ['contacts' => 25000, 'campaigns' => 100, 'seats' => 10],
'scale' => ['contacts' => null, 'campaigns' => null, 'seats' => null],
];
How it works
What it solves
Subscription products sell allowances: contacts, campaigns, seats. Checking them inside every controller drifts quickly and produces inconsistent errors. A middleware declared on the route makes the rule visible next to the URL it protects and gives every limit the same failure shape.
How it works
Plans live in config/plans.php as a simple map of limit names to numbers, where null means unlimited. The middleware takes the limit name and a counter key as parameters: plan.limit:contacts. It looks up the team's plan, counts current usage through a small resolver on the team model, and compares.
When the allowance is spent it returns a 402 for JSON clients, or redirects back with a flash message for browsers, and includes the limit, the usage and the plan name so the client can render an upgrade prompt. Unlimited plans short-circuit before any count query runs, so top-tier customers pay no extra cost per request.
Because the check runs before the controller, it is a gate, not a guarantee: two simultaneous requests can both pass the last slot.
Adapting it
Add usage($limit) to your team model returning an int, for example contacts()->count(). Register the alias in bootstrap/app.php. For hard guarantees pair this with a database constraint or an atomic counter at write time.
Pitfalls
- Gate only creating routes, not reads, or customers cannot even view their data when over the limit.
- Cache the count if it is expensive, but expire it on writes.
How to use it
- Add
config/plans.phpand the middleware class. - Register the alias in
bootstrap/app.php:$middleware->alias(['plan.limit' => EnforcePlanLimit::class]);. - Give your
Teammodel aplanattribute and ausage(string $limit): intmethod. - Protect creating routes:
Route::post('/contacts', [ContactController::class, 'store'])->middleware('plan.limit:contacts');. - Show
response()->json()['upgrade_url']in your front end when you receive a 402.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.