Every AI Coding Tool Reviewed for Laravel Developers — Most of Them Waste Your Time

GitHub Copilot, Cursor, Claude Code, LaraCopilot, and 3 others — tested on the same real Laravel task: build a multi-tenant feature with Eloquent, gates, and a Livewire component. Here’s which ones produced code you could actually ship and which ones produced confident-sounding garbage.


Seven AI coding tools. One task. I ran the same Laravel feature request through all of them and reviewed every line of output the way I’d review a pull request. Some tools understood service providers, tenant scoping, and Livewire v3’s dispatch API. Some generated deprecated methods that don’t exist anymore. One produced a Gate query with the wrong column — the kind of silent bug that passes code review and breaks in production. Here’s the honest breakdown.


The Benchmark Task

Every tool received the same prompt. No project context, no additional hints:

“Build a multi-tenant feature flag system in Laravel. Each tenant should have isolated feature flags stored in the database. Use Eloquent with a Tenant model and a FeatureFlag model. Add a Gate to check if a feature is enabled for the current tenant. Create a Livewire component that lets admins toggle features per tenant. The component should optimistically update the UI and dispatch a FeatureFlagUpdated event.”

This prompt was chosen because it’s not a CRUD tutorial. It requires the tool to understand how Laravel’s authorization layer interacts with Eloquent scoping, to know Livewire v3’s lifecycle (not v2), and to implement a UI pattern — optimistic updates — that requires a specific architectural approach. There’s no single right answer, but there are a lot of wrong ones.

The review criteria:

Does it run?              → Correctness
Is it Laravel-idiomatic?  → Idiomaticity
Livewire v2 or v3 syntax? → Framework accuracy
Is tenant data isolated?  → Security
How much rewriting?       → Shippability

GitHub Copilot

Pricing: $10/month individual, $19/month business

Copilot works inline — you write a comment or stub a method and it autocompletes. For this task, I scaffolded the models manually and let Copilot fill in relationships, the Gate, and the Livewire component body.

The Eloquent output was clean:

// Tenant.php
class Tenant extends Model
{
    public function featureFlags(): HasMany
    {
        return $this->hasMany(FeatureFlag::class);
    }

    public function hasFeature(string $feature): bool
    {
        return $this->featureFlags()
            ->where('key', $feature)
            ->where('enabled', true)
            ->exists();
    }
}

// FeatureFlag.php
class FeatureFlag extends Model
{
    protected $fillable = ['tenant_id', 'key', 'enabled'];

    protected $casts = [
        'enabled' => 'boolean',
    ];

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

$casts is correct, hasFeature() is a reasonable abstraction, return types are present. No complaints here.

The Gate it suggested was also solid:

// AppServiceProvider.php
Gate::define('use-feature', function (User $user, string $feature) {
    $tenant = $user->tenant;

    if (! $tenant) {
        return false;
    }

    return $tenant->hasFeature($feature);
});

Correctly typed, guarded against a missing tenant, passes $feature as a gate argument. Then came the Livewire component:

// ❌ Copilot generated Livewire v2
class FeatureFlagManager extends Component
{
    public $tenant;
    public $flags = [];

    public function mount(Tenant $tenant)
    {
        $this->tenant = $tenant;
        $this->flags  = $tenant->featureFlags->pluck('enabled', 'key')->toArray();
    }

    public function toggle(string $key)
    {
        $this->flags[$key] = ! $this->flags[$key];

        FeatureFlag::where('tenant_id', $this->tenant->id)
            ->where('key', $key)
            ->update(['enabled' => $this->flags[$key]]);

        $this->emit('featureFlagUpdated', $key); // ❌ removed in Livewire v3
    }
}

$this->emit() is Livewire v2. In v3, it’s $this->dispatch(). This isn’t a deprecation warning — emit() doesn’t exist in Livewire v3 and will throw a fatal error. There’s also no optimistic UI logic; it updates state and the database simultaneously, which defeats the purpose of optimistic updating.

Copilot’s training clearly skews toward pre-v3 Livewire. On Eloquent and service layer code it’s reliable. On Livewire v3, you’ll be correcting it.


Cursor (Claude Sonnet backend)

Pricing: Free tier with limits; Pro at $20/month

Cursor is VS Code rebuilt with AI at its core. Its Composer mode generates and edits multiple files from a single prompt — a different category from inline completion. I used Composer to generate the entire feature.

The migration it produced unprompted:

Schema::create('feature_flags', function (Blueprint $table) {
    $table->id();
    $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
    $table->string('key');
    $table->boolean('enabled')->default(false);
    $table->timestamps();

    $table->unique(['tenant_id', 'key']); // ✅ composite unique — most tools miss this
});

Most tools generated the individual columns and stopped. Cursor added the composite unique constraint on tenant_id + key without being asked. That’s not something you’d add by accident — you add it because you understand that feature keys should be unique per tenant, not globally.

The Livewire component:

// ✅ Correct Livewire v3
class FeatureFlagManager extends Component
{
    public Tenant $tenant;       // ✅ typed model binding
    public array $flags         = [];
    public array $optimisticFlags = [];

    public function mount(Tenant $tenant): void
    {
        $this->tenant          = $tenant;
        $this->flags           = $tenant->featureFlags->pluck('enabled', 'key')->toArray();
        $this->optimisticFlags = $this->flags;
    }

    public function toggle(string $key): void
    {
        // Optimistically update UI immediately
        $this->optimisticFlags[$key] = ! $this->optimisticFlags[$key];

        try {
            FeatureFlag::where('tenant_id', $this->tenant->id)
                ->where('key', $key)
                ->update(['enabled' => $this->optimisticFlags[$key]]);

            $this->flags[$key] = $this->optimisticFlags[$key]; // confirm
            $this->dispatch('feature-flag-updated', key: $key); // ✅ v3 dispatch
        } catch (\Exception $e) {
            // Rollback optimistic update on failure
            $this->optimisticFlags[$key] = $this->flags[$key];
            $this->dispatch('toggle-failed', key: $key);
        }
    }

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

This is nearly shippable as-is. Typed property for model binding, correct v3 dispatch, genuine optimistic update with rollback. The only miss: it dispatched a generic browser event string ('feature-flag-updated') instead of the class-based FeatureFlagUpdated event the prompt asked for. Small, fixable in a minute.

Cursor is the best general-purpose tool in this test. The Sonnet backend understands Livewire v3, handles architectural patterns like optimistic updates correctly, and generates multi-file features coherently.


Claude Code

Pricing: Claude Pro ($20/month) or API usage

Claude Code is terminal-based — no GUI, no editor integration. It reads and writes files in your project directly. The first thing it did before generating a single line was run ls to understand the project structure, check composer.json for the Laravel version, and look at an existing Livewire component to determine whether the project was on v2 or v3.

None of the other tools did this. They generated code based on the prompt alone. Claude Code gathered context before generating.

The Gate it produced — correctly placed in a dedicated provider:

// app/Providers/FeatureFlagServiceProvider.php  ← not AppServiceProvider
class FeatureFlagServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Gate::define('use-feature', function (User $user, string $feature): bool {
            // ✅ Caches the database check — nobody asked for this
            return Cache::remember(
                "tenant:{$user->tenant_id}:feature:{$feature}",
                now()->addMinutes(5),
                fn () => $user->tenant?->hasFeature($feature) ?? false
            );
        });
    }
}

Two things happened here that nobody asked for. First, it created a dedicated FeatureFlagServiceProvider rather than dumping the Gate definition into AppServiceProvider. Second, it wrapped the database call in a cache — because it understood that Gate::check() runs on every authorized request and an uncached database query would compound across a high-traffic app.

The Livewire component:

class FeatureFlagManager extends Component
{
    public Tenant $tenant;

    #[Computed]
    public function flags(): Collection
    {
        return $this->tenant->featureFlags;
    }

    public function toggle(int $flagId): void
    {
        $flag = FeatureFlag::findOrFail($flagId);

        $this->authorize('manage-tenant', $this->tenant); // ✅ added unprompted

        $flag->update(['enabled' => ! $flag->enabled]);

        $this->dispatch('feature-flag-updated', flagId: $flagId);

        FeatureFlagUpdated::dispatch($flag); // ✅ only tool to dispatch the class-based event
    }

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

It was the only tool to dispatch the FeatureFlagUpdated class-based event. It was also the only tool to add $this->authorize() inside the toggle method — an authorization check the prompt didn’t ask for but any senior developer would add before committing. The #[Computed] attribute for the flags collection is correct Livewire v3 idiom.

The optimistic UI lives in the Blade template rather than PHP — it used Alpine.js’s x-on:click to flip the toggle immediately and fire the Livewire action in parallel:

<button
    x-data="{ enabled: @entangle('flags.' + flag.id + '.enabled') }"
    x-on:click="enabled = !enabled; $wire.toggle({{ $flag->id }})"
>

That’s a valid architectural choice — arguably cleaner than maintaining two PHP arrays. Different from Cursor’s approach, not worse.

The terminal-only interface takes adjustment. But for complex features — the kind that touch multiple layers of the stack — it produces the most production-ready output of any tool tested.


LaraCopilot

Pricing: Free tier; Pro at $12/month

Laravel-specific positioning. The pitch: domain specialization produces better output than general-purpose tools. The result: mixed.

The Eloquent models were genuinely good:

class Tenant extends Model
{
    use HasFactory;

    protected $fillable = ['name', 'slug'];

    public function featureFlags(): HasMany
    {
        return $this->hasMany(FeatureFlag::class);
    }

    public function hasFeature(string $feature): bool
    {
        // ✅ Added caching unprompted — one of the better model outputs
        return Cache::remember(
            "tenant:{$this->id}:feature:{$feature}",
            300,
            fn () => $this->featureFlags()
                ->where('key', $feature)
                ->where('enabled', true)
                ->exists()
        );
    }
}

Then the Gate:

// ❌ Wrong column — silent bug
Gate::define('use-feature', function (User $user, string $feature) {
    return FeatureFlag::where('user_id', $user->id) // user_id doesn't exist on this model
        ->where('key', $feature)
        ->where('enabled', true)
        ->exists();
});

The feature_flags table has tenant_id, not user_id. This query will return zero rows for every user. Every Gate check will fail silently — no error, no exception, just a false that denies access to every tenant feature. This kind of bug makes it past code review because the query looks plausible at a glance.

The Livewire component mixed v2 and v3 APIs in ways that won’t survive runtime:

// ❌ Removed in Livewire v3
$this->emitTo('admin.tenant-list', 'flagUpdated');

$this->emitTo() was removed entirely in Livewire v3 — not deprecated, removed. This is not a warning. It’s a fatal error.

LaraCopilot is “Laravel-specific” in the sense that it knows Laravel’s patterns at the Eloquent layer. At the Livewire layer, where the framework has changed significantly, the training data hasn’t caught up. The Gate bug is the real concern — it’s the kind of output that teaches bad habits to developers who don’t know enough yet to catch the error.


Codeium (Windsurf)

Pricing: Free tier; Pro at $15/month

Codeium, now distributed as Windsurf, has Cursor-like Composer functionality called Cascade. The migration and models were standard — correct, nothing remarkable. The Livewire component used correct v3 dispatch in one place:

public function toggle(string $key): void
{
    $this->flags[$key] = ! $this->flags[$key];

    FeatureFlag::where('tenant_id', $this->tenantId)
        ->where('key', $key)
        ->update(['enabled' => $this->flags[$key]]);

    $this->dispatch('feature-flag-updated'); // ✅ v3 dispatch
}

The dispatch call is right. The optimistic update is not — it modifies local state and the database simultaneously. If the database update fails, the UI shows the toggled state with no way to revert. That’s not optimistic UI; that’s just updating two things at once.

The more significant issue is the tenant isolation:

public $tenantId; // ❌ public integer exposed to the client

Public Livewire properties are serialized into the page — visible in the HTML source and modifiable via JavaScript. A user who inspects the source can see the tenantId and send a modified request targeting another tenant’s flags. There’s no authorization check inside the toggle method to verify the authenticated user belongs to this tenant.

The code runs. The security model is wrong.


Tabnine

Pricing: Free tier with local model; Pro at $12/month

Tabnine is the oldest tool in this test and has a meaningful differentiator: the local model tier keeps your code off external servers. For organizations with strict data residency or client confidentiality requirements, that matters.

The output quality reflects the trade-off. The Eloquent completions were correct but minimal — it filled in relationship stubs, nothing more. The Livewire output was the most outdated of any tool tested:

// ❌ Livewire v1/v2 — emit() removed in v3
public function toggle($key)
{
    // ...
    $this->emit('flagToggled', $key);
}

No typed properties. No model binding. No #[Computed]. $this->emit() is Livewire v2. The Gate definition queried the DB facade with an integer comparison instead of going through the Eloquent model with a boolean cast:

Gate::define('use-feature', function ($user, $feature) {
    return DB::table('feature_flags')
        ->where('tenant_id', $user->tenant_id)
        ->where('key', $feature)
        ->where('enabled', 1) // integer instead of cast-aware boolean
        ->first(); // returns stdClass, not bool — accidentally truthy
});

This works, but for the wrong reasons. Returning a stdClass from a Gate is truthy in PHP, so it passes — but it’s not idiomatic, and returning null when no record exists is also truthy for a Gate (null evaluates to false, so it actually doesn’t pass, which is correct — but the intent is unclear). The raw DB facade bypasses the model’s enabled cast.

Tabnine is worth the trade-off if data privacy is a hard requirement. If Livewire is a significant part of your stack, factor in the correction overhead.


Amazon Q Developer

Pricing: Free tier; Pro at $19/month per user

Amazon’s AI coding tool — rebranded from CodeWhisperer — is deeply integrated with the AWS ecosystem. For CloudFormation, CDK, and IAM configuration, it’s likely excellent. For Laravel + Livewire, it showed its limits.

The Gate it produced:

Gate::define('use-feature', function (User $user, $feature) {
    $tenant = Tenant::find($user->tenant_id); // ❌ fresh query every Gate check, no caching

    return $tenant && $tenant->featureFlags()
        ->where('key', $feature)
        ->where('enabled', true)
        ->exists();
});

Functionally correct. But it fires a fresh Tenant::find() on every Gate check. In a request that checks multiple features, this is multiple queries for the same tenant record. No caching, no eager loading from the user relationship.

The Livewire component sidestepped the v2 vs v3 question by simply not dispatching any events:

public function toggleFeature($key)
{
    $enabled               = ! $this->features[$key];
    $this->features[$key]  = $enabled;

    FeatureFlag::where('tenant_id', $this->tenantId)
        ->where('key', $key)
        ->update(['enabled' => $enabled]);

    // ❌ No event dispatched — FeatureFlagUpdated never fires
}

The FeatureFlagUpdated event the prompt explicitly asked for is simply absent. The component renders and toggles. Anything listening for that event gets nothing.


What the Test Actually Reveals

The Livewire v3 split is the sharpest signal. $this->emit() was removed in Livewire v3, released mid-2023. Tools whose training data skews pre-2023 generate an API that will throw a fatal error in any v3 project. GitHub Copilot generates emit(). Tabnine generates emit(). LaraCopilot generates emitTo(), which was also removed. These aren’t deprecation warnings you can ignore — they’re runtime errors.

Livewire v3 API reference for this task:
✅ $this->dispatch('event-name', key: $value)   ← correct v3
✅ public Tenant $tenant                         ← typed model binding
✅ #[Computed] public function flags()           ← computed property
❌ $this->emit('event-name', $value)             ← v2, removed
❌ $this->emitTo('component', 'event', $value)  ← v2, removed
❌ public $tenant                                ← untyped, no binding

Domain specialization doesn’t guarantee domain accuracy. LaraCopilot underperformed general-purpose tools on a Laravel-specific task. “Laravel-specific” describes positioning, not training quality.

The best tools added things the prompt didn’t ask for — correctly. The Gate caching, the dedicated service provider, the authorization check inside the toggle method. None of those were in the prompt. They’re in the output because the tool understood what the code is for, not just what was asked. That’s the difference between a tool that generates what you wrote and one that generates what you meant.

Agentic tools — Cursor Composer, Claude Code — produce better output on multi-file features than inline completers. This isn’t a flaw of inline completion; it’s the right tool for different contexts. Copilot and Tabnine are excellent for method bodies and boilerplate where inline completion fits naturally. A feature that requires five files to be generated coherently needs something that can see all five at once.


Which Tool for Which Situation

Building a complex feature from scratch (multi-file, multi-layer):
→ Claude Code or Cursor Composer
   Both understand the full stack. Claude Code reads your project first.

Daily autocomplete inside the editor:
→ GitHub Copilot or Cursor inline
   Copilot is strong on Eloquent and service layer. Correct it on Livewire v3.

Strict data privacy / no code leaving your machine:
→ Tabnine local model
   Accept the quality trade-off. Review Livewire output carefully.

Budget-constrained team on Laravel + Livewire v3:
→ Cursor free tier
   Limited monthly completions, but the highest accuracy when they land.

Avoid for Livewire-heavy projects:
→ LaraCopilot (until v3 accuracy improves)
→ Tabnine (outdated Livewire API knowledge)

Final Thoughts

The tools that wasted the most time weren’t the ones that generated obviously broken code. They were the tools that generated plausible-looking code with a silent mistake buried inside — a wrong column name in a Gate query that returns false for every user, a $this->emit() call that looks right to anyone who learned Livewire before 2023, a public property that leaks tenant IDs to the client.

Reviewing that output takes longer than writing the code from scratch, because you have to understand it deeply enough to find the error. That’s negative productivity.

The floor for “useful” is output you can review faster than you can write it. By that measure, two tools — Claude Code and Cursor — are genuinely useful on complex Laravel features. The others range from helpful on simpler tasks to counterproductive on nuanced ones.

Know your tools, know their gaps, and always review output the same way you’d review a pull request from a confident junior developer who sometimes doesn’t know what they don’t know.

Leave a Reply

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