---
name: laravel
description: Expert Laravel development with Eloquent, queues, policies, API Resources, testing, and production patterns.
license: Apache 2.0.
author: "@firdausmntp"
---

# Laravel Specialist

You are an expert Laravel developer. Apply these principles when building backends on **Laravel 11+**, **PHP 8.3+**, **Pest 2+**, and **Sanctum 4+**. Favor explicit layering, typed code, and production-grade defaults over "clever" conventions.

## Application Structure

Layer the app explicitly. Routes dispatch, Form Requests validate, Controllers stay thin, Actions/Services own business logic, Eloquent stays a persistence layer.

```
app/
├── Actions/                 # invokable business operations
├── Http/{Controllers, Requests, Resources, Middleware}/
├── Models/                  # Eloquent — persistence + relationships only
├── Policies/                # per-model authorization
├── Services/                # cross-model orchestration
├── Jobs/  Events/  Listeners/  Observers/
└── Support/                 # value objects, DTOs, helpers

routes/{web.php (session+CSRF), api.php (stateless), console.php (scheduling)}
```

```php
// app/Http/Controllers/OrderController.php
class OrderController extends Controller
{
    public function store(PlaceOrderRequest $request, PlaceOrder $action): OrderResource
    {
        $order = $action->handle(
            user: $request->user(),
            items: $request->validated('items'),
        );
        return OrderResource::make($order);
    }
}

// app/Actions/Orders/PlaceOrder.php
class PlaceOrder
{
    public function __construct(private readonly StockReservation $stock) {}

    public function handle(User $user, array $items): Order
    {
        return DB::transaction(function () use ($user, $items) {
            $order = $user->orders()->create(['status' => 'pending']);
            $this->stock->reserveFor($order, $items);
            OrderPlaced::dispatch($order);
            return $order;
        });
    }
}
```

Actions read like domain verbs and stay testable. Reach for a `Service` only when multiple actions share state.

## Eloquent Patterns

### Relationships

```php
class User extends Authenticatable
{
    public function orders(): HasMany { return $this->hasMany(Order::class); }

    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class)
            ->withPivot(['granted_at', 'granted_by'])->withTimestamps();
    }

    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}
```

### Scopes

```php
// local — opt in per query
public function scopeActive(Builder $q): void { $q->where('is_active', true); }

// global — always applied; opt out with withoutGlobalScope()
protected static function booted(): void
{
    static::addGlobalScope('tenant', function (Builder $q) {
        if ($id = app(CurrentTenant::class)->id()) $q->where('tenant_id', $id);
    });
}

User::active()->get();
User::withoutGlobalScope('tenant')->find($id);
```

### N+1 prevention

Ban lazy loading outside production so the bug surfaces in CI, not at runtime.

```php
// app/Providers/AppServiceProvider.php::boot()
Model::preventLazyLoading(! $this->app->isProduction());
Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());

// ❌ 1 + N queries
foreach (User::all() as $u) { echo $u->orders->count(); }

// ✅ eager-load + counts
$users = User::withCount('orders')
    ->with(['orders' => fn ($q) => $q->latest()->limit(5)])->get();

$users->load('profile');                              // lazy-load after the fact
```

### Casts (built-in + custom)

```php
// app/Casts/Money.php
class Money implements CastsAttributes
{
    public function get($model, $key, $value, $attributes): MoneyValue
    {
        return new MoneyValue((int) $value, $attributes['currency'] ?? 'USD');
    }
    public function set($model, $key, $value, $attributes): array
    {
        return ['amount_cents' => $value->cents, 'currency' => $value->currency];
    }
}

// app/Models/Order.php
protected function casts(): array
{
    return [
        'status'    => OrderStatus::class,            // PHP enum
        'metadata'  => AsCollection::class,           // JSON → Collection
        'placed_at' => 'immutable_datetime',
        'total'     => Money::class,                  // custom cast
    ];
}
```

### Observers vs events

Observers handle model lifecycle (uuid, slug, hash). Events broadcast cross-aggregate side effects.

```php
class UserObserver
{
    public function creating(User $user): void { $user->uuid ??= (string) Str::uuid(); }
    public function created(User $user): void { UserRegistered::dispatch($user); }
}
// register in AppServiceProvider::boot()
User::observe(UserObserver::class);
```

### Large datasets + soft deletes

```php
Order::where('status', 'pending')->chunkById(500, fn ($rows) => /* ... */);

// LazyCollection — generator-backed, constant memory
Order::where('created_at', '<', now()->subYear())
    ->lazy()->each(fn ($o) => $o->archive());

// PDO cursor — lowest memory
foreach (Order::where('status', 'pending')->cursor() as $order) { /* ... */ }

// soft deletes (add $table->softDeletes() in migration)
class Invoice extends Model { use SoftDeletes; }
Invoice::withTrashed()->find($id);
Invoice::onlyTrashed()->restore();
```

## Validation with Form Requests

`FormRequest` owns rules **and** authorization. Always consume `validated()` in the controller — never `all()`.

```php
class PlaceOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Order::class);
    }

    protected function prepareForValidation(): void
    {
        $this->merge([
            'coupon' => $this->coupon ? strtoupper(trim($this->coupon)) : null,
        ]);
    }

    public function rules(): array
    {
        return [
            'items'              => ['required', 'array', 'min:1', 'max:100'],
            'items.*.product_id' => ['required', 'integer', 'exists:products,id'],
            'items.*.qty'        => ['required', 'integer', 'between:1,999'],
            'coupon'             => ['nullable', 'string', new ValidCouponCode],
            'shipping_method'    => ['required', Rule::in(['standard', 'express'])],
            'gift_message'       => ['sometimes', 'required_if:is_gift,true', 'string', 'max:500'],
        ];
    }
}

// app/Rules/ValidCouponCode.php
class ValidCouponCode implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (! Coupon::query()->active()->whereCode($value)->exists()) {
            $fail('The :attribute is not a valid coupon.');
        }
    }
}
```

## Authentication & Authorization

- **Sanctum** — SPA cookie auth, personal access tokens, lightweight API tokens with abilities. Default choice.
- **Passport** — full OAuth2 server. Use only when you need to be an OAuth provider for third-party clients.

```php
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
    Route::get('/me', fn (Request $r) => $r->user());
    Route::post('/orders', [OrderController::class, 'store'])
        ->middleware('ability:orders:write');
});

$token = $user->createToken('mobile', ['orders:read', 'orders:write'])->plainTextToken;
```

Policies for per-model checks; Gates for cross-model or non-model rules.

```php
// app/Policies/OrderPolicy.php
class OrderPolicy
{
    public function view(User $user, Order $order): bool
    {
        return $user->id === $order->user_id || $user->hasRole('support');
    }
    public function update(User $user, Order $order): bool
    {
        return $user->id === $order->user_id && $order->status === OrderStatus::Pending;
    }
}

// controller
$this->authorize('view', $order);                     // 403 if denied

// AppServiceProvider::boot()
Gate::define('access-admin', fn (User $u) => $u->is_admin && $u->mfa_verified);
```

## API Resources

Resources are the JSON contract — control exactly what leaves the API.

```php
class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'           => $this->id,
            'email'        => $this->email,
            'name'         => $this->name,
            'role'         => $this->when($request->user()?->isAdmin(), $this->role),
            'orders'       => OrderResource::collection($this->whenLoaded('orders')),
            'orders_count' => $this->whenCounted('orders'),
            'created_at'   => $this->created_at->toAtomString(),
        ];
    }
}

// pagination + meta via ResourceCollection
class UserCollection extends ResourceCollection
{
    public function toArray(Request $request): array
    {
        return [
            'data' => $this->collection,
            'meta' => ['generated_at' => now()->toAtomString()],
        ];
    }
}

return UserResource::collection(User::paginate(25));

// versioning via prefix
Route::prefix('v1')->group(base_path('routes/api/v1.php'));
Route::prefix('v2')->group(base_path('routes/api/v2.php'));
```

## Queues & Jobs

- **database** — zero infra, fine for low throughput
- **redis + Horizon** — production default; dashboard + autoscaling
- **sqs** — managed durability across regions

```php
class SendInvoice implements ShouldQueue, ShouldBeUnique
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 5;
    public int $backoff = 30;                         // or [30, 60, 120, 240, 480]
    public int $timeout = 60;
    public int $uniqueFor = 3600;

    public function __construct(public readonly int $invoiceId) {}

    public function uniqueId(): string { return (string) $this->invoiceId; }

    public function middleware(): array
    {
        return [
            new RateLimited('invoice-mailer'),
            (new WithoutOverlapping($this->uniqueId()))->expireAfter(60),
        ];
    }

    public function handle(Mailer $mailer): void
    {
        $invoice = Invoice::findOrFail($this->invoiceId);
        $mailer->send(new InvoiceMailable($invoice));
    }

    public function failed(Throwable $e): void
    {
        Log::error('invoice.send.failed', ['id' => $this->invoiceId, 'err' => $e->getMessage()]);
    }
}

// dispatch — afterCommit() is critical when inside DB::transaction()
SendInvoice::dispatch($invoice->id)->onQueue('mail')->afterCommit();

// batching
Bus::batch([new ProcessShipment($a->id), new ProcessShipment($b->id)])
    ->name('nightly-shipments')->onQueue('shipping')->allowFailures()
    ->then(fn (Batch $b) => Log::info('batch.done', ['id' => $b->id]))
    ->catch(fn (Batch $b, Throwable $e) => Log::error('batch.failed', ['id' => $b->id]))
    ->dispatch();

// rate-limit definition — AppServiceProvider::boot()
RateLimiter::for('invoice-mailer', fn () => Limit::perMinute(60));
```

Failed jobs land in `failed_jobs`. Inspect with `php artisan queue:failed`, retry with `php artisan queue:retry {id|all}`.

## Testing

Pest + `RefreshDatabase` for feature tests; plain unit tests for pure logic.

```php
// tests/Feature/OrderTest.php
use function Pest\Laravel\{actingAs, postJson};
uses(Tests\TestCase::class, Illuminate\Foundation\Testing\RefreshDatabase::class);

it('creates an order for the authenticated user', function () {
    $user = User::factory()->create();
    $product = Product::factory()->inStock()->create();

    Queue::fake();
    Event::fake([OrderPlaced::class]);

    actingAs($user)
        ->postJson('/api/v1/orders', [
            'items' => [['product_id' => $product->id, 'qty' => 2]],
            'shipping_method' => 'standard',
        ])
        ->assertCreated()
        ->assertJsonPath('data.status', 'pending');

    expect($user->orders()->count())->toBe(1);
    Event::assertDispatched(OrderPlaced::class);
    Queue::assertPushedOn('shipping', ProcessShipment::class);
});

// factories with states
class ProductFactory extends Factory
{
    public function definition(): array
    {
        return ['name' => fake()->words(3, true), 'price_cents' => fake()->numberBetween(100, 50000)];
    }
    public function inStock(int $qty = 10): static
    {
        return $this->state(fn () => ['stock' => $qty]);
    }
}

// faking outbound calls
Http::fake([
    'api.stripe.com/*' => Http::response(['id' => 'ch_test'], 200),
    'api.twilio.com/*' => Http::response([], 500),
]);
Mail::fake();  Notification::fake();  Storage::fake('s3');

Mail::assertQueued(WelcomeMail::class, fn ($m) => $m->hasTo($user->email));
```

Run `php artisan test --parallel` for CI; Pest 2+ supports it natively.

## Database & Migrations

Reversible migrations, named for the change. Index FKs and `WHERE` columns explicitly.

```php
return new class extends Migration {
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->enum('status', ['pending', 'paid', 'shipped', 'cancelled'])->default('pending');
            $table->unsignedInteger('total_cents');
            $table->char('currency', 3)->default('USD');
            $table->json('metadata')->nullable();
            $table->timestamp('placed_at')->nullable();
            $table->timestamps();
            $table->softDeletes();

            $table->index(['user_id', 'status']);
            $table->index('placed_at');
        });

        DB::statement('ALTER TABLE orders ADD FULLTEXT search_idx (notes)'); // MySQL-only
    }

    public function down(): void { Schema::dropIfExists('orders'); }
};

// transactions retry on deadlock
DB::transaction(function () use ($user, $items) {
    $order = $user->orders()->create(['status' => 'pending']);
    foreach ($items as $line) $order->lines()->create($line);
}, attempts: 3);

// batch upsert
Product::upsert($rows, uniqueBy: ['sku'], update: ['name', 'price_cents', 'updated_at']);
```

## Caching

```php
// remember — hit on miss, store for TTL
$featured = Cache::remember('products:featured', now()->addMinutes(10),
    fn () => Product::featured()->with('brand')->get());

// tagged caches (Redis/Memcached only) — bulk invalidation
Cache::tags(['products', "user:{$user->id}"])->remember("cart:{$user->id}", 60, fn () => /* ... */);
Cache::tags(['products'])->flush();

// route/config/view caches — production-only build step
// php artisan config:cache route:cache view:cache event:cache
```

Invalidate on write through observers/events. Don't rely on TTL alone for consistency-sensitive data.

## Error Handling

Laravel 11 wires exceptions in `bootstrap/app.php`. Render JSON for API consumers, log everything else with context.

```php
return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(/* ... */)
    ->withExceptions(function (Exceptions $exceptions) {
        $exceptions->report(function (Throwable $e) {
            Log::withContext(['request_id' => request()->header('X-Request-Id')]);
        });

        $exceptions->dontReport([DomainException::class]);

        $exceptions->render(function (ValidationException $e, Request $request) {
            if ($request->expectsJson()) {
                return response()->json([
                    'type'   => 'urn:app:error:validation',
                    'title'  => 'invalid_request',
                    'status' => 422,
                    'errors' => $e->errors(),
                ], 422, ['Content-Type' => 'application/problem+json']);
            }
        });

        $exceptions->render(function (Throwable $e, Request $request) {
            if ($request->expectsJson() && ! app()->hasDebugModeEnabled()) {
                return response()->json([
                    'type'   => 'urn:app:error:internal',
                    'title'  => 'internal_error',
                    'status' => 500,
                ], 500, ['Content-Type' => 'application/problem+json']);
            }
        });
    })->create();
```

## Observability

```php
// config/logging.php
'channels' => [
    'stack'  => ['driver' => 'stack', 'channels' => ['stdout', 'sentry']],
    'stdout' => ['driver' => 'monolog', 'handler' => StreamHandler::class,
                 'with' => ['stream' => 'php://stdout'], 'formatter' => JsonFormatter::class],
    'sentry' => ['driver' => 'sentry', 'level' => 'error'],
],

Log::withContext(['user_id' => $user->id, 'tenant' => $tenant->id])
   ->info('order.placed', ['order_id' => $order->id, 'total_cents' => $order->total_cents]);
```

- **Telescope** — local/staging debugger. Never enable in production.
- **Pulse** — production-safe dashboard for slow queries, queues, exceptions.

```php
// AppServiceProvider::register()
if ($this->app->environment('local', 'staging')) {
    $this->app->register(TelescopeServiceProvider::class);
}
```

## Production

```bash
# build-time caches — re-run on every deploy
php artisan config:cache route:cache view:cache event:cache
# route:cache breaks if you have closure routes
```

```ini
; php.ini — opcache pays for itself many times over
opcache.enable=1
opcache.memory_consumption=256
opcache.validate_timestamps=0           ; off in prod, on in staging
opcache.max_accelerated_files=20000
```

Workers via supervisor:

```ini
[program:app-worker]
command=php /var/www/app/artisan queue:work redis --queue=high,default --tries=3 --max-time=3600
autostart=true
autorestart=true
numprocs=4
user=www-data
stopwaitsecs=60
```

Or **Horizon** for Redis — supervises and autoscales:

```php
// config/horizon.php
'defaults' => [
    'supervisor-1' => [
        'connection' => 'redis',
        'queue'      => ['high', 'default', 'low'],
        'balance'    => 'auto',
        'autoScalingStrategy' => 'time',
        'minProcesses' => 2, 'maxProcesses' => 20,
        'tries' => 3,
    ],
],
```

**Octane** (Swoole / RoadRunner / FrankenPHP) keeps the framework booted between requests — large latency win, but stateful globals leak. Before enabling: audit singletons for request-scoped state, never mutate `config()` at runtime, use `$app->scoped(...)` for per-request bindings.

Zero-downtime deploys:

```bash
php artisan down --secret="a1b2" --render="errors::503"   # operators bypass via /a1b2
php artisan migrate --force
php artisan config:cache route:cache view:cache event:cache
php artisan queue:restart                               # workers reload code
php artisan up
```

Ship via **Forge**, **Envoyer**, or a GitHub Actions pipeline that rsyncs to an atomic release directory and flips a symlink.

## Anti-Patterns

### ❌ Fat controllers

```php
// Bad — controller validates, persists, sends mail, transforms
public function store(Request $request)
{
    $data = $request->validate([/* ... */]);
    $user = User::create([...$data, 'password_hash' => bcrypt($data['password'])]);
    Mail::to($user->email)->send(new WelcomeMail($user));
    return ['id' => $user->id, 'email' => $user->email];
}
```

```php
// ✅ FormRequest + Action + Resource
public function store(CreateUserRequest $request, RegisterUser $register): UserResource
{
    return UserResource::make($register->handle($request->validated()));
}
```

### ❌ N+1 in Blade / Resource

```blade
{{-- Bad: each iteration triggers a query --}}
@foreach ($posts as $post)
    {{ $post->author->name }}
@endforeach
```

```php
// ✅ eager-load up front
$posts = Post::with('author:id,name')->latest()->paginate(20);
```

### ❌ `env()` outside config files

```php
// Bad — returns null after `php artisan config:cache`
$this->key = env('STRIPE_KEY');
```

```php
// ✅ env in config/, config() everywhere else
// config/services.php
'stripe' => ['key' => env('STRIPE_KEY')],

$this->key = config('services.stripe.key');
```

### ❌ Mass-assigning `$request->all()`

```php
// Bad — trusts whatever the client sent, including role/is_admin
User::create($request->all());
```

```php
// ✅ only validated input
User::create($request->validated());
// User::create($request->safe()->only(['email', 'name']));
```

### ❌ Raw queries without bindings

```php
// Bad — SQL injection
DB::statement("UPDATE users SET role = 'admin' WHERE email = '{$email}'");
```

```php
// ✅ parameterized
DB::update('UPDATE users SET role = ? WHERE email = ?', ['admin', $email]);
// User::whereEmail($email)->update(['role' => 'admin']);
```

### ❌ Synchronous dispatch of queued work

```php
// Bad — blocks the request, no retries, lost on crash
ProcessUpload::dispatchSync($upload);
```

```php
// ✅ proper queue dispatch, after the transaction commits
ProcessUpload::dispatch($upload)->onQueue('uploads')->afterCommit();
```

### ❌ `get()` on result sets that don't fit in memory

```php
// Bad — loads the whole table
Order::where('status', 'archived')->get()->each(fn ($o) => $o->purge());
```

```php
// ✅ constant-memory streaming
Order::where('status', 'archived')->lazyById()->each(fn ($o) => $o->purge());
```

### ❌ Missing indexes on FKs and `WHERE` columns

`foreignId(...)->constrained()` adds the FK constraint, not always the right composite index. If you filter by `(user_id, status)`, add it explicitly:

```php
$table->index(['user_id', 'status']);
$table->index('placed_at');
```

Run `EXPLAIN` on production-shaped queries before shipping a new list endpoint.

## Mental Model

Laravel rewards explicit layering. Fat controllers and fat models both rot — push business logic into Actions or Services with small, testable methods, let Eloquent stay a thin persistence layer, and let Form Requests, Policies, and Resources own the edge of the HTTP boundary. The framework's ergonomics make the right shape fast; don't trade that away for shortcuts that collapse responsibilities back together.