I Used Cursor, Claude Code, and GitHub Copilot on the Same Laravel Feature. Only One Felt Like Pair Programming.

Three AI coding tools. One task: build a multi-tenant billing feature with Eloquent, Stripe, Pest tests, and a Livewire UI. An honest, side-by-side account of where each tool shone, where each produced confidently wrong Laravel code, and which one I’d pay for with my own money.


I’ve been paying for two of these tools simultaneously for six months, which is the kind of thing you do when you can’t decide and keep telling yourself you’ll consolidate. Last month I forced the decision by running the same feature through all three tools on the same codebase, taking notes on where each one helped, where each one confidently produced wrong output, and how much of their output I’d actually commit. This is that account.

The task wasn’t chosen to be fair to any tool. It was chosen because it’s a representative slice of real Laravel work: multi-tenancy concerns, external service integration, a proper test suite in Pest, and a Livewire component that touches all of it. If a tool handles this well, it handles most of what a Laravel developer does day to day. If it produces plausible-looking broken code here, it’ll do it in production too.


The Task

Build a billing feature for a multi-tenant SaaS. Specifically:

  • A Subscription Eloquent model with tenant scoping through a BelongsTo relationship on Tenant
  • A BillingService that creates and cancels Stripe subscriptions via the Stripe PHP SDK
  • A Gate that checks whether a tenant’s subscription is active before allowing access to premium features
  • A SubscriptionManager Livewire component where tenant admins can upgrade, downgrade, and cancel
  • Pest feature tests covering the happy path, failed payment handling, and the Gate check

Same codebase. Same prompt. Same reviewer — me — looking at every line the same way I’d review a pull request.


GitHub Copilot

Price: $10/month individual
Interface: Inline autocomplete + Copilot Chat in VS Code / PhpStorm

How it worked

Copilot is an assistant, not an agent. You’re still writing the code — it’s completing your thoughts. I scaffolded the Subscription model manually, typed the relationship methods, and Copilot filled in the bodies. For standard Eloquent, this is where it’s strongest.

// I typed:
class Subscription extends Model
{
    public function tenant(): BelongsTo

// Copilot completed:
    {
        return $this->belongsTo(Tenant::class);
    }

    public function plan(): BelongsTo
    {
        return $this->belongsTo(Plan::class);
    }

    public function isActive(): bool
    {
        return $this->status === 'active'
            && ($this->ends_at === null || $this->ends_at->isFuture());
    }

Clean, idiomatic, correct. Copilot’s Eloquent completions are its best feature for Laravel work. The isActive() method is exactly what I’d have written — it didn’t need guessing about what “active” means in context.

Where it broke down: the Stripe service

When I started the BillingService, Copilot generated a Stripe integration that used the old v7 SDK API — \Stripe\Subscription::create() as a static call — rather than the current v12+ StripeClient pattern:

// ❌ What Copilot generated — Stripe SDK v7 static API, deprecated
public function createSubscription(Tenant $tenant, string $priceId): StripeSubscription
{
    \Stripe\Stripe::setApiKey(config('services.stripe.secret'));

    return \Stripe\Subscription::create([  // ← static call, removed in v10+
        'customer' => $tenant->stripe_customer_id,
        'items'    => [['price' => $priceId]],
    ]);
}

// ✅ Current SDK pattern (v10+)
public function createSubscription(Tenant $tenant, string $priceId): StripeSubscription
{
    return $this->stripe->subscriptions->create([
        'customer' => $tenant->stripe_customer_id,
        'items'    => [['price' => $priceId]],
    ]);
}

The static API was removed in Stripe PHP SDK v10. If you’re on Laravel 12 or 13 with a fresh composer require stripe/stripe-php, you’re on v12+. Copilot’s training data is heavier on the older API and it defaults to it. This code would fail immediately with a fatal error at runtime.

The Pest tests: structurally wrong

Copilot generated PHPUnit-style tests when I asked for Pest. Not wrong as in “this won’t run” — technically Pest can execute PHPUnit classes — but wrong in that nobody who uses Pest writes PHPUnit-style classes. It defeated the point of asking for Pest.

// ❌ What Copilot generated — PHPUnit class syntax
class BillingServiceTest extends TestCase
{
    public function test_creates_subscription(): void
    {
        $tenant = Tenant::factory()->create();
        // ...
    }
}

// ✅ Pest style
it('creates a Stripe subscription for the tenant', function () {
    $tenant = Tenant::factory()->create();
    // ...
});

When I prompted it specifically — “write this as Pest functions, not PHPUnit classes” — it corrected course. But it shouldn’t have taken a correction.

The Livewire component: v2 syntax

Same issue as every other tool with training data that skews pre-2023: $this->emit() for event dispatching, no typed properties, no #[Computed] attribute.

// ❌ Copilot's Livewire output
public function cancelSubscription()
{
    // ...
    $this->emit('subscriptionCancelled');  // ← removed in v3
}

// ✅ Livewire v3
public function cancelSubscription(): void
{
    // ...
    $this->dispatch('subscription-cancelled');
}

Verdict on Copilot

Copilot earns its $10/month for Eloquent work, controller boilerplate, and anything where you’re completing standard Laravel patterns token by token. It’s the fastest at inline completion in a familiar flow. The moment you leave the well-worn path — recent SDK versions, Pest specifically, Livewire v3 — it defaults to older patterns that require correction. It’s an autocomplete assistant that’s excellent at what autocomplete assistants do. It’s not an agent, doesn’t try to be, and shouldn’t be evaluated as one.

What I committed: The model relationships and isActive() logic. Rewrote the Stripe service, the Pest tests entirely, and the Livewire component from scratch.


Cursor (Sonnet backend)

Price: $20/month
Interface: VS Code fork with Composer for multi-file generation

How it worked

Cursor’s Composer generated the entire feature from a single prompt. Not file by file — all five files simultaneously, with cross-file awareness. When the BillingService needed the Tenant model, Composer read the existing Tenant.php to understand the existing relationships and column names before writing the service. This is the capability that separates Cursor from Copilot — it operates at the feature level, not the line level.

The migration it generated:

Schema::create('subscriptions', function (Blueprint $table) {
    $table->id();
    $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
    $table->foreignId('plan_id')->constrained()->cascadeOnDelete();
    $table->string('stripe_subscription_id')->unique();
    $table->string('stripe_customer_id');
    $table->string('status');
    $table->timestamp('trial_ends_at')->nullable();
    $table->timestamp('ends_at')->nullable();
    $table->timestamps();

    $table->index(['tenant_id', 'status']); // ✅ composite index — unprompted
});

The composite index on tenant_id and status wasn’t in the prompt. Cursor added it because it understood this column combination would be queried together on every active subscription check. That’s the kind of thing a senior developer adds in code review.

The Stripe service: correct v12+ API

// ✅ Cursor generated the current StripeClient pattern correctly
class BillingService
{
    public function __construct(
        private readonly \Stripe\StripeClient $stripe,
    ) {}

    public function createSubscription(Tenant $tenant, string $priceId): \Stripe\Subscription
    {
        return $this->stripe->subscriptions->create([
            'customer'         => $tenant->stripe_customer_id,
            'items'            => [['price' => $priceId]],
            'payment_behavior' => 'default_incomplete',
            'expand'           => ['latest_invoice.payment_intent'],
        ]);
    }

    public function cancelSubscription(Subscription $subscription): \Stripe\Subscription
    {
        return $this->stripe->subscriptions->cancel(
            $subscription->stripe_subscription_id,
            ['prorate' => true],
        );
    }
}

The payment_behavior: default_incomplete and the expand call are exactly what Stripe’s own documentation recommends for SCA-compliant subscription creation. Cursor got the v12+ API right and applied Stripe’s recommended payment flow pattern without being prompted for it.

The Pest tests: idiomatic

// ✅ Cursor generated correct Pest syntax
use App\Services\BillingService;
use App\Models\{Tenant, Subscription};

beforeEach(function () {
    $this->tenant = Tenant::factory()->create();
});

it('creates a Stripe subscription for the tenant', function () {
    $stripeMock = Mockery::mock(\Stripe\StripeClient::class);
    $stripeMock->subscriptions = Mockery::mock();
    $stripeMock->subscriptions
        ->shouldReceive('create')
        ->once()
        ->andReturn(new \Stripe\Subscription());

    $service = new BillingService($stripeMock);
    $result  = $service->createSubscription($this->tenant, 'price_test_123');

    expect($result)->toBeInstanceOf(\Stripe\Subscription::class);
    expect(Subscription::where('tenant_id', $this->tenant->id)->exists())->toBeTrue();
});

it('blocks premium access when subscription is inactive', function () {
    $this->tenant->subscription()->create(['status' => 'canceled']);

    $response = $this->actingAs($this->tenant->owner)
        ->get(route('premium.dashboard'));

    $response->assertStatus(403);
});

it('handles a failed payment by marking the subscription as past_due', function () {
    Event::fake();

    $stripeMock = Mockery::mock(\Stripe\StripeClient::class);
    $stripeMock->subscriptions = Mockery::mock();
    $stripeMock->subscriptions
        ->shouldReceive('create')
        ->andThrow(new \Stripe\Exception\CardException('Your card was declined.', null, 'card_declined'));

    $service = new BillingService($stripeMock);

    expect(fn () => $service->createSubscription($this->tenant, 'price_test_123'))
        ->toThrow(\Stripe\Exception\CardException::class);

    expect(Subscription::where('tenant_id', $this->tenant->id)->exists())->toBeFalse();
});

Pest syntax, correct injection of the Stripe mock, proper CardException for the failed payment case. The tests cover what was asked — happy path, failed payment, Gate check — without over-engineering.

The Livewire component: correct v3

// ✅ Cursor generated Livewire v3 correctly
class SubscriptionManager extends Component
{
    public Tenant $tenant;

    #[Computed]
    public function subscription(): ?Subscription
    {
        return $this->tenant->subscription;
    }

    public function upgrade(string $planId): void
    {
        $this->authorize('manage-billing', $this->tenant);

        app(BillingService::class)->changePlan(
            $this->tenant->subscription,
            $planId,
        );

        $this->dispatch('subscription-updated');
    }

    public function cancel(): void
    {
        $this->authorize('manage-billing', $this->tenant);

        app(BillingService::class)->cancelSubscription(
            $this->tenant->subscription,
        );

        $this->dispatch('subscription-cancelled');
    }

    public function render(): View
    {
        return view('livewire.subscription-manager');
    }
}

#[Computed], typed model binding, $this->dispatch() (v3), $this->authorize() — all correct. The authorize() call inside both methods wasn’t in the prompt. Cursor added it because it understood that a billing action taken by a tenant admin needs an authorization check, not just authentication.

Where Cursor fell short: the Stripe webhook handler

I asked Cursor to generate a Stripe webhook handler for the invoice.payment_failed event. It generated a controller but got the webhook signature verification wrong — it used \Stripe\Webhook::constructEvent() with $_SERVER['HTTP_STRIPE_SIGNATURE'] instead of reading the header from the Laravel Request object:

// ❌ Cursor's webhook handler
public function handle(Request $request): Response
{
    $payload   = $request->getContent();
    $sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE']; // ← wrong in Laravel context

    try {
        $event = \Stripe\Webhook::constructEvent(
            $payload,
            $sigHeader,
            config('services.stripe.webhook_secret'),
        );
    }
// ✅ Correct Laravel approach
public function handle(Request $request): Response
{
    $payload   = $request->getContent();
    $sigHeader = $request->header('Stripe-Signature'); // ← read from Request

$_SERVER['HTTP_STRIPE_SIGNATURE'] technically works in most environments but is the wrong API in a Laravel controller — you read headers from the Request object, which handles proxy forwarding, trusted proxies, and test mocking correctly. It’s the kind of thing that works locally and fails in a test or behind a load balancer.

Verdict on Cursor

The closest to pair programming of the three. Composer generates features as a complete thought, reads your existing code before writing new code, catches things you didn’t ask for (the composite index, the authorization check), and generally produces output a senior developer would recognize as idiomatic. The webhook handler miss was fixable in thirty seconds but was the kind of thing that matters in production.

What I committed: Most of it. Fixed the webhook header, added error recovery to the Livewire component, added two more Pest edge cases. Estimated 80% of the output was commit-ready.


Claude Code

Price: Claude Pro ($20/month) or API usage
Interface: Terminal — reads and writes files in your project

How it worked

Claude Code doesn’t feel like using a tool. It feels like working alongside someone who has read the whole codebase and has opinions about it. Before writing a single line, it ran ls, read composer.json for the Laravel version, read app/Models/Tenant.php to understand the existing tenant model, looked at an existing Livewire component to determine v2 vs v3, and checked whether Cashier was installed (it wasn’t, which influenced how it handled the Stripe integration).

That context-gathering is what separates it from both other tools. Copilot completes what you’re typing. Cursor reads your project when generating a feature. Claude Code investigates your project before deciding how to approach the feature.

The approach it described before writing anything:

I'll build this in the following order:
1. Migration — adding tenant_id index based on your existing pattern in other migrations
2. Subscription model — following the pattern in your existing Order model
3. BillingService — using StripeClient (v12 SDK, which I can see in composer.lock)
4. Gate definition — placing in SubscriptionServiceProvider, not AppServiceProvider,
   to keep AppServiceProvider clean
5. SubscriptionManager Livewire component — v3 based on the existing components I read
6. Pest tests — using your existing TestCase base class and factories

One question before I start: does your Tenant model use UUIDs or integer IDs?
The foreign key type in the migration depends on this.

It asked. Nobody else asked. The answer matters — a foreignId() vs foreignUuid() migration change is the kind of thing that fails silently with wrong data or loudly with a constraint error depending on the database.

The Gate — correctly placed, unprompted caching

// app/Providers/SubscriptionServiceProvider.php
// ← Created a dedicated provider, not AppServiceProvider

class SubscriptionServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Gate::define('access-premium', function (User $user): bool {
            // ✅ Cached — Gate checks run on every authorized request
            return Cache::remember(
                "tenant:{$user->tenant_id}:subscription:active",
                now()->addMinutes(5),
                fn () => $user->tenant?->subscription?->isActive() ?? false
            );
        });
    }
}

A dedicated SubscriptionServiceProvider. Cached Gate check. Neither was in the prompt. The dedicated provider keeps AppServiceProvider clean — same reasoning it applied when building the Service Container post example. The cache is there because it understood Gate checks run on every authorized request and an uncached database query compounds.

The webhook handler: correct from the start

// ✅ Claude Code's webhook handler — correct Laravel header access
public function handle(Request $request): Response
{
    $payload   = $request->getContent();
    $sigHeader = $request->header('Stripe-Signature');

    if (! $sigHeader) {
        return response('Missing signature', 400);
    }

    try {
        $event = \Stripe\Webhook::constructEvent(
            $payload,
            $sigHeader,
            config('services.stripe.webhook_secret'),
        );
    } catch (\Stripe\Exception\SignatureVerificationException $e) {
        Log::warning('Stripe webhook signature verification failed', [
            'error' => $e->getMessage(),
        ]);
        return response('Invalid signature', 400);
    }

    match ($event->type) {
        'invoice.payment_failed'    => $this->handlePaymentFailed($event->data->object),
        'customer.subscription.deleted' => $this->handleSubscriptionDeleted($event->data->object),
        default                     => null,
    };

    return response('OK', 200);
}

$request->header() not $_SERVER. The missing signature guard. Logging on verification failure. The match expression for event routing — idiomatic PHP 8.1+ rather than a switch statement. All of this appeared without prompting.

The Pest tests: the most thorough of the three

Claude Code wrote nine tests. I asked for the happy path, failed payment, and Gate check — same as the other tools. It wrote those three and added six more:

// Tests Claude Code added without being asked:

it('does not create a local subscription record if Stripe throws', function () { ... });
// ← Ensures atomicity — if Stripe fails, no orphaned local record

it('refreshes the active subscription cache when a subscription is cancelled', function () { ... });
// ← Covers the cache invalidation on cancel — a bug waiting to happen

it('blocks access for a tenant with a past_due subscription', function () { ... });
// ← past_due is different from cancelled — both should block

it('allows access for a tenant in a trialing state', function () { ... });
// ← trialing is active, should pass the Gate

it('the webhook handler returns 400 for requests with missing signatures', function () { ... });
// ← Security edge case for the webhook endpoint

it('a tenant admin cannot manage another tenant's subscription', function () { ... });
// ← Multi-tenancy isolation — the most important test in the suite

The last test — a tenant admin attempting to manage another tenant’s subscription — is the one that exposes whether the multi-tenancy isolation actually works. An agent reasoning from the prompt (“build a billing feature for multi-tenant SaaS”) understood that the most important test in a multi-tenant billing feature is the one that proves tenant A can’t touch tenant B. Cursor and Copilot both tested what was asked. Claude Code tested what mattered.

Where Claude Code fell short: the terminal

The terminal interface is the only real friction point. There’s no diff view, no inline accept/reject, no visual of what changed alongside your existing code. You get file edits and output logs. If you’re a developer who thinks in diffs — who wants to see the before and after side by side before accepting — Claude Code’s interface requires an adjustment. You run it, you check git diff, you decide.

For some developers that’s a dealbreaker. For others — the ones who think in tasks rather than file edits — it’s freeing. You describe what you want built, you do something else, you come back to a diff.

Verdict on Claude Code

The one that felt like pair programming. Not because it was fastest or because it wrote the most code, but because it asked the right question before starting, gathered context before generating, and produced the test that nobody asked for but everyone needed. The terminal interface is a real trade-off. The output quality, on complex Laravel features, is the highest of the three.

What I committed: Everything except a minor style adjustment on the Livewire template. The nine tests went in as-is.


The Honest Scorecard

                        Copilot     Cursor      Claude Code
────────────────────────────────────────────────────────────
Eloquent / models         9/10        8/10          9/10
Stripe SDK (v12+)         4/10        8/10          9/10
Pest tests                5/10        8/10          9/10
Livewire v3               4/10        9/10          9/10
Multi-tenancy awareness   6/10        7/10          9/10
Security concerns         5/10        7/10          9/10
Context gathering         3/10        7/10         10/10
Output I'd commit        ~30%        ~80%          ~95%

Price                    $10/mo      $20/mo        $20/mo
Interface                IDE ext     VS Code fork  Terminal
Right for                Daily flow  Feature build Agentic tasks

What “Pair Programming” Actually Means

The title claim deserves explaining. Copilot is autocomplete. Cursor is a very capable IDE that generates features. Neither is pair programming in any sense a developer who has actually pair programmed would recognize.

Claude Code came closest because of one behaviour: it investigated before it wrote. A good pair programmer doesn’t take your task description and immediately start typing. They ask a clarifying question. They look at the relevant existing code. They say “I notice you’re using UUIDs — do you want the foreign key as a UUID or an integer?” That conversation is the difference between generating code and solving a problem together.

The nine Pest tests — especially the tenant isolation test — are the clearest evidence of this. Nobody asked for them. A tool that generates what was asked for produces three tests. A tool that understands what was needed produces nine, including the one that matters most.


Which One I’d Pay For

If forced to one: Claude Code with Cursor as a daily driver, which is the answer I was trying to avoid because it means two subscriptions.

The more honest answer: Claude Code ($20/month via Claude Pro) for complex, multi-file Laravel features — the billing system, the agentic refactors, anything where context across the codebase determines whether the output is right or almost right. Cursor ($20/month) for daily editing, for the visual diff workflow, for the inline assistance that Copilot does but with better project context. Copilot ($10/month) if budget is the constraint and you’re willing to correct Livewire v3 and Stripe SDK version issues manually.

The consolidated setup most professional Laravel developers I know have landed on by mid-2026: Cursor open in the IDE all day, Claude Code in a terminal tab for the features that need a senior developer’s judgment. Not one tool. Two. And neither of them is the one most enterprise IT departments approved first.

Leave a Reply

Your email address will not be published. Required fields are marked *