1. What We're Building
By the end of this Laravel 12 SaaS tutorial, you'll have a fully functional SaaS application called ContentPilot — a content management platform where teams collaborate on blog posts and marketing copy, powered by AI. Here's the feature set:
- Authentication — Email/password, Google OAuth, two-factor authentication
- Teams — Create teams, invite members, assign roles (owner, admin, member)
- Billing — Free, Pro ($29/mo), and Business ($79/mo) plans via Stripe
- Admin panel — Manage users, teams, and subscriptions with Filament
- AI chatbot — Customer support agent that answers questions from your knowledge base
- AI content generator — Generate blog posts, social media copy, and email campaigns with structured output
- Usage limits — AI requests are metered and gated by subscription plan
This is a real architecture that powers production SaaS apps. We'll cover every layer and show how the SaaS foundation and AI features connect.
2. Project Setup and Architecture
Start by scaffolding a new Laravel 12 project:
laravel new contentpilot
cd contentpilot
Our tech stack:
- Backend: Laravel 12, PHP 8.3+
- Frontend: Livewire 3 + Tailwind CSS 4
- Database: PostgreSQL (for pgvector support with AI embeddings)
- Billing: Laravel Cashier (Stripe)
- Admin: Filament 3
- AI: Laravel AI SDK (
laravel/ai) - Testing: Pest 3
Install the core dependencies:
composer require laravel/cashier livewire/livewire filament/filament laravel/ai
composer require --dev pestphp/pest
Configure your .env with database, Stripe, and AI provider credentials:
DB_CONNECTION=pgsql
DB_DATABASE=contentpilot
STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...
Run the AI SDK installer which creates migration tables for conversation persistence:
php artisan install:ai
php artisan migrate
The directory structure we'll build:
app/
├── Ai/
│ ├── Agents/
│ │ ├── SupportChatbot.php
│ │ └── ContentWriter.php
│ ├── Tools/
│ │ ├── SearchKnowledgeBase.php
│ │ └── GetTeamContext.php
│ └── Middleware/
│ └── TrackAiUsage.php
├── Models/
│ ├── User.php
│ ├── Team.php
│ ├── Invitation.php
│ └── AiUsageLog.php
├── Http/Controllers/
│ ├── AiChatController.php
│ └── AiContentController.php
└── Filament/
└── Resources/
3. Authentication with Email, Google, and 2FA
Every SaaS needs solid authentication. We'll implement three layers: email/password registration, Google OAuth for faster onboarding, and optional two-factor authentication for security.
Email Registration and Login
Laravel 12 ships with starter kits. For a Livewire stack:
php artisan make:auth
This scaffolds registration, login, password reset, and email verification. Customize the registration to create a default team for new users:
<?php
namespace App\Actions;
use App\Models\Team;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
class CreateUser
{
public function __invoke(array $data): User
{
return DB::transaction(function () use ($data) {
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
]);
$team = Team::create([
'name' => $user->name . "'s Team",
'owner_id' => $user->id,
]);
$user->teams()->attach($team, ['role' => 'owner']);
$user->update(['current_team_id' => $team->id]);
return $user;
});
}
}
Google OAuth
Install Socialite and configure Google:
composer require laravel/socialite
# .env
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
GOOGLE_REDIRECT_URI=https://contentpilot.com/auth/google/callback
// routes/web.php
use Laravel\Socialite\Facades\Socialite;
Route::get('/auth/google', fn () =>
Socialite::driver('google')->redirect()
)->name('auth.google');
Route::get('/auth/google/callback', function () {
$googleUser = Socialite::driver('google')->user();
$user = User::firstOrCreate(
['email' => $googleUser->getEmail()],
[
'name' => $googleUser->getName(),
'google_id' => $googleUser->getId(),
'password' => Hash::make(Str::random(24)),
'email_verified_at' => now(),
],
);
// Create default team if this is a new user
if ($user->wasRecentlyCreated) {
app(CreateUser::class)->createDefaultTeam($user);
}
Auth::login($user, remember: true);
return redirect('/dashboard');
});
Two-Factor Authentication
Use Laravel Fortify for 2FA with TOTP (Google Authenticator, Authy):
composer require laravel/fortify
// config/fortify.php
'features' => [
Features::registration(),
Features::emailVerification(),
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]),
],
Users enable 2FA from their profile settings. Once activated, they scan a QR code and enter a TOTP code on every login. For a deeper dive on authentication patterns, see our Laravel SaaS authentication guide.
4. Team-Based Multi-Tenancy
Most AI-powered SaaS apps are collaborative. Teams share AI usage, content, and billing. We'll implement team-based multi-tenancy where every piece of data is scoped to the current team.
php artisan make:model Team -m
// database/migrations/create_teams_table.php
Schema::create('teams', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->foreignId('owner_id')->constrained('users');
$table->string('stripe_id')->nullable()->index();
$table->string('pm_type')->nullable();
$table->string('pm_last_four', 4)->nullable();
$table->timestamp('trial_ends_at')->nullable();
$table->timestamps();
});
// Pivot table for team membership
Schema::create('team_user', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('role')->default('member'); // owner, admin, member
$table->timestamps();
$table->unique(['team_id', 'user_id']);
});
Create a BelongsToTeam trait that automatically scopes all queries to the user's current team:
<?php
namespace App\Concerns;
use App\Models\Team;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
trait BelongsToTeam
{
protected static function bootBelongsToTeam(): void
{
// Auto-assign team on creation
static::creating(function ($model) {
if (! $model->team_id && auth()->check()) {
$model->team_id = auth()->user()->current_team_id;
}
});
// Global scope: always filter by current team
static::addGlobalScope('team', function (Builder $query) {
if (auth()->check() && auth()->user()->current_team_id) {
$query->where('team_id', auth()->user()->current_team_id);
}
});
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
}
Now apply this trait to every team-scoped model:
class Post extends Model
{
use BelongsToTeam;
}
class AiUsageLog extends Model
{
use BelongsToTeam;
}
// Every query is automatically scoped:
$posts = Post::all(); // Only returns posts for the current team
$usage = AiUsageLog::where('created_at', '>=', now()->startOfMonth())->count();
Team Invitations
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class Invitation extends Model
{
protected $fillable = ['team_id', 'email', 'role', 'token'];
protected static function booted(): void
{
static::creating(fn ($inv) => $inv->token = Str::random(64));
}
public function team() { return $this->belongsTo(Team::class); }
}
// Controller to send invitation
public function invite(Request $request)
{
$request->validate([
'email' => 'required|email',
'role' => 'required|in:admin,member',
]);
$invitation = Invitation::create([
'team_id' => currentTeam()->id,
'email' => $request->email,
'role' => $request->role,
]);
Mail::to($request->email)->send(new TeamInvitationMail($invitation));
return back()->with('success', 'Invitation sent.');
}
For a comprehensive guide on multi-tenancy with roles, permissions, and scoped billing, see our dedicated Laravel multi-tenancy guide.
5. Subscription Billing with Stripe
Billing is attached to teams, not individual users. This way, one person pays and the whole team gets access (including AI features).
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Laravel\Cashier\Billable;
class Team extends Model
{
use Billable;
public function users() { return $this->belongsToMany(User::class)->withPivot('role'); }
public function owner() { return $this->belongsTo(User::class, 'owner_id'); }
public function onFreePlan(): bool
{
return ! $this->subscribed('default');
}
public function onProPlan(): bool
{
return $this->subscribedToPrice(config('saas.plans.pro.stripe_price_id'));
}
public function onBusinessPlan(): bool
{
return $this->subscribedToPrice(config('saas.plans.business.stripe_price_id'));
}
public function aiRequestsLimit(): int
{
return match (true) {
$this->onBusinessPlan() => 5000,
$this->onProPlan() => 500,
default => 25, // free plan
};
}
}
Define your plans in config:
// config/saas.php
return [
'plans' => [
'free' => [
'name' => 'Free',
'ai_requests' => 25,
],
'pro' => [
'name' => 'Pro',
'price' => 29,
'stripe_price_id' => env('STRIPE_PRO_PRICE_ID'),
'ai_requests' => 500,
],
'business' => [
'name' => 'Business',
'price' => 79,
'stripe_price_id' => env('STRIPE_BUSINESS_PRICE_ID'),
'ai_requests' => 5000,
],
],
];
Checkout flow using Cashier's hosted checkout:
// routes/web.php
Route::post('/subscribe/{plan}', function (string $plan) {
$priceId = config("saas.plans.{$plan}.stripe_price_id");
return currentTeam()
->newSubscription('default', $priceId)
->checkout([
'success_url' => route('dashboard') . '?subscribed=1',
'cancel_url' => route('pricing'),
]);
})->middleware(['auth', 'verified']);
Handle the Stripe webhook to keep subscription status in sync:
// routes/web.php
Route::post('/stripe/webhook', [\Laravel\Cashier\Http\Controllers\WebhookController::class, 'handleWebhook']);
6. Admin Panel with Filament
Every SaaS needs an admin panel to manage users, teams, subscriptions, and monitor AI usage. Filament 3 gives you a production-ready admin in minutes.
php artisan filament:install --panels
php artisan make:filament-resource User --generate
php artisan make:filament-resource Team --generate
Create a custom widget to monitor AI usage across all teams:
php artisan make:filament-widget AiUsageOverview --stats
<?php
namespace App\Filament\Widgets;
use App\Models\AiUsageLog;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class AiUsageOverview extends StatsOverviewWidget
{
protected function getStats(): array
{
$today = AiUsageLog::whereDate('created_at', today());
return [
Stat::make('AI Requests Today', $today->count())
->description('Across all teams')
->chart($this->getDailyTrend())
->color('success'),
Stat::make('Tokens Used Today', number_format($today->sum('total_tokens')))
->description('Input + output tokens'),
Stat::make('Estimated Cost', '$' . number_format($today->sum('estimated_cost_usd'), 2))
->description('Based on provider pricing')
->color('warning'),
];
}
private function getDailyTrend(): array
{
return AiUsageLog::query()
->where('created_at', '>=', now()->subDays(7))
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->orderBy('date')
->pluck('count')
->toArray();
}
}
Protect the admin panel with a simple gate:
// app/Providers/Filament/AdminPanelProvider.php
->authGuard('web')
->authMiddleware([Authenticate::class])
->login()
->discoverResources(in: app_path('Filament/Resources'))
->discoverWidgets(in: app_path('Filament/Widgets'))
7. Installing the Laravel AI SDK
The Laravel AI SDK (laravel/ai) was released on February 5, 2026. It uses Prism as a dependency for multi-provider abstraction and adds Laravel-specific features: the Agent pattern, Artisan scaffolding, conversation persistence, streaming, queue integration, and testing fakes.
We already installed it in step 2. The install:ai command created two database tables:
agent_conversations— Stores conversation metadata (user, agent class, timestamps)agent_conversation_messages— Stores each message in a conversation (role, content, tool calls)
Create a migration for AI usage tracking:
php artisan make:model AiUsageLog -m
Schema::create('ai_usage_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('agent_class');
$table->integer('input_tokens')->default(0);
$table->integer('output_tokens')->default(0);
$table->integer('total_tokens')->default(0);
$table->decimal('estimated_cost_usd', 8, 6)->default(0);
$table->integer('duration_ms')->default(0);
$table->timestamps();
});
Create an AI middleware that logs every agent request and enforces plan limits:
php artisan make:agent-middleware TrackAiUsage
<?php
namespace App\Ai\Middleware;
use App\Models\AiUsageLog;
use Closure;
use Laravel\Ai\AgentPrompt;
use Laravel\Ai\Contracts\AgentMiddleware;
use Laravel\Ai\Responses\AgentResponse;
class TrackAiUsage implements AgentMiddleware
{
public function handle(AgentPrompt $prompt, Closure $next)
{
// Check plan limit before making the API call
$team = currentTeam();
$monthlyUsage = AiUsageLog::where('team_id', $team->id)
->where('created_at', '>=', now()->startOfMonth())
->count();
if ($monthlyUsage >= $team->aiRequestsLimit()) {
throw new \App\Exceptions\AiLimitExceededException(
'Your team has reached its monthly AI request limit. Please upgrade your plan.'
);
}
$startTime = microtime(true);
return $next($prompt)->then(function (AgentResponse $response) use ($team, $startTime) {
AiUsageLog::create([
'team_id' => $team->id,
'user_id' => auth()->id(),
'agent_class' => get_class($response->agent),
'input_tokens' => $response->usage->inputTokens ?? 0,
'output_tokens' => $response->usage->outputTokens ?? 0,
'total_tokens' => ($response->usage->inputTokens ?? 0) + ($response->usage->outputTokens ?? 0),
'estimated_cost_usd' => $this->estimateCost($response),
'duration_ms' => (int) ((microtime(true) - $startTime) * 1000),
]);
});
}
private function estimateCost(AgentResponse $response): float
{
$inputCost = ($response->usage->inputTokens ?? 0) * 0.000003;
$outputCost = ($response->usage->outputTokens ?? 0) * 0.000015;
return $inputCost + $outputCost;
}
}
8. Build an AI Customer Support Chatbot
Our first AI feature: a chatbot that answers customer questions using your knowledge base. It remembers conversation history, searches your docs, and knows which team the user belongs to. This is the feature that converts free users to paid — instant support without waiting for a human.
Create the Knowledge Base Tool
php artisan make:tool SearchKnowledgeBase
<?php
namespace App\Ai\Tools;
use App\Models\HelpArticle;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
class SearchKnowledgeBase implements Tool
{
public function description(): string
{
return 'Search the knowledge base for help articles relevant to the user question. Returns the top matching articles with their content.';
}
public function schema(JsonSchema $schema): array
{
return [
'query' => $schema->string()->description('The search query based on the user question')->required(),
];
}
public function handle(Request $request): string
{
$articles = HelpArticle::query()
->where('published', true)
->whereFullText(['title', 'content'], $request['query'])
->limit(3)
->get(['title', 'content', 'category']);
if ($articles->isEmpty()) {
return 'No relevant articles found. Suggest the user contact support@contentpilot.com for further help.';
}
return $articles->map(fn ($a) =>
"## {$a->title}\nCategory: {$a->category}\n\n{$a->content}"
)->implode("\n\n---\n\n");
}
}
Create the Team Context Tool
php artisan make:tool GetTeamContext
<?php
namespace App\Ai\Tools;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
class GetTeamContext implements Tool
{
public function __construct(private int $teamId) {}
public function description(): string
{
return 'Get information about the current team: name, plan, member count, and usage stats.';
}
public function schema(JsonSchema $schema): array
{
return []; // No parameters needed — context is injected
}
public function handle(Request $request): string
{
$team = \App\Models\Team::with('users')->find($this->teamId);
return json_encode([
'team_name' => $team->name,
'plan' => $team->onBusinessPlan() ? 'Business' : ($team->onProPlan() ? 'Pro' : 'Free'),
'member_count' => $team->users->count(),
'ai_requests_used' => \App\Models\AiUsageLog::where('team_id', $team->id)
->where('created_at', '>=', now()->startOfMonth())->count(),
'ai_requests_limit' => $team->aiRequestsLimit(),
]);
}
}
Create the Support Chatbot Agent
php artisan make:agent SupportChatbot
<?php
namespace App\Ai\Agents;
use App\Ai\Middleware\TrackAiUsage;
use App\Ai\Tools\GetTeamContext;
use App\Ai\Tools\SearchKnowledgeBase;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasMiddleware;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
use Laravel\Ai\RemembersConversations;
#[Provider(Lab::Anthropic)]
#[Model('claude-sonnet-4-5-20250514')]
#[MaxSteps(5)]
#[Temperature(0.4)]
class SupportChatbot implements Agent, HasTools, Conversational, HasMiddleware
{
use Promptable, RemembersConversations;
public function __construct(private int $teamId) {}
public function instructions(): string
{
return <<<'PROMPT'
You are a friendly, knowledgeable customer support agent for ContentPilot, a content
management and AI writing platform. Your job is to help users with their questions.
Guidelines:
- Search the knowledge base before answering product questions
- If the answer isn't in the knowledge base, be honest and suggest contacting support
- Use the team context tool to personalize answers (e.g., mention their plan name)
- Be concise — 2-3 paragraphs max unless the user asks for detail
- For billing questions, direct users to the Settings > Billing page
- Never make up features that don't exist
PROMPT;
}
public function tools(): iterable
{
return [
new SearchKnowledgeBase,
new GetTeamContext($this->teamId),
];
}
public function middleware(): array
{
return [new TrackAiUsage];
}
}
Wire It Up with Routes
// routes/web.php
use App\Ai\Agents\SupportChatbot;
Route::middleware(['auth', 'verified'])->group(function () {
// Start a new chat
Route::post('/ai/support', function (Request $request) {
$response = (new SupportChatbot(currentTeam()->id))
->forUser($request->user())
->prompt($request->input('message'));
return response()->json([
'reply' => $response->text,
'conversation_id' => $response->conversationId,
]);
});
// Continue an existing chat
Route::post('/ai/support/{conversationId}', function (Request $request, string $conversationId) {
$response = (new SupportChatbot(currentTeam()->id))
->continue($conversationId, as: $request->user())
->prompt($request->input('message'));
return response()->json([
'reply' => $response->text,
'conversation_id' => $response->conversationId,
]);
});
});
That's a production-ready AI chatbot with knowledge base search, team context awareness, conversation memory, usage tracking, and plan-based rate limiting. For a deeper dive on building agents with tools, middleware, and structured output, see our AI agents guide.
9. Build an AI Content Generator
The second AI feature — and the one that makes ContentPilot worth paying for: an AI writer that generates blog posts, social media copy, and email campaigns with structured output.
php artisan make:agent ContentWriter --structured
<?php
namespace App\Ai\Agents;
use App\Ai\Middleware\TrackAiUsage;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasMiddleware;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
#[Provider(Lab::Anthropic)]
#[Model('claude-sonnet-4-5-20250514')]
#[Temperature(0.7)]
class ContentWriter implements Agent, HasStructuredOutput, HasMiddleware
{
use Promptable;
public function instructions(): string
{
return <<<'PROMPT'
You are an expert content writer for SaaS companies. Generate high-quality,
engaging content based on the user's brief. Match the requested tone and format.
Include SEO-friendly titles and meta descriptions. Content should be original,
well-structured, and ready to publish.
PROMPT;
}
public function schema(JsonSchema $schema): array
{
return [
'title' => $schema->string()->description('SEO-optimized title, 50-60 characters')->required(),
'meta_description' => $schema->string()->description('Meta description, 150-160 characters')->required(),
'content' => $schema->string()->description('The full content in Markdown format')->required(),
'suggested_tags' => $schema->array(
items: $schema->string()
)->description('3-5 relevant SEO tags')->required(),
'estimated_reading_time' => $schema->integer()->description('Estimated reading time in minutes')->required(),
];
}
public function middleware(): array
{
return [new TrackAiUsage];
}
}
Now create content types with different prompts:
<?php
namespace App\Http\Controllers;
use App\Ai\Agents\ContentWriter;
use Illuminate\Http\Request;
class AiContentController extends Controller
{
public function generateBlogPost(Request $request)
{
$request->validate([
'topic' => 'required|string|max:200',
'tone' => 'required|in:professional,casual,technical,persuasive',
'word_count' => 'required|integer|min:300|max:3000',
]);
$result = (new ContentWriter)->prompt(
"Write a {$request->tone} blog post about: {$request->topic}. " .
"Target length: {$request->word_count} words. " .
"Target audience: SaaS founders and marketers."
);
return response()->json([
'title' => $result['title'],
'meta_description' => $result['meta_description'],
'content' => $result['content'],
'tags' => $result['suggested_tags'],
'reading_time' => $result['estimated_reading_time'],
]);
}
public function generateSocialPosts(Request $request)
{
$request->validate(['topic' => 'required|string|max:200']);
// Queue this for non-blocking UX
(new ContentWriter)->queue(
"Create a social media content bundle for: {$request->topic}. " .
"Include a LinkedIn post (200 words), a tweet thread (5 tweets), " .
"and an Instagram caption (with emoji). Professional but engaging tone."
)->then(function ($response) use ($request) {
// Save the generated content
$request->user()->currentTeam->contents()->create([
'type' => 'social_bundle',
'title' => $response['title'],
'body' => $response['content'],
'tags' => $response['suggested_tags'],
'user_id' => $request->user()->id,
]);
});
return response()->json(['status' => 'generating', 'message' => 'Content is being generated...']);
}
public function generateEmailCampaign(Request $request)
{
$request->validate([
'product' => 'required|string|max:200',
'audience' => 'required|string|max:200',
'goal' => 'required|in:launch,nurture,re-engagement,upsell',
]);
$result = (new ContentWriter)->prompt(
"Write a {$request->goal} email campaign for '{$request->product}'. " .
"Target audience: {$request->audience}. Include subject line, preview text, " .
"and full email body. Use a professional but warm tone. Add a clear CTA."
);
return response()->json($result);
}
}
// routes/web.php
Route::middleware(['auth', 'verified'])->prefix('ai/content')->group(function () {
Route::post('/blog', [AiContentController::class, 'generateBlogPost']);
Route::post('/social', [AiContentController::class, 'generateSocialPosts']);
Route::post('/email', [AiContentController::class, 'generateEmailCampaign']);
});
The structured output guarantees every response has a title, meta_description, content, suggested_tags, and estimated_reading_time. No parsing, no regex, no broken JSON — the SDK validates the response schema and retries automatically if the model output doesn't match.
10. AI Usage Limits by Subscription Plan
AI calls cost money. You can't offer unlimited usage on a free plan. We already built the TrackAiUsage middleware that checks the team's monthly limit before every request. Now let's expose usage data to the user and handle the upgrade flow.
// Livewire component: AiUsageMeter
<?php
namespace App\Livewire;
use App\Models\AiUsageLog;
use Livewire\Component;
class AiUsageMeter extends Component
{
public function render()
{
$team = currentTeam();
$used = AiUsageLog::where('team_id', $team->id)
->where('created_at', '>=', now()->startOfMonth())
->count();
$limit = $team->aiRequestsLimit();
$percentage = $limit > 0 ? min(100, round(($used / $limit) * 100)) : 0;
return view('livewire.ai-usage-meter', compact('used', 'limit', 'percentage'));
}
}
<!-- resources/views/livewire/ai-usage-meter.blade.php -->
<div class="bg-gray-800 rounded-lg p-4">
<div class="flex justify-between text-sm mb-2">
<span class="text-gray-400">AI Requests This Month</span>
<span class="text-white font-medium">{{ $used }} / {{ number_format($limit) }}</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-2">
<div class="h-2 rounded-full transition-all duration-500
{{ $percentage >= 90 ? 'bg-red-500' : ($percentage >= 70 ? 'bg-yellow-500' : 'bg-green-500') }}"
style="width: {{ $percentage }}%">
</div>
</div>
@if($percentage >= 90)
<p class="text-xs text-red-400 mt-2">
Running low!
<a href="{{ route('billing') }}" class="underline hover:text-red-300">Upgrade your plan</a>
for more AI requests.
</p>
@endif
</div>
Handle the limit exceeded exception globally:
// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (\App\Exceptions\AiLimitExceededException $e) {
return response()->json([
'error' => $e->getMessage(),
'upgrade_url' => route('billing'),
'current_plan' => currentTeam()->onProPlan() ? 'Pro' : 'Free',
], 429);
});
})
11. Streaming AI Responses to the Frontend
Users expect to see AI responses appear in real-time, not wait 5-10 seconds for a full response. The Laravel AI SDK makes streaming trivial.
Backend: Streaming Route
// routes/web.php
Route::post('/ai/support/stream', function (Request $request) {
$chatbot = new SupportChatbot(currentTeam()->id);
if ($request->input('conversation_id')) {
$chatbot = $chatbot->continue($request->input('conversation_id'), as: $request->user());
} else {
$chatbot = $chatbot->forUser($request->user());
}
return $chatbot->stream($request->input('message'));
})->middleware(['auth', 'verified']);
Frontend: Consuming the Stream
// resources/js/ai-chat.js
async function sendMessage(message, conversationId = null) {
const chatWindow = document.getElementById('chat-messages');
const messageDiv = document.createElement('div');
messageDiv.className = 'ai-message text-gray-300 leading-relaxed';
chatWindow.appendChild(messageDiv);
const response = await fetch('/ai/support/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({
message,
conversation_id: conversationId,
}),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullText = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
fullText += decoder.decode(value, { stream: true });
messageDiv.innerHTML = marked.parse(fullText); // Render markdown
chatWindow.scrollTop = chatWindow.scrollHeight;
}
return fullText;
}
For React or Next.js frontends, the SDK supports the Vercel AI SDK data protocol:
return $chatbot->stream($request->input('message'))->usingVercelDataProtocol();
And for real-time updates to multiple team members watching the same conversation, broadcast over WebSockets:
use Illuminate\Broadcasting\Channel;
$chatbot->broadcastOnQueue(
$request->input('message'),
new Channel('team.' . currentTeam()->id . '.support'),
);
12. Testing Everything — SaaS and AI
A production SaaS needs comprehensive tests. Here's how to test both the traditional SaaS features and the AI features without making a single API call.
Testing Authentication and Teams
<?php
use App\Models\Team;
use App\Models\User;
test('new user gets a default team', function () {
$user = User::factory()->create();
expect($user->currentTeam)->not->toBeNull()
->and($user->currentTeam->owner_id)->toBe($user->id);
});
test('team owner can invite members', function () {
$owner = User::factory()->withTeam()->create();
$this->actingAs($owner)
->postJson('/team/invite', [
'email' => 'member@example.com',
'role' => 'member',
])
->assertOk();
$this->assertDatabaseHas('invitations', [
'team_id' => $owner->currentTeam->id,
'email' => 'member@example.com',
]);
});
test('team data is scoped correctly', function () {
$team1 = Team::factory()->hasPosts(5)->create();
$team2 = Team::factory()->hasPosts(3)->create();
$this->actingAs($team1->owner);
expect(\App\Models\Post::count())->toBe(5);
$this->actingAs($team2->owner);
expect(\App\Models\Post::count())->toBe(3);
});
Testing Billing
test('free plan has 25 AI request limit', function () {
$team = Team::factory()->create();
expect($team->aiRequestsLimit())->toBe(25);
});
test('pro plan has 500 AI request limit', function () {
$team = Team::factory()->subscribedToPro()->create();
expect($team->aiRequestsLimit())->toBe(500);
});
Testing AI Features with Fakes
use App\Ai\Agents\SupportChatbot;
use App\Ai\Agents\ContentWriter;
test('support chatbot responds to user questions', function () {
SupportChatbot::fake(['You can reset your password from Settings > Security.']);
$user = User::factory()->withTeam()->create();
$this->actingAs($user)
->postJson('/ai/support', [
'message' => 'How do I reset my password?',
])
->assertOk()
->assertJsonPath('reply', 'You can reset your password from Settings > Security.');
SupportChatbot::assertPrompted(fn ($prompt) =>
str_contains($prompt->prompt, 'reset my password')
);
});
test('content writer returns structured output', function () {
ContentWriter::fake([json_encode([
'title' => 'Test Blog Post Title',
'meta_description' => 'A test meta description for the blog post.',
'content' => '# Test Post\n\nThis is a test blog post about AI.',
'suggested_tags' => ['ai', 'saas', 'laravel'],
'estimated_reading_time' => 3,
])]);
$user = User::factory()->withTeam()->create();
$this->actingAs($user)
->postJson('/ai/content/blog', [
'topic' => 'AI in SaaS',
'tone' => 'professional',
'word_count' => 500,
])
->assertOk()
->assertJsonPath('title', 'Test Blog Post Title')
->assertJsonCount(3, 'tags');
ContentWriter::assertPrompted();
});
test('AI requests are blocked when plan limit is exceeded', function () {
SupportChatbot::fake(['This should not be reached.']);
$user = User::factory()->withTeam()->create(); // Free plan = 25 limit
// Simulate 25 existing requests
\App\Models\AiUsageLog::factory()->count(25)->create([
'team_id' => $user->currentTeam->id,
'user_id' => $user->id,
]);
$this->actingAs($user)
->postJson('/ai/support', ['message' => 'Help me'])
->assertStatus(429)
->assertJsonPath('error', 'Your team has reached its monthly AI request limit. Please upgrade your plan.');
});
test('no stray AI calls in the entire test suite', function () {
SupportChatbot::fake()->preventStrayPrompts();
ContentWriter::fake()->preventStrayPrompts();
// Any un-faked AI call in any test will now throw an exception
});
Add preventStrayPrompts() to your TestCase.php base class to ensure no test ever accidentally hits a real AI API. Zero API costs in CI, deterministic results, fast test execution.
13. Deploying to Production
Your AI-powered Laravel SaaS is ready. Here's the deployment checklist.
Infrastructure
- Server: Laravel Forge or Laravel Cloud on a 2+ vCPU, 4GB RAM server (AI requests need headroom)
- Database: PostgreSQL 16+ (for pgvector if you plan to add embeddings/RAG later)
- Queue: Redis with Horizon for AI job processing (content generation runs via queues)
- Cache: Redis for rate limiting, session storage, and AI response caching
Environment Variables
# Production .env (key variables)
APP_ENV=production
APP_DEBUG=false
# Database
DB_CONNECTION=pgsql
DB_HOST=your-db-host
DB_DATABASE=contentpilot
# Stripe (live keys!)
STRIPE_KEY=pk_live_...
STRIPE_SECRET=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
# AI Providers
ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-... # backup provider
# Queue
QUEUE_CONNECTION=redis
REDIS_HOST=your-redis-host
Deploy Script
# deploy.sh
php artisan down
git pull origin main
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan queue:restart
php artisan up
Post-Deploy Monitoring
- Laravel Pulse for real-time application performance monitoring
- Sentry for error tracking (especially AI timeout/rate-limit errors)
- Filament admin widget for AI usage and cost monitoring (we built this in step 6)
- Stripe webhooks should be verified with
php artisan cashier:webhook - Set up provider failover on your agents for resilience:
// In your agent class — if Anthropic is down, fall back to OpenAI
#[Provider([Lab::Anthropic, Lab::OpenAI])]
class SupportChatbot implements Agent { ... }
14. Conclusion
You've just built a complete AI-powered SaaS application with Laravel 12. Let's recap what's in the box:
- Authentication with email, Google OAuth, and two-factor
- Team-based multi-tenancy with global query scoping, roles, and invitations
- Subscription billing via Stripe with Free, Pro, and Business plans
- Admin panel with Filament including AI usage monitoring
- AI customer support chatbot with knowledge base search, team context, and conversation memory
- AI content generator with structured output for blog posts, social media, and email campaigns
- Usage limits enforced per team per plan via agent middleware
- Real-time streaming with SSE, Vercel AI SDK protocol, and WebSocket broadcasting
- Comprehensive testing with agent fakes, zero API costs in CI
- Production deployment with failover, monitoring, and queue processing
This is a non-trivial amount of code. In a real project, you'd spend weeks building this infrastructure from scratch — wiring up authentication, getting Stripe webhooks right, implementing team scoping, setting up Filament, and then adding AI features on top. It's doable, but it's a lot of foundational work before you write your first line of business logic.
That's exactly what LaraSpeed solves. Everything in steps 2-6 of this tutorial — authentication with social login and 2FA, team-based multi-tenancy, Stripe billing, admin panel, REST API, and CI/CD pipeline — comes pre-built. You skip straight to the AI features (steps 7-11) and start shipping the features that differentiate your product on day one.