Laravel is one of the most opinionated PHP frameworks — it has specific conventions for Eloquent relationships, service providers, form requests, job queues, and API resources. AI tools that don’t know Laravel idioms generate technically correct PHP that violates framework conventions. This guide compares Claude and GPT-4 on real Laravel tasks.
Testing Methodology
Tasks run across both models with identical prompts. Evaluated on: framework idiom correctness, use of Laravel helpers vs. raw PHP, proper use of Eloquent vs. Query Builder, and handling of edge cases like N+1 prevention.
Task 1: Eloquent Relationship with Eager Loading
Prompt: “Write an Eloquent model for a Post that belongs to a User and has many Comments. Include a scope for published posts and a method that returns the post with its author and top 3 comments.”
Claude’s output:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Builder;
class Post extends Model
{
protected $fillable = ['title', 'body', 'published_at', 'user_id'];
protected $casts = [
'published_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class)->orderBy('created_at', 'desc');
}
public function scopePublished(Builder $query): Builder
{
return $query->whereNotNull('published_at')
->where('published_at', '<=', now());
}
public static function withAuthorAndTopComments(int $postId): ?self
{
return static::with([
'user:id,name,email',
'comments' => fn ($q) => $q->limit(3)->with('user:id,name'),
])->find($postId);
}
}
Claude uses return type declarations, proper relationship typing, scoped query methods with Builder type hints, and constrained eager loading with column selection. This is idiomatic Laravel 11.
GPT-4’s output:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
protected $fillable = ['title', 'body', 'published_at', 'user_id'];
public function user()
{
return $this->belongsTo(User::class);
}
public function comments()
{
return $this->hasMany(Comment::class);
}
public function scopePublished($query)
{
return $query->whereNotNull('published_at')
->where('published_at', '<=', now());
}
public static function getWithAuthorAndComments($postId)
{
return static::with(['user', 'comments' => function($q) {
$q->orderBy('created_at', 'desc')->take(3);
}])->find($postId);
}
}
GPT-4’s output works but lacks return type hints, doesn’t constrain the eager loaded columns (potential data leak / memory issue), and uses the older function($q) syntax rather than the arrow function. Functional, but not idiomatic Laravel 11.
Winner: Claude — more complete type hints, constrained eager loading, arrow function syntax.
Task 2: Form Request Validation
Prompt: “Create a Laravel Form Request for creating an order. Fields: user_id (required, exists in users), items (required array, each has product_id and quantity), shipping_address (required object with street, city, zip). Add custom error messages.”
Claude:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CreateOrderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'user_id' => ['required', 'integer', 'exists:users,id'],
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', 'integer', 'exists:products,id'],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:100'],
'shipping_address' => ['required', 'array'],
'shipping_address.street' => ['required', 'string', 'max:255'],
'shipping_address.city' => ['required', 'string', 'max:100'],
'shipping_address.zip' => ['required', 'string', 'regex:/^\d{5}(-\d{4})?$/'],
];
}
public function messages(): array
{
return [
'items.required' => 'An order must contain at least one item.',
'items.*.product_id.exists' => 'One or more selected products do not exist.',
'items.*.quantity.max' => 'Maximum quantity per item is 100.',
'shipping_address.zip.regex' => 'Please provide a valid US ZIP code.',
];
}
}
Claude correctly uses dot-notation wildcard rules for nested arrays (items.*.product_id), which is the correct Laravel approach. It also adds sensible range constraints on quantity.
GPT-4 produces similar output but sometimes misses the 'array' rule on shipping_address, validating it as a flat field. Claude consistently handles nested array validation.
The prepareForValidation Hook
Claude will also add the prepareForValidation method unprompted when sanitizing inputs makes sense:
protected function prepareForValidation(): void
{
$this->merge([
'user_id' => $this->user()?->id ?? $this->user_id,
]);
}
This pattern — overwriting user_id from the authenticated user rather than trusting client input — is a security best practice that Claude applies when the prompt mentions authenticated endpoints. GPT-4 omits it unless the security concern is spelled out explicitly.
Task 3: Queue Job with Retry Logic
Prompt: “Write a Laravel queue job that sends an order confirmation email. Handle failures: retry up to 3 times with exponential backoff, log the failure, send Slack notification on final failure.”
<?php
// Claude's output
namespace App\Jobs;
use App\Models\Order;
use App\Mail\OrderConfirmation;
use App\Notifications\OrderEmailFailed;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Throwable;
class SendOrderConfirmationEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $maxExceptions = 3;
public function __construct(private readonly Order $order) {}
public function backoff(): array
{
return [30, 120, 300]; // 30s, 2min, 5min
}
public function handle(): void
{
Mail::to($this->order->user->email)
->send(new OrderConfirmation($this->order));
}
public function failed(Throwable $exception): void
{
Log::error('Order confirmation email failed permanently', [
'order_id' => $this->order->id,
'user_id' => $this->order->user_id,
'error' => $exception->getMessage(),
]);
Notification::route('slack', config('services.slack.webhook_url'))
->notify(new OrderEmailFailed($this->order, $exception));
}
}
Claude uses the backoff() method (correct for custom backoff intervals in Laravel), readonly constructor promotion, and the failed() method with proper Throwable typing. GPT-4 sometimes outputs $this->release(30) inside handle() in a try/catch, which is a legacy pattern.
Unique Jobs and Preventing Duplicates
For jobs where duplicate execution would be harmful (double-charging, duplicate emails), Claude generates the ShouldBeUnique interface correctly:
use Illuminate\Contracts\Queue\ShouldBeUnique;
class SendOrderConfirmationEmail implements ShouldQueue, ShouldBeUnique
{
// ...
public function uniqueId(): string
{
return (string) $this->order->id;
}
public int $uniqueFor = 3600; // 1 hour lock
}
The uniqueFor property ensures the lock expires after a hour even if the job fails without releasing it. GPT-4 generates the uniqueId() method but regularly omits uniqueFor, which can cause jobs to stay locked indefinitely on worker crash.
Task 4: API Resource Transformation
Both models handle API Resources, but Claude includes conditional fields and meta more naturally:
// Claude generates conditional relationship inclusion automatically
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'body' => $this->body,
'published_at' => $this->published_at?->toISOString(),
'author' => new UserResource($this->whenLoaded('user')),
'comments' => CommentResource::collection($this->whenLoaded('comments')),
'stats' => $this->when(
$request->user()?->can('viewStats', $this->resource),
fn () => ['views' => $this->view_count, 'likes' => $this->like_count]
),
];
}
whenLoaded() prevents N+1 issues in resource responses. Claude includes this by default. GPT-4 includes it when reminded.
Resource Collections with Metadata
For paginated API responses, Claude also generates the ResourceCollection wrapper with cursor pagination metadata:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\ResourceCollection;
class PostCollection extends ResourceCollection
{
public $collects = PostResource::class;
public function toArray($request): array
{
return [
'data' => $this->collection,
'meta' => [
'current_page' => $this->currentPage(),
'last_page' => $this->lastPage(),
'per_page' => $this->perPage(),
'total' => $this->total(),
],
];
}
public function paginationInformation($request, $paginated, $default): array
{
return [
'links' => [
'first' => $paginated['first_page_url'] ?? null,
'last' => $paginated['last_page_url'] ?? null,
'prev' => $paginated['prev_page_url'] ?? null,
'next' => $paginated['next_page_url'] ?? null,
],
];
}
}
GPT-4 generates a flat toArray that merges pagination into the top level, which breaks API consumers expecting a consistent data + meta envelope.
Task 5: Service Container and Dependency Injection
Both models understand the service container, but Claude produces more testable patterns using interface binding:
// Claude's approach: bind interface to implementation in ServiceProvider
// app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->bind(
\App\Contracts\OrderRepository::class,
\App\Repositories\EloquentOrderRepository::class,
);
$this->app->singleton(
\App\Services\PaymentGateway::class,
fn ($app) => new \App\Services\StripeGateway(
config('services.stripe.secret'),
$app->make(\Illuminate\Log\LogManager::class),
),
);
}
And the controller that consumes it:
class OrderController extends Controller
{
public function __construct(
private readonly OrderRepository $orders,
private readonly PaymentGateway $payments,
) {}
public function store(CreateOrderRequest $request): JsonResponse
{
$order = $this->orders->create($request->validated());
$this->payments->charge($order);
return new JsonResponse(new OrderResource($order), 201);
}
}
Claude’s use of constructor injection with interfaces makes the controller fully mockable in tests. GPT-4 frequently reaches for app()->make() inside methods, which defeats testability.
Overall Comparison
| Task | Claude | GPT-4 |
|---|---|---|
| Eloquent relationships + eager loading | Excellent — constrained columns, typed | Good — works, no constraints |
| Form Request nested array validation | Excellent | Good — sometimes misses array rule |
| Queue jobs with backoff | Excellent — backoff(), failed() | Good — sometimes legacy pattern |
| Unique jobs | Excellent — includes uniqueFor | Partial — misses uniqueFor |
| API Resources with whenLoaded | Excellent by default | Good when prompted |
| Service container / DI | Excellent — interface binding | Good — prefers app()->make() |
| PHP 8.x idioms | Current (readonly, arrow fns) | Mostly current |
For routine Laravel tasks — generating models, migrations, controllers — both models perform acceptably. The gap opens on edge cases: N+1 prevention, backoff configuration, unique job locks, and constructor-injected interfaces vs. service locator calls. Claude handles these without prompting; GPT-4 handles them when prompted.
Related Articles
- Claude vs Copilot for Swift Development 2026
- Claude vs GPT-4 for Data Analysis Tasks
- Claude Code Go Module Development Guide
- Claude Max vs Claude Pro Actual Difference
- Claude Code vs Cursor for Backend Development
Built by theluckystrike — More at zovo.one