| name | team-billing |
| description | Enforce team isolation patterns and Stripe billing logic when modifying controllers or models. |
| paths | ["app/Http/Controllers/**/*.php","app/Models/**/*.php","app/Actions/**/*.php"] |
Team Billing & Multi-Tenancy Security
Team Isolation (Critical)
Every data query must verify team ownership. Never access records without team context.
Policy-First Authorization
public function show(Invoice $invoice)
{
$this->authorize('view', $invoice);
return new InvoiceResource($invoice);
}
public function show($id)
{
return Invoice::find($id);
}
Model Scope Pattern
public function scopeForTeam(Builder $query, Team $team): Builder
{
return $query->where('team_id', $team->id);
}
$invoices = Invoice::forTeam($user->team)->get();
Policy Implementation
public function view(User $user, Invoice $invoice): bool
{
return $user->team_id === $invoice->team_id;
}
public function update(User $user, Invoice $invoice): bool
{
return $user->team_id === $invoice->team_id && $user->hasRole('admin');
}
public function delete(User $user, Invoice $invoice): bool
{
return $user->team_id === $invoice->team_id && $user->hasRole('admin');
}
Middleware Layer
public function handle(Request $request, Closure $next)
{
$model = $request->route()->parameter('invoice');
if ($model->team_id !== auth()->user()->team_id) {
abort(403, 'Unauthorized team access');
}
return $next($request);
}
Stripe Billing with Laravel Cashier
Subscription States
Track in subscriptions table:
status: 'active' | 'past_due' | 'canceled' | 'incomplete'
stripe_id: Stripe subscription reference
price_id: Stripe price (monthly/yearly)
current_period_end: Next billing date
Action Pattern (Abstract Stripe)
Never call Cashier directly in controllers. Use Actions:
class CreateTeamSubscriptionAction
{
public function __construct(private StripeService $stripe) {}
public function execute(Team $team, string $priceId): Subscription
{
$subscription = $team->subscriptionBuilder()
->add('price', $priceId)
->create();
return Subscription::create([
'team_id' => $team->id,
'stripe_id' => $subscription->id,
'price_id' => $priceId,
'status' => $subscription->status,
'current_period_end' => now()->addMonth(),
]);
}
}
Controller Usage
public function upgrade(UpgradeRequest $request, CreateTeamSubscriptionAction $action)
{
$subscription = $action->execute(auth()->user()->team, $request->price_id);
return redirect('/billing')->with('success', 'Upgraded successfully');
}
Webhook Handling
public function handleSubscriptionUpdated(array $payload)
{
$subscription = Subscription::where('stripe_id', $payload['id'])->firstOrFail();
$subscription->update([
'status' => $payload['status'],
'current_period_end' => Carbon::createFromTimestamp($payload['current_period_end']),
]);
}
public function handleSubscriptionDeleted(array $payload)
{
Subscription::where('stripe_id', $payload['id'])
->update(['status' => 'canceled']);
}
Feature Gating by Subscription
public function isPro(): bool
{
return $this->subscription?->status === 'active'
&& str_contains($this->subscription->price_id, 'pro');
}
public function canCreateInvoices(): bool
{
return $this->isPro() || $this->subscription?->invoice_limit === null;
}
In Controllers
public function create()
{
if (!auth()->user()->team->canCreateInvoices()) {
return redirect('/billing/upgrade');
}
return inertia('CreateInvoice');
}
In Policies
public function create(User $user): bool
{
return $user->team->canCreateInvoices();
}
Usage Tracking
public function trackUsage(Team $team, string $metric)
{
$team->increment("usage_{$metric}");
$limit = $team->subscription?->limits[$metric] ?? 0;
if ($limit > 0 && $team->{"usage_{$metric}"} >= $limit) {
throw new UsageLimitExceededException("$metric limit reached");
}
}
Testing
test('user can upgrade to pro', function () {
$team = Team::factory()->create();
$user = User::factory()->for($team)->create();
$response = $this->actingAs($user)
->post('/billing/upgrade', ['price_id' => 'price_pro']);
expect($response)->redirect('/billing');
expect($team->refresh()->isPro())->toBeTrue();
});
test('webhook updates subscription status', function () {
$subscription = Subscription::factory()->create();
$event = [
'type' => 'customer.subscription.updated',
'data' => [
'object' => [
'id' => $subscription->stripe_id,
'status' => 'past_due',
],
],
];
->(, , [
=> ->(),
]);
(->()->status)->();
});
Security Checklist
Common Vulnerabilities
❌ Accessing model without authorization
return Invoice::find($request->invoice_id);
✅ Proper access
$invoice = Invoice::find($request->invoice_id);
$this->authorize('view', $invoice);
return $invoice;
❌ Trusting Stripe webhook without verification
$subscription = Subscription::where('stripe_id', $request->input('stripe_id'))->first();
✅ Verified access
$event = Stripe::webhook()->verify($payload);
$subscription = Subscription::where('stripe_id', $event['id'])->firstOrFail();
❌ Feature gating without subscription check
public function export() { ... }
✅ Protected feature
public function export()
{
abort_unless(auth()->user()->team->isPro(), 403);
}