Three of the most talked-about AI coding tools in August 2026, one real task: build a multi-tenant billing feature with Stripe, Eloquent, Pest tests, and a Livewire UI. Here are the outputs, the errors, the hallucinated Laravel APIs, and the one tool that actually understood the framework deeply enough to write code I could ship.
I expected Claude Code to win. It’s built on Opus 5, it’s terminal-native, and I’ve been using it on Laravel work for six months. What I didn’t expect was the specific ways each tool failed, the Laravel-specific errors that appeared in tools marketed as capable of production-level code, and the hallucinated method calls that looked right until I tried to run them.
The test: build the billing feature I use in every SaaS project. Multi-tenant Stripe subscription management with Cashier, an Eloquent subscription state machine, a Livewire 4 component for plan management, and a complete Pest 3 test suite. This feature is complex enough to differentiate tools — it requires understanding of multi-tenancy constraints, Cashier’s specific API, Livewire 4’s dispatch syntax, and the Pest 3 test patterns. The same feature, the same prompt, across three tools in the same day on the same codebase.
The Test Environment
Laravel 13, PHP 8.4
Livewire 4.x
Laravel Cashier 15.x (Stripe)
Pest 3
MySQL 8
Spatie Laravel Permission 6.x
Single-database multi-tenancy with BelongsToTenant global scope
The prompt given to each tool:
Build a billing feature for this multi-tenant Laravel SaaS.
Requirements:
1. Subscription model with tenant scoping (use BelongsToTenant trait)
2. BillingService that creates, cancels, and changes plans via Cashier
3. Stripe webhook handler for invoice.payment_succeeded,
invoice.payment_failed, customer.subscription.deleted
4. Gate that blocks access to premium features when subscription is inactive
5. SubscriptionManager Livewire 4 component where tenant admins
can upgrade, downgrade, and cancel
6. Pest 3 test suite: happy path, failed payment, Gate check,
tenant isolation, webhook idempotency
The Billable trait is on the Tenant model, not the User model.
Tenants have: stripe_id, pm_type, pm_last_four, trial_ends_at columns.
Cursor 3: Composer Agent on Claude 4.6 Backend
Cursor 3 shipped April 2, 2026 with a rebuilt interface centred on parallel AI agents. The Composer 3 agent — described as “co-architect grade” — handles multi-file, multi-hour tasks. For a billing feature, Composer is the right tool.
What Cursor 3 Did Well
Composer read the existing codebase before generating. It found the BelongsToTenant trait, found the existing Tenant model, and correctly placed the Billable trait on Tenant. It didn’t generate Billable on User — a mistake I’ve seen in AI-generated Cashier code repeatedly.
The migration it generated was correct:
Schema::create('subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('stripe_id')->unique();
$table->string('stripe_status');
$table->string('stripe_price')->nullable();
$table->integer('quantity')->nullable();
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->timestamps();
$table->index(['tenant_id', 'stripe_status']); // ✅ compound index, unprompted
});
The compound index on tenant_id and stripe_status appeared without being requested. Cursor 3 understood that subscription status queries are always scoped to a tenant.
The BillingService used the correct Cashier 15.x API:
public function subscribe(Tenant $tenant, string $priceId, string $paymentMethodId): Subscription
{
$tenant->createOrGetStripeCustomer(['name' => $tenant->name]);
$tenant->addPaymentMethod($paymentMethodId);
$tenant->updateDefaultPaymentMethod($paymentMethodId);
return $tenant->newSubscription('default', $priceId)
->create($paymentMethodId);
}
Where Cursor 3 Failed
Livewire 4 syntax error:
// ❌ What Cursor 3 generated — Livewire 3 dispatch syntax
public function cancelSubscription(): void
{
app(BillingService::class)->cancel($this->tenant);
$this->emit('subscription-cancelled'); // ← removed in Livewire 4
}
// ✅ Livewire 4 syntax
public function cancelSubscription(): void
{
app(BillingService::class)->cancel($this->tenant);
$this->dispatch('subscription-cancelled');
}
$this->emit() was removed in Livewire 4. Cursor 3’s training data skews toward the patterns that dominated 2024–2025 documentation, and $this->emit() was the correct Livewire 3 method. The generated component would have failed immediately on a Livewire 4 installation.
Webhook signature verification:
// ❌ Cursor 3's webhook handler — wrong header access
public function handle(Request $request): Response
{
$sig = $_SERVER['HTTP_STRIPE_SIGNATURE']; // ← wrong in Laravel context
// ✅ Correct Laravel approach
$sig = $request->header('Stripe-Signature');
The $_SERVER superglobal works in some environments but is incorrect in a Laravel controller — headers should come from the Request object, which handles trusted proxies and test mocking correctly.
Pest test syntax — partially wrong:
// ❌ Cursor 3 generated a mix of Pest 3 and PHPUnit syntax
class BillingServiceTest extends TestCase // ← PHPUnit class syntax
{
public function test_creates_subscription() // ← PHPUnit method naming
{
A proportion of Cursor 3’s test output used PHPUnit class syntax rather than Pest 3 function syntax. When corrected in follow-up prompts, it produced correct Pest syntax — but it shouldn’t have required correction on a codebase where Pest is clearly installed.
What I committed from Cursor 3: ~70% after fixing Livewire syntax, webhook header, and test format.
Gemini CLI: Google’s Terminal Agent on Gemini 2.5 Pro
Gemini CLI is Google’s open-source terminal AI agent. With a 1M-token context window on the free tier (Gemini 2.5 Pro), it’s positioned as the high-context option for large codebases. For a Laravel billing feature, the large context window should help — the Cashier documentation, the existing models, and the Stripe integration patterns can all fit.
What Gemini CLI Did Well
Context reading was genuinely impressive. Gemini CLI ingested the full codebase — all migrations, all models, all existing service classes — before generating anything. Its summary of the existing architecture was accurate, and it correctly identified the BelongsToTenant trait before writing a single line.
The Gate definition was the most complete of the three tools:
Gate::define('access-premium', function (User $user): bool {
return Cache::remember(
"tenant:{$user->current_tenant_id}:subscription:active",
now()->addMinutes(5),
fn() => $user->currentTenant?->subscription('default')?->active() ?? false
);
});
The 5-minute cache on the Gate check appeared without prompting. Gemini CLI understood that Gate checks fire on every authorized request and added caching proactively — a detail that shows genuine framework understanding at the integration level.
Where Gemini CLI Failed
Hallucinated Cashier method:
// ❌ Gemini CLI generated this — method does not exist in Cashier 15.x
public function changePlan(Tenant $tenant, string $newPriceId): void
{
$tenant->subscription('default')->changePlan($newPriceId);
// changePlan() was removed from Cashier in v13
// Correct method is swap()
}
// ✅ Correct Cashier 15.x
public function changePlan(Tenant $tenant, string $newPriceId): void
{
$tenant->subscription('default')->swap($newPriceId);
}
changePlan() existed in older Cashier versions and was replaced by swap(). Gemini CLI generated the removed method, which would have thrown a BadMethodCallException at runtime. The code looked correct — the method name was semantically right — but it doesn’t exist in the installed version.
This is the category of error that’s hardest to catch in code review. A swap() call and a changePlan() call look equally plausible to a reviewer who doesn’t have Cashier’s changelog memorized.
Livewire 4 syntax — identical error to Cursor 3:
// ❌ Same Livewire 3 emit() call
$this->emit('plan-changed', ['plan' => $plan]);
Both Cursor 3 and Gemini CLI made the same Livewire 4 syntax error. This suggests the error isn’t model-specific — it’s a training data distribution issue. The vast majority of Livewire tutorials and Stack Overflow answers reference Livewire 2 and 3 patterns, which dominated the ecosystem for three years. Livewire 4 is less than a year old and its documentation hasn’t achieved the same coverage depth yet.
Webhook event handler naming:
// ❌ Gemini CLI generated snake_case handler method names
public function handle_invoice_payment_succeeded(array $payload): Response
{
// Cashier's webhook router uses camelCase methods
// handle_invoice_payment_succeeded does not get called
// ✅ Correct Cashier webhook handler naming
public function handleInvoicePaymentSucceeded(array $payload): Response
{
Cashier’s webhook controller maps Stripe event names to camelCase handler methods automatically. invoice.payment_succeeded → handleInvoicePaymentSucceeded. Gemini CLI generated snake_case method names, which the webhook router would never call. The webhook handler would have registered, appeared functional, and silently done nothing when Stripe sent events.
Pest tests — missing tenant isolation:
Gemini CLI wrote four tests. Happy path, validation, authorization, and webhook handling. The tenant isolation test — confirming that Tenant B cannot access Tenant A’s subscription — was absent. For a multi-tenant billing feature, this is arguably the most important test.
What I committed from Gemini CLI: ~55% after replacing changePlan() with swap(), fixing Livewire syntax, fixing webhook handler names, and adding the tenant isolation test.
Claude Code: Terminal Agent on Opus 5
Claude Code runs in the terminal, reads your codebase before generating, and runs an iterative self-verification loop. On Opus 5 (96% SWE-bench Verified), it has context-window and reasoning capabilities that show up in framework-specific output.
What Claude Code Did Differently Before Writing Anything
Before generating a single file, Claude Code asked one question:
Before I start: I can see the Tenant model uses a BelongsToTenant
trait that adds a global Eloquent scope. Should the Subscription model
also use this trait, or does the existing subscriptions table (from
Cashier's migration) handle tenant scoping differently?
Also, I notice the test database is configured as MySQL. I'll make
sure all queries are MySQL-compatible and avoid SQLite-specific syntax.
Neither Cursor 3 nor Gemini CLI asked a clarifying question. Both generated immediately. Claude Code identified that Cashier manages its own subscriptions table through its own migrations — and that applying BelongsToTenant to a Cashier-managed model could conflict with Cashier’s internal query logic. The question was architecturally correct.
The answer: Cashier’s subscription model is scoped to the tenant through the Tenant model’s billable relationship. A global scope on the Subscription model would have created conflicts.
What Claude Code Got Right
Livewire 4 syntax — correct from the start:
// ✅ Correct Livewire 4 syntax throughout
public function upgrade(string $priceId): void
{
$this->authorize('manage-billing', $this->tenant);
try {
app(BillingService::class)->changePlan($this->tenant, $priceId);
$this->dispatch('subscription-updated');
session()->flash('success', 'Plan updated successfully.');
} catch (\Laravel\Cashier\Exceptions\IncompletePayment $e) {
$this->dispatch('payment-requires-action', [
'clientSecret' => $e->payment->clientSecret(),
]);
}
}
$this->dispatch(), not $this->emit(). IncompletePayment exception handled inline, not ignored. The authorization check ($this->authorize()) appeared without being requested.
Correct Cashier 15.x API throughout:
// ✅ swap() not changePlan()
public function changePlan(Tenant $tenant, string $newPriceId): void
{
try {
$tenant->subscription('default')->swap($newPriceId);
} catch (\Laravel\Cashier\Exceptions\IncompletePayment $e) {
throw $e; // Let the caller handle SCA
}
}
// ✅ Correct webhook handler naming (camelCase)
public function handleInvoicePaymentSucceeded(array $payload): Response
{
// Idempotency check
if (Cache::has("stripe:event:{$payload['id']}")) {
return $this->successMethod();
}
$subscription = $this->findSubscription(
$payload['data']['object']['subscription']
);
if ($subscription) {
$subscription->billable->update(['stripe_status' => 'active']);
Cache::put("stripe:event:{$payload['id']}", true, now()->addHours(24));
}
return $this->successMethod();
}
The idempotency check appeared in the webhook handler without being requested. Stripe can send the same webhook twice. Without idempotency, handleInvoicePaymentSucceeded would run twice for the same event — updating the database twice, potentially sending duplicate emails. Claude Code added the check because it understood why it was necessary.
The Pest Tests — The Clearest Differentiator
I asked each tool for the same test coverage. Claude Code wrote seven tests for the categories requested, plus two that weren’t.
// Tests Claude Code added without being asked:
it('does not process a webhook event that has already been handled', function () {
// Idempotency test — verifies the cache check prevents double processing
$payload = webhookPayload('invoice.payment_succeeded');
Cache::put("stripe:event:{$payload['id']}", true, now()->addHours(24));
// Second call — should not update the tenant status again
$initialStatus = auth()->user()->currentTenant->fresh()->stripe_status;
postJson('/stripe/webhook', $payload, ['Stripe-Signature' => validSignature($payload)]);
expect(auth()->user()->currentTenant->fresh()->stripe_status)->toBe($initialStatus);
});
it('tenant B cannot view or modify tenant A subscription', function () {
// Tenant isolation test
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$tenantAOwner = User::factory()->for($tenantA)->create();
actingAs($tenantAOwner)
->getJson("/api/tenants/{$tenantB->id}/subscription")
->assertNotFound(); // 404, not 403 — don't reveal the resource exists
});
Both tests covered critical production concerns that weren’t in the prompt. The idempotency test verified the webhook handler’s cache check. The tenant isolation test verified that Stripe subscription state for one tenant couldn’t be accessed or manipulated by another.
What I committed from Claude Code: ~97% with one minor change — the $this->authorize() call inside the Livewire component used a policy that didn’t exist yet. I added the policy in two minutes.
The Scorecard
Cursor 3 Gemini CLI Claude Code
────────────────────────────────────────────────────────────────
Cashier 15.x API 8/10 5/10 9/10
Livewire 4 syntax 4/10 4/10 9/10
Webhook handler naming 7/10 3/10 9/10
Tenant scoping 8/10 7/10 9/10
Pest 3 syntax 6/10 7/10 9/10
Idempotency handling 5/10 4/10 9/10
Tenant isolation test 5/10 3/10 9/10
Clarifying question ✗ ✗ ✓
Output commit rate: ~70% ~55% ~97%
Most critical error: emit() changePlan() Missing policy
The Pattern Behind Each Tool’s Failures
Cursor 3 failed on Livewire 4 syntax and the webhook header access. Both are version-specific errors — the correct answer changed in a recent release, and the training data contains more instances of the old pattern than the new one. Cursor 3’s codebase understanding is genuinely good; the failures were in framework version currency, not in architectural comprehension.
Gemini CLI had a more serious failure category: the hallucinated changePlan() method. This is the failure type that doesn’t surface until runtime — syntactically plausible, semantically reasonable, doesn’t exist. Gemini CLI’s large context window is genuinely valuable for reading and summarising existing code. Its code generation for framework-specific API calls requires verification against the installed package version.
Claude Code had the highest output quality because it asked the right question before generating — the BelongsToTenant/Subscription model question — and because it consistently applied patterns that go one layer deeper than the direct request (idempotency check, SCA handling in Livewire, tenant isolation test). These additions aren’t in any prompt engineering guide. They’re what a senior developer who has shipped billing features in production adds by default.
Which Tool for Which Laravel Work
Cursor 3:
Right for: Daily development flow — IDE autocomplete, multi-file
refactors, code navigation with AI assist.
Strongest when the framework version is stable and well-documented.
Watch for: Livewire 4, new package versions — verify against
installed versions before committing.
Gemini CLI:
Right for: Codebase exploration and analysis — "explain this architecture,"
"find all places where we make Stripe calls,"
"summarise what changed in this PR."
The free tier + large context window make it excellent for reading.
Watch for: Code generation on specific package APIs — verify method
existence against the installed version.
changePlan() vs swap() is one example; there are others.
Claude Code (Opus 5):
Right for: Complex multi-file features that require framework depth —
billing, real-time features, multi-tenancy concerns,
anything where production correctness matters more than speed.
Watch for: Cost at scale — Opus 5 is more expensive than Cursor Pro
for equivalent usage. The quality premium is real;
whether it's worth the cost depends on the feature complexity.
The answer isn’t one tool. It’s Cursor 3 for the IDE layer, Gemini CLI for codebase analysis and exploration, and Claude Code for the features where getting it wrong means shipping a security hole or a runtime error that fails silently.
The One Finding That Changed How I Work
The hallucinated changePlan() method from Gemini CLI wasn’t the most alarming finding. The most alarming finding was that I almost committed it.
The code looked right. changePlan() is a reasonable method name. The Cashier documentation doesn’t prominently note that changePlan() was removed in version 13. I ran the tests (which I’d written myself) and they passed because the test mocked the subscription object and the mock accepted any method call.
The method would have worked in tests, failed in staging, been reported as a bug, been debugged for hours, and eventually been fixed with a one-line change.
The lesson: AI-generated code that calls specific package APIs needs to be verified against the installed package version, not against reasonable expectations of what that package’s API should look like. changePlan() sounds like exactly what Cashier should have. swap() is what it has. The AI doesn’t always know the difference. You need to.
