Cursor Rules for PHP and Laravel: AI Dev Guide 2026

php dev.to

Laravel is the framework that lets you ship a working CRUD app over a long morning and a tangled mess of fat controllers, mass-assignment vulnerabilities, and N+1 queries by the end of the quarter. The first regression is almost always a query log explosion: a Blade view iterates over $users and renders $user->company->name inside the loop, every row triggers a separate select * from companies where id = ?, the homepage now fires four hundred queries on every request, and the database starts queuing connections under load. The second is mass assignment that became mass exploitation: a developer copy-pasted protected $guarded = [] from a Stack Overflow answer in 2018, the User model now accepts whatever the request payload contains, and a curl request with is_admin=1 in the body just escalated a regular user to root. The third is the synchronous mailer in a registration controller: the SMTP server hiccups, the request hangs for 30 seconds, the user double-clicks the submit button, and now there are two accounts with the same email and a half-sent welcome message in the queue.

Then you add an AI assistant.

Cursor and Claude Code were trained on Laravel code that spans a decade and a half — Laravel 4 IoC bindings in start/global.php, Laravel 5 callable strings in routes (Route::get('/x', 'XController@show')), protected $guarded = [] everywhere because "we'll get to it later," DB::raw("WHERE name LIKE '%" . $search . "%'") with string concatenation that is one form field away from a SQL injection postmortem, controllers that do everything inline (validate, query, transform, mail, respond) in 200 lines per action, jobs dispatched as dispatch(new SendEmail(...)) from inside a controller and awaited synchronously, API endpoints that return raw Eloquent models with password_hash, remember_token, and stripe_secret exposed to the client because nobody wrote a JsonResource, authorization via if (auth()->user()->is_admin) scattered through every controller, tests that use the real Mailgun API in CI because nobody called Mail::fake(), boot() methods on models with twenty static event hooks that nobody can find when they break, and accessors written as getFooAttribute() because the AI never learned the Attribute::make() syntax that has been the idiom since Laravel 9. Ask for "an endpoint that creates an order and emails the customer," and you get a 180-line invocable controller that validates inline, persists with Order::create($request->all()), fires Mail::to($order->user)->send(new OrderConfirmation($order)) synchronously, returns the bare model as JSON, and has zero tests. It runs. It is not the Laravel you should ship in 2026.

The fix is .cursorrules — one file in the repo that tells the AI what idiomatic modern Laravel looks like. Eight rules below, each with the failure mode, the rule, and a before/after. Copy-paste .cursorrules at the end.


How Cursor Rules Work for Laravel Projects

Cursor reads project rules from two locations: .cursorrules (a single file at the repo root, still supported) and .cursor/rules/*.mdc (modular files with frontmatter, recommended). For a Laravel app I recommend modular rules so concerns stay separable — the model rules apply to app/Models/**, the test rules apply to tests/**, and the queue rules apply to app/Jobs/** and app/Listeners/** without bleeding into HTTP code:

.cursor/rules/
  laravel-models.mdc         # fillable, casts, observers, Attribute::make
  laravel-controllers.mdc    # invokable controllers + Action classes
  laravel-validation.mdc     # FormRequest, custom rules, authorize()
  laravel-queries.mdc        # eager loading, scopes, chunk/lazy
  laravel-jobs.mdc           # ShouldQueue, retries, uniqueness, fakes
  laravel-resources.mdc      # JsonResource, conditional fields, OpenAPI
  laravel-authorization.mdc  # Policies, Gates, $this->authorize()
  laravel-testing.mdc        # Pest, factories, RefreshDatabase, fakes
Enter fullscreen mode Exit fullscreen mode

Frontmatter controls activation: globs: ["app/**/*.php", "routes/**/*.php", "tests/**/*.php"] with alwaysApply: false. Now the rules.


Rule 1: Eloquent Models — Explicit $fillable, Typed $casts, Observers Over boot(), Attribute::make() Accessors

Cursor's training is heavy on Laravel 5–7 model patterns: protected $guarded = [] to "save time," accessors written as getFullNameAttribute(), mutators in the same file scattered between scopes and relationships, static::created(function ($model) { ... }) blocks inside boot() that grow until nobody can find which side effect ran on save. The rule: every model declares an explicit $fillable allowlist, every column with a non-string type declares a $casts entry (including PHP 8.1+ enums), the modern Attribute::make() API is the only accessor/mutator syntax, and all model events live in dedicated Observer classes registered in AppServiceProvider.

The rule:

Every Eloquent model in app/Models/* has:

  - `protected $fillable = [...]` listing exactly the columns that may
    be mass-assigned through `Model::create()` or `$model->fill()`.
  - `protected $guarded = []` is FORBIDDEN. PRs containing it are
    rejected.
  - `protected $casts = [...]` for every non-string column. Required
    cast types:
      - 'integer', 'boolean', 'float', 'decimal:2'
      - 'datetime', 'immutable_datetime', 'date'
      - 'array', 'collection', 'object', 'AsArrayObject::class'
      - Enum classes: 'status' => OrderStatus::class
      - Custom value objects via 'AsCustomCast::class'

  - `protected $hidden = [...]` for any sensitive column that must
    never serialize (password, remember_token, two_factor_secret,
    api_token, stripe_*).

  - `protected $appends = [...]` ONLY for accessors that the API
    explicitly needs serialized  never as a convenience for Blade.

Accessors and mutators use `Attribute::make()` (Laravel 9+ syntax).
The `getFooAttribute()` / `setFooAttribute()` magic-method form is
forbidden in new code:

  use Illuminate\Database\Eloquent\Casts\Attribute;

  protected function fullName(): Attribute {
      return Attribute::make(
          get: fn () => trim("{$this->first_name}{$this->last_name}"),
          set: fn (string $value) => [
              'first_name' => Str::before($value, ' '),
              'last_name'  => Str::after($value, ' '),
          ],
      );
  }

Model events (creating/created/updating/updated/deleting/deleted/
restoring/restored) live in dedicated Observer classes in
app/Observers/*Observer.php. The model's `boot()` method is reserved
for global scopes only (rare). Observers are registered in
AppServiceProvider::boot() via `Order::observe(OrderObserver::class)`.

Relationships have explicit return types so static analysis catches
mistakes:

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

Use enum-backed constants for any column with a fixed set of values.
The migration uses `$table->string('status')->default('pending')` and
the model casts to the enum:

  protected $casts = ['status' => OrderStatus::class];

Soft deletes via `use SoftDeletes;` ONLY when the business explicitly
needs an "undelete" or "audit-trail" affordance. Soft deletes change
every query and trip up junior devs — they are not a default.

Timestamps: rely on `created_at` / `updated_at` (Laravel default).
For models that don't need them set `public $timestamps = false;`
explicitly  silent absence is a bug.

`Model::preventLazyLoading()`, `Model::preventSilentlyDiscardingAttributes()`,
and `Model::preventAccessingMissingAttributes()` are called in
AppServiceProvider::boot() inside `if (! $this->app->isProduction())`
so dev/test environments crash on N+1 lazy loads, silent attribute
drops, and missing attribute reads.
Enter fullscreen mode Exit fullscreen mode

Before — guarded empty, magic accessor, event in boot():

class Order extends Model
{
    protected $guarded = [];

    public function getFormattedTotalAttribute(): string
    {
        return '$' . number_format($this->total / 100, 2);
    }

    protected static function boot()
    {
        parent::boot();
        static::created(function ($order) {
            Mail::to($order->user)->send(new OrderConfirmation($order));
            Log::info("Order {$order->id} created");
            $order->user->increment('lifetime_value', $order->total);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Mass-assignable everything. Magic accessor invisible to static analysis. Side effects buried in boot() that fire on every test and every seeder.

After — explicit fillable, typed casts, observer, modern accessor:

namespace App\Models;

use App\Enums\OrderStatus;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Order extends Model
{
    protected $fillable = [
        'user_id', 'shipping_address_id', 'subtotal_cents',
        'tax_cents', 'total_cents', 'currency', 'status', 'placed_at',
    ];

    protected $casts = [
        'subtotal_cents' => 'integer',
        'tax_cents'      => 'integer',
        'total_cents'    => 'integer',
        'status'         => OrderStatus::class,
        'placed_at'      => 'immutable_datetime',
    ];

    protected $hidden = ['stripe_payment_intent_id'];

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

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

    protected function formattedTotal(): Attribute
    {
        return Attribute::make(
            get: fn () => Money::cents($this->total_cents, $this->currency)->format(),
        )->shouldCache();
    }
}

// app/Observers/OrderObserver.php
namespace App\Observers;

use App\Jobs\SendOrderConfirmation;
use App\Models\Order;

class OrderObserver
{
    public function created(Order $order): void
    {
        SendOrderConfirmation::dispatch($order);
    }
}

// app/Providers/AppServiceProvider.php
public function boot(): void
{
    Order::observe(OrderObserver::class);
    Model::preventLazyLoading(! $this->app->isProduction());
    Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());
}
Enter fullscreen mode Exit fullscreen mode

Mass assignment is allowlisted. Status is a real enum with a single source of truth. Side effects live in an observer that tests can swap out. Lazy loads crash dev immediately.


Rule 2: Skinny Controllers, Single-Action Invokable Controllers, Service/Action Classes For Business Logic

Cursor's default for "build me an endpoint" is a resource controller with seven methods, each ranging from 30 to 200 lines, mixing validation, persistence, third-party calls, mailers, and response shaping in one file. The rule: HTTP controllers are thin — they receive a typed Form Request, dispatch a single Action class, and return a JsonResource or a redirect. All business logic lives in app/Actions/* invokable classes that are pure functions of their dependencies, easy to test in isolation, and reusable from controllers, console commands, and queue jobs.

The rule:

Controllers in app/Http/Controllers/* are SINGLE-ACTION INVOKABLE
classes by default for any non-trivial endpoint:

  class CreateOrderController {
      public function __invoke(CreateOrderRequest $request, CreateOrder $action) {
          $order = $action($request->validatedDto());
          return new OrderResource($order);
      }
  }

A controller method body has at most these statements:
  1. Resolve dependencies (constructor / method injection).
  2. Pull validated data via the Form Request (see Rule 3).
  3. Call exactly ONE Action class (or a small composition of two).
  4. Return a JsonResource, RedirectResponse, View, or
     ResourceCollection.

Forbidden in controllers:
  - Database queries (Eloquent or DB facade)  those go in the Action
    or a Repository/Query class.
  - Business rules (`if ($order->total > 1000) { ... }`)  those go in
    the Action or the Domain layer.
  - Mailer calls (`Mail::to(...)->send(...)`)  those go in queued
    jobs dispatched by the Action.
  - Third-party HTTP calls  those go in dedicated Service classes
    injected into the Action.
  - More than ~15 lines of code total.

Resourceful controllers (`Route::resource`) are still acceptable for
straight CRUD where each method is 1-3 lines. The moment a method
exceeds that, split it into an invokable controller + Action.

Action classes live in app/Actions/<Domain>/<Verb><Noun>.php and are
invokable:

  namespace App\Actions\Orders;

  use App\DataObjects\OrderInput;
  use App\Models\Order;

  final class CreateOrder
  {
      public function __construct(
          private readonly OrderRepository $repository,
          private readonly InventoryService $inventory,
          private readonly Dispatcher $events,
      ) {}

      public function __invoke(OrderInput $input): Order {
          $this->inventory->reserve($input->productIds);
          $order = $this->repository->create($input);
          $this->events->dispatch(new OrderPlaced($order));
          return $order;
      }
  }

Actions accept typed input objects (DataObjects / spatie/laravel-data
objects)  never raw arrays, never the Request itself. The Request
lives only in the controller; conversion to a DTO happens in the
Form Request's `validatedDto()` helper.

Actions are `final` classes and use `readonly` properties for
dependencies (PHP 8.1+).

Actions return domain models or DTOs — never HTTP responses or
JsonResources. Response shaping is the controller's concern.

Routes in routes/api.php and routes/web.php use the `[Controller::class,
'method']` array syntax for resource controllers, or `Controller::class`
alone for invokable controllers. The string callable `'X@y'` form
(Laravel 5 era) is forbidden.

Route model binding does the lookup; controllers receive the resolved
model:

  Route::get('/orders/{order}', ShowOrderController::class);

  class ShowOrderController {
      public function __invoke(Order $order): OrderResource {
          $this->authorize('view', $order);
          return new OrderResource($order);
      }
  }
Enter fullscreen mode Exit fullscreen mode

Before — fat controller with everything inline:

class OrderController extends Controller
{
    public function store(Request $request)
    {
        $request->validate([
            'product_ids'   => 'required|array',
            'product_ids.*' => 'integer|exists:products,id',
            'address_id'    => 'required|integer|exists:addresses,id',
        ]);

        $products = Product::whereIn('id', $request->product_ids)->get();
        $total = 0;
        foreach ($products as $p) {
            if ($p->stock < 1) {
                return response()->json(['error' => "Out of stock: {$p->name}"], 422);
            }
            $total += $p->price_cents;
            $p->decrement('stock');
        }

        $order = Order::create([
            'user_id'    => auth()->id(),
            'address_id' => $request->address_id,
            'total'      => $total,
            'status'     => 'pending',
        ]);

        foreach ($products as $p) {
            $order->items()->create(['product_id' => $p->id, 'price' => $p->price_cents]);
        }

        Mail::to(auth()->user())->send(new OrderConfirmation($order));

        return response()->json($order);
    }
}
Enter fullscreen mode Exit fullscreen mode

Validation, query, business rules, persistence, mailer, and response shaping in one method. Untestable without spinning up the world. Returns the bare model.

After — invokable controller, Form Request, Action class, Resource:

// app/Http/Controllers/Orders/CreateOrderController.php
namespace App\Http\Controllers\Orders;

use App\Actions\Orders\CreateOrder;
use App\Http\Requests\Orders\CreateOrderRequest;
use App\Http\Resources\OrderResource;

final class CreateOrderController
{
    public function __invoke(
        CreateOrderRequest $request,
        CreateOrder $action,
    ): OrderResource {
        $order = $action($request->toDto());
        return new OrderResource($order);
    }
}

// app/Actions/Orders/CreateOrder.php
namespace App\Actions\Orders;

use App\DataObjects\OrderInput;
use App\Events\OrderPlaced;
use App\Exceptions\OutOfStockException;
use App\Models\Order;
use App\Services\InventoryService;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\Facades\DB;

final readonly class CreateOrder
{
    public function __construct(
        private InventoryService $inventory,
        private Dispatcher $events,
    ) {}

    public function __invoke(OrderInput $input): Order
    {
        return DB::transaction(function () use ($input) {
            $reserved = $this->inventory->reserve($input->productIds);
            $order = Order::create([
                'user_id'             => $input->userId,
                'shipping_address_id' => $input->shippingAddressId,
                'subtotal_cents'      => $reserved->subtotalCents,
                'tax_cents'           => $reserved->taxCents,
                'total_cents'         => $reserved->totalCents,
                'currency'            => $reserved->currency,
                'status'              => OrderStatus::Pending,
            ]);
            $order->items()->createMany($reserved->items);
            $this->events->dispatch(new OrderPlaced($order));
            return $order;
        });
    }
}

// routes/api.php
Route::middleware('auth:sanctum')
    ->post('/orders', CreateOrderController::class)
    ->name('orders.store');
Enter fullscreen mode Exit fullscreen mode

Controller is six lines. Action is testable in isolation with a fake InventoryService. The mailer is dispatched by an event listener (Rule 5), not the controller.


Rule 3: Form Requests For Validation — Authorization, Custom Rule Objects, DTO Conversion

$request->validate([...]) inline at the top of every controller is the Laravel anti-pattern that survived three major versions because it works and the docs show it. Cursor reproduces it everywhere. The rule: every endpoint that accepts input has a dedicated FormRequest in app/Http/Requests/*, the rules array lives there, the authorize() method does the policy check, custom rules are objects in app/Rules/*, and the request exposes a typed toDto() method that the controller and action consume.

The rule:

Every controller method that accepts user input takes a Form Request,
not the bare Request. Form Requests live in
app/Http/Requests/<Domain>/<Verb><Noun>Request.php:

  php artisan make:request Orders/CreateOrderRequest

Generated. Never inline `$request->validate([...])` in a controller.

Form Request structure:

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

      public function rules(): array
      {
          return [
              'product_ids'        => ['required', 'array', 'min:1', 'max:50'],
              'product_ids.*'      => ['integer', 'exists:products,id', new ProductInStock()],
              'shipping_address_id'=> [
                  'required', 'integer',
                  Rule::exists('addresses', 'id')->where('user_id', $this->user()->id),
              ],
              'currency'           => ['required', 'string', new Enum(Currency::class)],
              'notes'              => ['nullable', 'string', 'max:500'],
          ];
      }

      public function messages(): array
      {
          return [
              'product_ids.required' => 'Pick at least one product.',
              'product_ids.max'      => 'You can order up to 50 items at once.',
          ];
      }

      public function toDto(): OrderInput
      {
          $v = $this->validated();
          return new OrderInput(
              userId:            $this->user()->id,
              productIds:        $v['product_ids'],
              shippingAddressId: $v['shipping_address_id'],
              currency:          Currency::from($v['currency']),
              notes:             $v['notes'] ?? null,
          );
      }
  }

Rules use the array form (`['required', 'string']`), never the
pipe-string form (`'required|string'`). The pipe form breaks when a
rule needs a `|` (regex patterns) and is harder to compose.

Custom validation rules are invokable Rule objects in app/Rules/*:

  namespace App\Rules;

  use Closure;
  use Illuminate\Contracts\Validation\ValidationRule;
  use App\Models\Product;

  class ProductInStock implements ValidationRule
  {
      public function validate(string $attribute, mixed $value, Closure $fail): void
      {
          if (! Product::where('id', $value)->where('stock', '>', 0)->exists()) {
              $fail('The :attribute is out of stock.');
          }
      }
  }

For complex validation that hits the DB hard, use `prepareForValidation`
to normalize input (trim, lowercase emails, strip currency formatting)
and `withValidator` for cross-field rules.

`authorize()` returns a boolean. For policy checks against a route-bound
model use `$this->user()->can('update', $this->route('order'))`. Never
do `return true;` to "deal with auth later."

Request rules referencing the authenticated user (e.g., a unique-email
rule that excludes the current user) take that user from `$this->user()`,
not `auth()->user()` inside the rules array.

Validated data accessed via `$this->validated()` (returns only validated
keys) or `$this->safe()->only([...])`. Never `$this->all()` or
`$this->input(...)` after validation  bypasses the contract.

Form Request -> DTO via `toDto()` is the canonical pattern. Actions
accept the DTO; they never see the FormRequest or Request.

For API endpoints: failing validation auto-returns 422 with the
ValidationException JSON shape. For web forms: redirects back with
`->withErrors()` and `->withInput()` automatically.

Rate limiting per route via the `throttle:` middleware, configured
in app/Providers/RouteServiceProvider.php; per-action limits inside
Form Requests via `RateLimiter::for(...)` named limiters.
Enter fullscreen mode Exit fullscreen mode

Before — inline validate, no authorization, no DTO:

public function store(Request $request)
{
    $data = $request->validate([
        'product_ids' => 'required|array',
        'address_id'  => 'required|integer',
    ]);

    return Order::create($data + ['user_id' => auth()->id()]);
}
Enter fullscreen mode Exit fullscreen mode

Authorization absent. No DTO. Mass-assigns directly from request.

After — Form Request with authorize, custom rule, typed DTO:

// app/Http/Requests/Orders/CreateOrderRequest.php
namespace App\Http\Requests\Orders;

use App\DataObjects\OrderInput;
use App\Enums\Currency;
use App\Models\Order;
use App\Rules\ProductInStock;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Enum;

final class CreateOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()?->can('create', Order::class) ?? false;
    }

    public function rules(): array
    {
        return [
            'product_ids'         => ['required', 'array', 'min:1', 'max:50'],
            'product_ids.*'       => ['integer', 'distinct', 'exists:products,id', new ProductInStock()],
            'shipping_address_id' => [
                'required', 'integer',
                Rule::exists('addresses', 'id')->where('user_id', $this->user()->id),
            ],
            'currency'            => ['required', new Enum(Currency::class)],
            'notes'               => ['nullable', 'string', 'max:500'],
        ];
    }

    public function toDto(): OrderInput
    {
        $v = $this->validated();
        return new OrderInput(
            userId:            $this->user()->id,
            productIds:        $v['product_ids'],
            shippingAddressId: $v['shipping_address_id'],
            currency:          Currency::from($v['currency']),
            notes:             $v['notes'] ?? null,
        );
    }
}

// app/DataObjects/OrderInput.php
namespace App\DataObjects;

use App\Enums\Currency;

final readonly class OrderInput
{
    public function __construct(
        public int $userId,
        /** @var array<int, int> */
        public array $productIds,
        public int $shippingAddressId,
        public Currency $currency,
        public ?string $notes,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Authorization happens before the controller body. Validation rules are typed and reusable. The Action receives a typed DTO it can confidently destructure.


Rule 4: Database Queries & N+1 Prevention — Eager Loading, Scopes, select() Only Needed Columns, chunk()/lazy() For Large Sets

The N+1 query is Laravel's most common production incident. Cursor will write User::all() followed by a Blade @foreach that touches $user->company->name, and the request that should hit the DB twice fires four hundred queries. The rule: every query that will be iterated declares its eager loads via with(), only selects the columns it needs, uses query scopes for any pattern repeated more than twice, and processes large result sets via chunk() or lazy(). DB::raw with string concatenation is a hard ban; parameterized binds or DB::raw('lower(?)', [$value]) are the only acceptable form.

The rule:

N+1 prevention is enforced. In dev/test, AppServiceProvider::boot()
calls `Model::preventLazyLoading()` so any unintended lazy load throws
`LazyLoadingViolationException`. Every PR that opts out (with
`Model::preventLazyLoading(false)`) requires a code-review justification.

Eager loading rules:
  - Every query whose results will be iterated declares `with([...])`.
  - Polymorphic and morph-many relations use `morphWith` for nested
    eager loads of polymorphic children.
  - Constrained eager loads via closure:
      `with(['orders' => fn ($q) => $q->where('status', 'paid')->limit(10)])`
  - `withCount(['comments'])` instead of `count($post->comments)` in
    a loop.
  - `withExists(['subscription'])` for boolean "does it exist" checks
    instead of `$user->subscription !== null`.
  - `loadMissing(...)` only when the parent query already executed
    (e.g., a single model passed into a method). For lists, use `with`
    at the query.

`select()` non-trivially. The default `select *` is wasteful for wide
tables. Specify columns whenever the consumer (resource, view) uses
fewer than half the columns. Always include the primary key and any
foreign keys needed for relationships:
  User::select('id', 'name', 'email')->with('orders:id,user_id,total_cents')->get();

Query scopes for any filter/order pattern reused >=2 times.
Local scopes:
  public function scopeActive(Builder $q): Builder {
      return $q->where('status', UserStatus::Active);
  }
Global scopes for tenancy / soft-delete-like behavior, registered in
the model's `booted()` method via `static::addGlobalScope(...)`.

DB::raw is forbidden with string concatenation. The only acceptable
forms are:
  - DB::raw('lower(name)') — literal, no user input.
  - DB::raw('count(*) as total') — literal aggregate alias.
  - For parameterized raw expressions, use whereRaw with bindings:
      ->whereRaw('lower(name) like ?', ["%{$search}%"])
    BUT the surrounding column name and operator are still literals;
    the user input is bound.
  - For full-text search: use the dedicated FullText index + Scout
    (Algolia/Meilisearch/Typesense), not LIKE concatenation.

Pagination: `paginate($perPage)` for offset pagination on small
datasets; `cursorPaginate()` for large datasets where deep offsets
would be expensive. `simplePaginate()` when the total count is unused.

Large datasets:
  - `chunk(1000, fn ($rows) => ...)` for batch processing where order
    is unimportant.
  - `chunkById(1000, fn ($rows) => ...)` when iterating while writing
    to the same table — avoids reprocessing.
  - `lazy(1000)` returns a LazyCollection — same memory profile as
    chunk but composes with collection methods.
  - `cursor()` returns a generator — single forward pass, smallest
    memory, but holds an open DB cursor. Avoid for jobs that may be
    long-running or interleave with other queries.

Indexes. Migrations declare indexes for any column used in a WHERE,
ORDER BY, or JOIN. Composite indexes for compound conditions; column
order matches selectivity (most selective first).

Transactions. Multi-write operations wrap in `DB::transaction(fn () =>
...)`. Never manual `DB::beginTransaction()` / `commit()` / `rollBack()`
— the closure form handles deadlock retries automatically.

Read replicas (database.connections with `read`/`write` arrays):
specify the connection on read-heavy reports via `->on('reporting')`
or rely on default routing.
Enter fullscreen mode Exit fullscreen mode

Before — N+1, DB::raw concat, no scope:

$users = User::all();
foreach ($users as $user) {
    echo $user->name . ' (' . $user->company->name . ') - ' . count($user->orders);
}

$results = DB::select(
    "SELECT * FROM products WHERE name LIKE '%" . $request->q . "%' ORDER BY created_at DESC"
);
Enter fullscreen mode Exit fullscreen mode

One query for users, one for each user's company, one for each user's orders. SQL injection in the second snippet.

After — eager loaded, scoped, parameterized:

// app/Models/User.php
public function scopeActive(Builder $q): Builder
{
    return $q->where('status', UserStatus::Active);
}

// Action / Query class
$users = User::query()
    ->active()
    ->select('id', 'name', 'email', 'company_id')
    ->with([
        'company:id,name',
    ])
    ->withCount('orders')
    ->orderByDesc('created_at')
    ->cursorPaginate(50);

foreach ($users as $user) {
    echo "{$user->name} ({$user->company->name}) - {$user->orders_count}";
}

// Search via parameterized whereRaw + Scout for production
$products = Product::search($request->validated('q'))
    ->where('available', true)
    ->paginate(20);
Enter fullscreen mode Exit fullscreen mode

Two queries total regardless of result size. Counts come from the DB, not PHP. Search goes through Scout's typed search engine, not LIKE concatenation.


Rule 5: Queues & Background Jobs — ShouldQueue, Idempotent handle(), WithoutOverlapping, Redis In Production, Never Sync Mailers

A synchronous mailer in a controller is a five-second user-facing latency on a good day and a 30-second timeout on a bad one. Cursor will dispatch jobs synchronously, omit retry strategies, and write handle() methods that crash halfway through with no idempotency guard, leaving the system in an inconsistent state. The rule: every side effect (mail, webhook, third-party API call, image processing) is queued, jobs implement ShouldQueue with explicit tries, backoff, timeout, and a unique-job constraint where appropriate, and handle() is written so the second attempt produces the same outcome as the first.

The rule:

Side effects that are not part of the request's correctness contract
are queued. This includes:
  - Mailers (welcome emails, receipts, notifications).
  - Push notifications, SMS.
  - Webhook deliveries to third parties.
  - Image/video transcoding.
  - PDF generation.
  - Search index updates (Scout already does this if QUEUE_CONNECTION
    is set).
  - Bulk imports / exports.
  - Cache warming.

Synchronous in-request:
  - Reading from cache to render the response.
  - Writing the primary domain model (the order, the post, the comment).
  - Authorization checks.
  - Validation.

Every queued job in app/Jobs/* implements `ShouldQueue` and uses these
traits:
  use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

And declares:
  public int $tries = 5;
  public int $backoff = 30; // or a method returning [10, 30, 60, 300]
  public int $timeout = 120;
  public int $maxExceptions = 3;
  public bool $deleteWhenMissingModels = true;

`uniqueId()` and `ShouldBeUnique` for jobs that must not run twice
simultaneously (sending a welcome email, processing a payment):

  class SendWelcomeEmail implements ShouldQueue, ShouldBeUnique
  {
      public int $uniqueFor = 3600;
      public function uniqueId(): string {
          return "welcome-email:{$this->user->id}";
      }
  }

WithoutOverlapping middleware for jobs that touch shared state
(updating a counter, syncing a single integration):

  public function middleware(): array {
      return [(new WithoutOverlapping($this->order->id))->expireAfter(180)];
  }

`handle()` MUST be idempotent. Patterns:
  - Check current state at the top: `if ($this->order->status->isPaid()) return;`
  - Use `firstOrCreate` / `updateOrCreate` for persistence.
  - Use idempotency keys when calling third-party APIs.
  - Wrap in a DB transaction so partial writes don't survive crashes.

`failed(Throwable $e)` method on every job that has business
implications when it fails  sends a Slack alert, marks a related
record as failed, releases a reservation.

Queue connection: `redis` in production (single-host Redis, or
Horizon-managed). `database` only in dev / for low-volume cron-like
work. `sync` only in tests with `Bus::fake()` / `Queue::fake()`.

Horizon for production: workers, balancing strategy ("auto"), per-queue
process counts, memory limits, retry policies all in
config/horizon.php. Slack notifications on long-waiting queues.

Job batching for fan-out work (process 10K rows): `Bus::batch([...])
->then(fn ($batch) => ...)->dispatch()`. Track progress, send a
notification on completion.

Scheduled jobs in app/Console/Kernel.php with explicit overlap
prevention:
  $schedule->command('reports:nightly')
      ->dailyAt('02:00')
      ->onOneServer()
      ->withoutOverlapping(60)
      ->runInBackground()
      ->emailOutputOnFailure(config('app.ops_email'));

`onQueue('emails')` for routing  high-priority queues processed by
dedicated worker pools.

Encryption of sensitive payloads via `ShouldBeEncrypted` interface for
jobs that serialize tokens / PII into the queue payload.

Never `Mail::to(...)->send(...)` (synchronous). Always
`Mail::to(...)->queue(...)` or implement Mailable as `ShouldQueue`.
Enter fullscreen mode Exit fullscreen mode

Before — sync mail, no retries, no idempotency:

class OrderController
{
    public function store(Request $request)
    {
        $order = Order::create($request->all());
        Mail::to(auth()->user())->send(new OrderConfirmation($order));
        SendToWarehouse::dispatch($order);
        return $order;
    }
}

class SendToWarehouse implements ShouldQueue
{
    public function __construct(public Order $order) {}

    public function handle(WarehouseClient $client): void
    {
        $client->createShipment($this->order->id, $this->order->items);
    }
}
Enter fullscreen mode Exit fullscreen mode

Mail blocks the request. Job has no tries, no timeout, no idempotency — a retry duplicates the warehouse shipment.

After — queued mail, retries with backoff, idempotent handle, unique guard:

// app/Jobs/SendOrderConfirmation.php
namespace App\Jobs;

use App\Mail\OrderConfirmationMail;
use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;

final class SendOrderConfirmation implements ShouldQueue, ShouldBeUnique
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 5;
    public int $timeout = 60;
    public int $maxExceptions = 3;
    public bool $deleteWhenMissingModels = true;
    public int $uniqueFor = 3600;

    public function __construct(public readonly Order $order) {}

    public function uniqueId(): string
    {
        return "order-confirmation:{$this->order->id}";
    }

    public function backoff(): array
    {
        return [10, 30, 60, 300];
    }

    public function handle(): void
    {
        $this->order->refresh();

        if ($this->order->confirmation_sent_at !== null) {
            return; // idempotent
        }

        Mail::to($this->order->user->email)
            ->queue(new OrderConfirmationMail($this->order));

        $this->order->forceFill(['confirmation_sent_at' => now()])->save();
    }

    public function failed(\Throwable $e): void
    {
        report($e);
        $this->order->forceFill(['confirmation_failed_at' => now()])->save();
    }
}

// app/Jobs/SyncToWarehouse.php
final class SyncToWarehouse implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 8;

    public function middleware(): array
    {
        return [(new WithoutOverlapping($this->order->id))->expireAfter(300)];
    }

    public function __construct(public readonly Order $order) {}

    public function handle(WarehouseClient $client): void
    {
        $idempotencyKey = "warehouse:order:{$this->order->id}";
        $client->createShipment($this->order, idempotencyKey: $idempotencyKey);
    }
}
Enter fullscreen mode Exit fullscreen mode

Jobs survive transient warehouse outages. Retries don't double-send the email or duplicate the shipment. The request returns the moment the order is persisted.


Rule 6: API Resources & Typed Responses — JsonResource For Every Endpoint, Conditional Fields, OpenAPI Generation

Returning a raw Eloquent model from an API is how your password_hash, remember_token, two_factor_secret, and stripe_customer_id end up in the public response and on a security researcher's blog post. Cursor does this routinely — return $user; is shorter than writing a Resource and the AI optimizes for fewer files. The rule: every API response goes through a JsonResource (or ResourceCollection), the resource controls exactly which fields ship, conditional includes use when() / whenLoaded(), and the OpenAPI spec is generated from typed annotations or scrambled from method signatures.

The rule:

Every JSON API response is wrapped in a JsonResource. Returning a bare
Eloquent model or collection is forbidden:

  // BAD
  return User::find($id);

  // GOOD
  return new UserResource(User::findOrFail($id));

Resources live in app/Http/Resources/<Domain>/<Noun>Resource.php and
extend either:
  - JsonResource  for a single model.
  - ResourceCollection  for a custom collection wrapper.
  - AnonymousResourceCollection (returned by `Resource::collection(...)`)
     for a list of models with the default wrapper.

Resource shape rules:
  - Whitelist fields. Only declare keys that the API contract documents.
  - Cast types match the cast types on the model. The Resource is the
    contract; the model schema may evolve underneath.
  - Money fields ship as `{ amount_cents: int, currency: string,
    formatted: string }`, never as floats.
  - Datetimes ship as ISO-8601 strings via `->toIso8601String()`.
  - Enums ship as their `->value`, not as objects.

Conditional fields:
  - `whenLoaded('relation')`  include the relation only if eager-loaded.
    Prevents accidental N+1 from a lazy-load inside the resource.
  - `whenCounted('relation')`  include the count only if `withCount`
    was called.
  - `when($condition, fn () => ...)`  include the field only when
    a condition is true (e.g., admin-only fields).
  - `mergeWhen($condition, [...])`  merge a sub-array conditionally.

Resource collections for paginated responses retain the meta envelope:
  return new OrderCollection($orders);

  class OrderCollection extends ResourceCollection {
      public $collects = OrderResource::class;
      public function with($request): array {
          return ['meta' => ['version' => 'v2']];
      }
  }

The default wrapping key is "data". For non-wrapped responses set
`JsonResource::withoutWrapping()` in AppServiceProvider; otherwise
keep the wrap.

Polymorphic relations: dispatch to the right resource via
`MorphResource::make($model)` that returns the appropriate resource
based on the model class.

OpenAPI documentation: either
  - L5-Swagger with annotations on controllers + resources, or
  - Scribe (knuckleswtf/scribe) with reflection + docblocks, or
  - Spectator + handcrafted OpenAPI YAML.
The CI build runs `php artisan scribe:generate` (or equivalent) and
fails if the spec drifts from the routes/controllers.

Versioning: API versions in route prefixes (`/api/v1`, `/api/v2`),
each with its own resources directory (`Resources/V1/`, `Resources/V2/`).
Never version inside a single resource via conditional logic.

Error responses: a single shape across the API.
  { "message": string, "errors"?: { "field": [string, ...] } }
ValidationException auto-renders this; for other exceptions register
custom renderers in app/Exceptions/Handler.php.

HTTP status codes are explicit:
  - 200 GET success
  - 201 POST creating a resource
  - 202 async / queued
  - 204 DELETE / no content
  - 400 client-side malformed
  - 401 unauthenticated
  - 403 unauthorized (use the Policy, see Rule 7)
  - 404 not found (route model binding handles this)
  - 409 conflict (business rule violation, e.g., already-paid order)
  - 422 validation
  - 429 rate limited

Streaming / large downloads: `Response::streamDownload(...)` for CSVs
and reports. Never assemble large strings in memory.
Enter fullscreen mode Exit fullscreen mode

Before — bare model, secrets in the wire format:

public function show(User $user)
{
    return $user->load('orders');
}
Enter fullscreen mode Exit fullscreen mode

password_hash, remember_token, two_factor_secret ship to the client. Lazy-loaded relations on every render.

After — JsonResource with whitelist, conditional includes, typed money:

// app/Http/Resources/V1/UserResource.php
namespace App\Http\Resources\V1;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

final class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'         => $this->id,
            'name'       => $this->name,
            'email'      => $this->email,
            'role'       => $this->role->value,
            'created_at' => $this->created_at->toIso8601String(),
            'orders'     => OrderResource::collection($this->whenLoaded('orders')),
            'orders_count' => $this->whenCounted('orders'),
            'admin_notes'  => $this->when(
                $request->user()?->can('viewAdminNotes', $this->resource),
                fn () => $this->admin_notes,
            ),
        ];
    }
}

// app/Http/Resources/V1/OrderResource.php
final class OrderResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'         => $this->id,
            'status'     => $this->status->value,
            'total'      => [
                'amount_cents' => $this->total_cents,
                'currency'     => $this->currency,
                'formatted'    => Money::cents($this->total_cents, $this->currency)->format(),
            ],
            'placed_at'  => $this->placed_at?->toIso8601String(),
            'items'      => OrderItemResource::collection($this->whenLoaded('items')),
        ];
    }
}

// app/Http/Controllers/Users/ShowUserController.php
final class ShowUserController
{
    public function __invoke(Request $request, User $user): UserResource
    {
        $request->user()->can('view', $user) ?: abort(403);
        return new UserResource(
            $user->loadMissing(['orders'])->loadCount('orders')
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The wire format is a contract, not a leak. Conditional fields hide admin-only data without a separate endpoint.


Rule 7: Authorization — Policies For Every Model, Gates For Non-Model Checks, $this->authorize() Everywhere, Never if ($user->is_admin)

Authorization scattered through controllers as if (auth()->user()->is_admin || auth()->user()->id === $order->user_id) is the pattern that survives until one developer forgets it on one endpoint and a regular user starts deleting other users' orders. Cursor produces this every time. The rule: every model has a Policy, every controller calls $this->authorize() (or the Form Request's authorize() method does), non-model permissions live in Gates registered in AppServiceProvider, and route-level guards use middleware (->middleware('can:update,order')).

The rule:

Every Eloquent model that has any access control has a Policy in
app/Policies/<Model>Policy.php, registered in AuthServiceProvider's
$policies array. Policies use the standard ability names:
  - viewAny(User $user): bool
  - view(User $user, Model $model): bool
  - create(User $user): bool
  - update(User $user, Model $model): bool
  - delete(User $user, Model $model): bool
  - restore(User $user, Model $model): bool
  - forceDelete(User $user, Model $model): bool

Plus custom abilities for domain-specific actions:
  - cancel(User $user, Order $order): bool
  - refund(User $user, Order $order): bool

Policy methods return `bool` or `Response` (for denial messages):
  return Response::deny('This order is older than 30 days.');

The `before()` method on a Policy short-circuits for super-admins:
  public function before(User $user, string $ability): ?bool {
      return $user->isPlatformAdmin() ? true : null;
  }
Returning `null` lets the per-method check run.

Authorization in controllers:
  - `$this->authorize('update', $order);` — throws 403 on denial.
  - `$user->can('update', $order)` — boolean, used in conditionals
    and `when()`-conditional resources.
  - `Gate::authorize('update', $order)` — facade form, same behavior.

Authorization in Form Requests' `authorize()` method:
  return $this->user()?->can('create', Order::class) ?? false;

Route-level middleware for whole-resource gates:
  Route::middleware('can:viewAny,App\Models\Order')
      ->get('/orders', IndexOrdersController::class);
  Route::middleware('can:update,order')
      ->patch('/orders/{order}', UpdateOrderController::class);

Non-model abilities (admin dashboard, feature toggles) are Gates
registered in AppServiceProvider::boot():
  Gate::define('access-admin-panel', fn (User $u) => $u->is_admin);
  Gate::define('manage-billing', fn (User $u) => $u->hasRole('billing'));

Forbidden patterns:
  - `if ($user->is_admin)` in controllers  use Gates.
  - `if ($user->id === $model->user_id)` in controllers  use Policies.
  - `auth()->user()->role === 'admin'`  use enum comparison + Gate.
  - Authorization "by hiding the URL"  not authorization.

Multi-tenant authorization: every Policy checks tenant scoping first:
  public function view(User $user, Order $order): bool {
      if ($order->tenant_id !== $user->current_tenant_id) return false;
      return $user->id === $order->user_id || $user->isTenantAdmin();
  }

Or use a global tenant scope on the model so cross-tenant queries
return zero rows; the Policy then only checks per-tenant role.

Roles & permissions: spatie/laravel-permission for any non-trivial
RBAC. Roles assigned via `$user->assignRole('editor')`. Permissions
checked via Gates that delegate to `$user->hasPermissionTo('publish posts')`.

Sanctum / Passport tokens have abilities (scopes); check via
`$request->user()->tokenCan('orders:write')` inside Policies for
machine-to-machine flows.

Audit: every authorization failure logs the user, ability, and target
to a dedicated channel (config/logging.php) so abuse patterns are
visible.
Enter fullscreen mode Exit fullscreen mode

Before — scattered conditionals, no Policy:

public function update(Request $request, Order $order)
{
    if (auth()->user()->id !== $order->user_id && !auth()->user()->is_admin) {
        return response()->json(['error' => 'Forbidden'], 403);
    }
    $order->update($request->all());
    return $order;
}
Enter fullscreen mode Exit fullscreen mode

Authorization repeated everywhere. Mass assigned. Easy to forget on the next endpoint.

After — Policy + authorize() + middleware + Form Request:

// app/Policies/OrderPolicy.php
namespace App\Policies;

use App\Models\Order;
use App\Models\User;
use Illuminate\Auth\Access\Response;

final class OrderPolicy
{
    public function before(User $user, string $ability): ?bool
    {
        return $user->isPlatformAdmin() ? true : null;
    }

    public function view(User $user, Order $order): bool
    {
        return $user->id === $order->user_id;
    }

    public function update(User $user, Order $order): Response
    {
        if ($user->id !== $order->user_id) {
            return Response::deny('Not your order.');
        }
        if ($order->status->isShipped()) {
            return Response::deny('Cannot modify a shipped order.');
        }
        return Response::allow();
    }

    public function cancel(User $user, Order $order): Response
    {
        return $this->update($user, $order)
            ->merge($order->placed_at->diffInHours() < 24
                ? Response::allow()
                : Response::deny('Cancellation window has closed.'));
    }
}

// app/Providers/AuthServiceProvider.php
protected $policies = [
    Order::class => OrderPolicy::class,
];

// routes/api.php
Route::middleware(['auth:sanctum', 'can:update,order'])
    ->patch('/orders/{order}', UpdateOrderController::class);

// app/Http/Controllers/Orders/UpdateOrderController.php
final class UpdateOrderController
{
    public function __invoke(
        UpdateOrderRequest $request,
        Order $order,
        UpdateOrder $action,
    ): OrderResource {
        // can:update middleware already ran; defense-in-depth:
        $this->authorize('update', $order);
        $order = $action($order, $request->toDto());
        return new OrderResource($order);
    }
}
Enter fullscreen mode Exit fullscreen mode

One source of truth for authorization. Reusable across HTTP, console, and event listeners. Denial reasons surface to the client.


Rule 8: Testing — Pest By Default, RefreshDatabase, Factories With States, Bus::fake() / Queue::fake() / Mail::fake(), Parallel Testing

Cursor will write PHPUnit-style tests with setUp() boilerplate, real Mailgun calls in CI, and assertions against the entire JSON response with assertJsonStructure brittle to any field addition. The rule: Pest is the default for new tests, every test extends a base TestCase with RefreshDatabase, factories define the canonical model shape with named states for variations, every test that dispatches jobs / events / mail uses the appropriate fake() and asserts against it, and the suite runs in parallel via php artisan test --parallel.

The rule:

Pest is the default test framework for new code (pestphp/pest +
pestphp/pest-plugin-laravel). PHPUnit tests are migrated on touch.

Test directory structure:
  tests/Unit/            pure PHP unit tests, no Laravel app.
  tests/Feature/         full HTTP / app boot, DB enabled.
  tests/Browser/         Dusk (only when needed).
  tests/Pest.php         global hooks (uses() registrations).

Pest.php registers global traits:
  uses(Tests\TestCase::class, Illuminate\Foundation\Testing\RefreshDatabase::class)
      ->in('Feature');

Feature tests use `RefreshDatabase` for transactional isolation. The
SQLite in-memory connection in CI:
  - DB_CONNECTION=sqlite, DB_DATABASE=:memory:
  - or a dedicated test Postgres for tests that depend on Postgres-only
    features (JSON ops, full-text, cursor pagination over composite
    keys).

Model factories in database/factories/* define the canonical shape and
named states for variations:

  class OrderFactory extends Factory {
      public function definition(): array {
          return [
              'user_id'     => User::factory(),
              'total_cents' => $this->faker->numberBetween(1000, 50000),
              'currency'    => 'USD',
              'status'      => OrderStatus::Pending,
              'placed_at'   => $this->faker->dateTimeBetween('-30 days'),
          ];
      }
      public function paid(): static {
          return $this->state(['status' => OrderStatus::Paid, 'paid_at' => now()]);
      }
      public function shipped(): static {
          return $this->state(['status' => OrderStatus::Shipped, 'shipped_at' => now()]);
      }
  }

Tests construct fixtures through factories, never raw `Model::create([...])`
inside a test:
  Order::factory()->paid()->for($user)->has(Item::factory()->count(3))->create();

HTTP tests via the Pest fluent API:
  it('creates an order', function () {
      $user = User::factory()->create();
      Bus::fake();

      $response = $this->actingAs($user)->postJson('/api/orders', [
          'product_ids' => [1, 2],
          'shipping_address_id' => 5,
          'currency' => 'USD',
      ]);

      $response->assertCreated()->assertJsonPath('data.status', 'pending');
      $this->assertDatabaseHas('orders', ['user_id' => $user->id, 'status' => 'pending']);
      Bus::assertDispatched(SyncToWarehouse::class, fn ($job) => $job->order->user_id === $user->id);
  });

Side-effect fakes are mandatory:
  - `Bus::fake()` before any code that dispatches jobs; assert via
    `Bus::assertDispatched(JobClass::class, $callback)`.
  - `Queue::fake()` for direct queue pushes.
  - `Mail::fake()` then `Mail::assertQueued(MailableClass::class, $callback)`.
  - `Notification::fake()` then `Notification::assertSentTo($user, ...)`.
  - `Event::fake([OrderPlaced::class])` to selectively fake only some
    events while others continue firing.
  - `Http::fake([...])` for outbound HTTP  never let tests hit real
    third parties.
  - `Storage::fake('s3')` for file uploads.

Database assertions:
  - `assertDatabaseHas('table', [...])` / `assertDatabaseMissing(...)`.
  - `assertDatabaseCount('table', n)`.
  - `assertModelExists($model)` / `assertModelMissing($model)`.
  - `assertSoftDeleted($model)`.

JSON response assertions:
  - `assertJsonPath('data.id', 5)` for pinpoint asserts.
  - `assertJsonStructure([...])` for shape.
  - `assertExactJson([...])` only for tiny responses; brittle for
    larger ones.
  - For complex shapes use a snapshot (spatie/pest-plugin-snapshots).

Authentication: `actingAs($user)` for session; `actingAs($user,
'sanctum')` for token-scoped tests; `Sanctum::actingAs($user, ['orders:write'])`
for ability-scoped tests.

Architecture tests via Pest's `arch()`:
  arch('controllers are thin')
      ->expect('App\Http\Controllers')
      ->toUseStrictTypes()
      ->and('App\Http\Controllers')
      ->classes()
      ->toHaveMethod('__invoke')
      ->ignoring(['App\Http\Controllers\Controller']);

Datasets for parameterized tests:
  it('rejects invalid currency', function (string $bad) {
      // ...
  })->with(['XYZ', '', 'us', '12345']);

Parallel testing: `php artisan test --parallel` with paratest. Tests
must be isolation-safe  no shared state, no order dependencies, no
hardcoded IDs.

CI: GitHub Actions matrix on PHP 8.3 / 8.4, Postgres + Redis services,
artisan test --parallel --coverage with a 80% threshold on app/.
Larastan level 8 in the same workflow.

Coverage targets:
  - Actions / Services: >90% line coverage.
  - Form Requests: every rule has a passing + failing case.
  - Policies: every method has an allowed + denied case.
  - Resources: at least one shape test (snapshot acceptable).
  - Controllers: covered transitively by Feature tests.
Enter fullscreen mode Exit fullscreen mode

Before — PHPUnit boilerplate, real mail, brittle assertions:

class OrderControllerTest extends TestCase
{
    public function test_creates_order()
    {
        $user = factory(User::class)->create();
        $this->actingAs($user);
        $response = $this->postJson('/api/orders', [
            'product_ids' => [1, 2], 'address_id' => 1,
        ]);
        $response->assertStatus(200);
        $this->assertEquals(1, Order::count());
    }
}
Enter fullscreen mode Exit fullscreen mode

Real mail fires. No transaction wrap. Brittle assertion on count.

After — Pest, fakes, factories with states, structured asserts:

// tests/Feature/Orders/CreateOrderTest.php
use App\Jobs\SyncToWarehouse;
use App\Mail\OrderConfirmationMail;
use App\Models\Address;
use App\Models\Product;
use App\Models\User;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Mail;

beforeEach(function () {
    Bus::fake();
    Mail::fake();
});

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

    $response = $this->actingAs($user)->postJson('/api/orders', [
        'product_ids'         => $products->pluck('id')->all(),
        'shipping_address_id' => $address->id,
        'currency'            => 'USD',
    ]);

    $response
        ->assertCreated()
        ->assertJsonPath('data.status', 'pending')
        ->assertJsonStructure(['data' => ['id', 'status', 'total' => ['amount_cents', 'currency', 'formatted']]]);

    $this->assertDatabaseHas('orders', [
        'user_id' => $user->id,
        'status'  => 'pending',
    ]);
    $this->assertDatabaseCount('order_items', 3);

    Bus::assertDispatched(SyncToWarehouse::class, fn ($j) => $j->order->user_id === $user->id);
    Mail::assertNothingQueued(); // confirmation queued by job, not request
});

it('forbids creating an order with another user\'s address', function () {
    $user  = User::factory()->create();
    $other = User::factory()->create();
    $alien = Address::factory()->for($other)->create();
    $product = Product::factory()->inStock()->create();

    $response = $this->actingAs($user)->postJson('/api/orders', [
        'product_ids'         => [$product->id],
        'shipping_address_id' => $alien->id,
        'currency'            => 'USD',
    ]);

    $response->assertUnprocessable()->assertJsonValidationErrorFor('shipping_address_id');
});

it('rejects unauthenticated requests', function () {
    $this->postJson('/api/orders', [])->assertUnauthorized();
});

// tests/Unit/Actions/CreateOrderTest.php — pure logic, no HTTP
it('creates the order inside a transaction', function () {
    $action = app(CreateOrder::class);
    $user = User::factory()->create();
    $address = Address::factory()->for($user)->create();
    $products = Product::factory()->count(2)->inStock()->create();

    $order = $action(new OrderInput(
        userId: $user->id,
        productIds: $products->pluck('id')->all(),
        shippingAddressId: $address->id,
        currency: Currency::USD,
        notes: null,
    ));

    expect($order)->toBeInstanceOf(Order::class)
        ->status->toBe(OrderStatus::Pending)
        ->items->toHaveCount(2);
});
Enter fullscreen mode Exit fullscreen mode

Tests are fast, deterministic, parallelizable. Side effects are asserted, not executed. Factory states make intent clear.


The Complete .cursorrules File

Drop this in the repo root. Cursor and Claude Code both pick it up.

# Laravel 11/12 — Production Patterns

## Models
- Explicit $fillable; $guarded = [] is forbidden.
- $casts for every non-string column, including enums.
- $hidden for secrets (password, remember_token, api_token, stripe_*).
- Accessors/mutators via Attribute::make() — never getFooAttribute().
- Model events in app/Observers/*Observer.php, registered in
  AppServiceProvider; boot() reserved for global scopes.
- Relationships have explicit return types (BelongsTo, HasMany, ...).
- Enum-backed columns via PHP 8.1+ enums.
- preventLazyLoading + preventSilentlyDiscardingAttributes +
  preventAccessingMissingAttributes in dev/test.
- Soft deletes only when business explicitly needs undelete.

## Controllers & Actions
- Single-action invokable controllers for non-trivial endpoints.
- Controller body: <=15 lines, no DB, no business logic, no mailers.
- Business logic in app/Actions/<Domain>/<Verb><Noun>.php — final
  readonly classes, accept typed DTOs, return models/DTOs.
- Resourceful controllers acceptable for 1-3 line CRUD methods.
- Routes use [Controller::class, 'method'] or Controller::class.
- String 'X@y' callables are forbidden.
- Route model binding for lookups.

## Validation
- Every input endpoint has a FormRequest in app/Http/Requests/...
- Inline $request->validate([...]) is forbidden.
- authorize() does the policy check; never `return true;`.
- Rules in array form ['required','string'] — never pipe strings.
- Custom rules as ValidationRule objects in app/Rules/*.
- toDto() converts validated input to a typed DTO; controller passes
  DTO to Action.
- Cross-field rules in withValidator(); normalization in
  prepareForValidation().

## Queries
- preventLazyLoading enforced in dev; opt-out requires justification.
- with([...]) on every query whose results are iterated.
- Constrained eager loads via closure; withCount/withExists for counts
  and existence checks.
- select() specific columns when consumer uses <half the row.
- Query scopes for any pattern reused >=2 times; global scopes for
  tenancy.
- DB::raw with string concatenation is forbidden; whereRaw with
  bindings only.
- Full-text via Scout, never LIKE concat.
- Pagination: paginate / cursorPaginate / simplePaginate by use case.
- Large sets: chunk / chunkById / lazy / cursor by tradeoff.
- Migrations declare indexes for WHERE/ORDER BY/JOIN columns.
- Multi-write ops in DB::transaction(fn () => ...).

## Queues & Jobs
- Side effects (mail, webhooks, third-party APIs, transcoding,
  search index, bulk imports) are queued.
- Mailers: ->queue(...), or Mailable implements ShouldQueue. Never
  ->send(...) for transactional mail.
- Every job: ShouldQueue + tries + backoff (array for tiered) +
  timeout + maxExceptions + deleteWhenMissingModels.
- ShouldBeUnique + uniqueId() for jobs that must not run twice.
- WithoutOverlapping middleware for shared-state jobs.
- handle() is idempotent: state-check at top, firstOrCreate /
  updateOrCreate, idempotency keys for third parties.
- failed(Throwable) on jobs with business consequences.
- Production queue: redis (Horizon-managed). database only in dev /
  low volume. sync only with Bus::fake/Queue::fake.
- onQueue('emails') for per-queue routing; dedicated worker pools.
- Bus::batch for fan-out work with progress.
- Scheduled tasks: onOneServer + withoutOverlapping + runInBackground.

## API Resources
- Every API response is a JsonResource / ResourceCollection. Bare
  models forbidden.
- Resources whitelist fields; types match model casts.
- Money: { amount_cents, currency, formatted }. Datetimes: ISO-8601.
  Enums: ->value.
- Conditional includes: whenLoaded / whenCounted / when / mergeWhen.
- Polymorphic via MorphResource that dispatches to the right resource.
- API versioned via route prefixes and Resources/V1 / Resources/V2
  directories.
- Error response shape: { message, errors? }; consistent across all
  endpoints.
- Status codes explicit and correct (200/201/202/204/400/401/403/404/
  409/422/429).
- OpenAPI spec generated by Scribe / L5-Swagger; CI fails on drift.
- Streaming downloads via Response::streamDownload.

## Authorization
- Every model with access control has a Policy in app/Policies/...
- Standard ability names + custom abilities (cancel, refund, ...).
- Policies return bool or Response (with deny messages).
- before() short-circuits for platform admins.
- $this->authorize() in controllers; can:ability,model middleware on
  routes; FormRequest::authorize() for early rejection.
- Non-model abilities as Gates registered in AppServiceProvider.
- Scattered if-conditionals (is_admin, user_id checks) forbidden.
- Multi-tenant: Policy or global scope checks tenant_id first.
- Spatie/laravel-permission for any non-trivial RBAC.
- Sanctum/Passport token abilities checked via tokenCan() inside
  Policies.

## Testing
- Pest is the default for new tests; PHPUnit migrated on touch.
- Feature tests use RefreshDatabase; SQLite :memory: or dedicated
  Postgres for Postgres-only features.
- Factories define canonical shape; named states for variations.
- Bus::fake / Queue::fake / Mail::fake / Notification::fake /
  Event::fake / Http::fake / Storage::fake mandatory before code
  that dispatches.
- assertDatabaseHas / assertModelExists / assertSoftDeleted for DB
  state.
- assertJsonPath for pinpoint; assertJsonStructure for shape;
  snapshots for large bodies.
- Sanctum::actingAs for token + abilities.
- Architecture tests via arch() (controllers thin, actions invokable,
  no facades in Domain).
- Datasets for parameterized cases.
- php artisan test --parallel; tests isolation-safe.
- CI: matrix on PHP 8.3/8.4, Postgres+Redis services, --parallel
  --coverage 80% min, Larastan level 8.
Enter fullscreen mode Exit fullscreen mode

End-to-End Example: Posting a Comment On A Blog Post

Without rules: fat controller, mass-assigned, sync mail, raw model in response, no test.

class CommentController extends Controller
{
    public function store(Request $request, $postId)
    {
        $request->validate(['body' => 'required|string']);
        $post = Post::findOrFail($postId);
        if (!auth()->check()) abort(401);
        $comment = Comment::create([
            'post_id' => $post->id,
            'user_id' => auth()->id(),
            'body'    => $request->body,
        ]);
        Mail::to($post->author->email)->send(new NewCommentMail($comment));
        return response()->json($comment->load('user'));
    }
}
Enter fullscreen mode Exit fullscreen mode

With rules: invokable controller, Form Request, Action, queued mail, Resource, Policy, Pest tests.

// routes/api.php
Route::middleware(['auth:sanctum', 'can:comment,post'])
    ->post('/posts/{post}/comments', PostCommentController::class)
    ->name('posts.comments.store');

// app/Http/Controllers/Comments/PostCommentController.php
namespace App\Http\Controllers\Comments;

use App\Actions\Comments\PostComment;
use App\Http\Requests\Comments\PostCommentRequest;
use App\Http\Resources\V1\CommentResource;
use App\Models\Post;

final class PostCommentController
{
    public function __invoke(
        PostCommentRequest $request,
        Post $post,
        PostComment $action,
    ): CommentResource {
        $comment = $action($post, $request->toDto());
        return new CommentResource($comment);
    }
}

// app/Http/Requests/Comments/PostCommentRequest.php
namespace App\Http\Requests\Comments;

use App\DataObjects\CommentInput;
use App\Models\Post;
use Illuminate\Foundation\Http\FormRequest;

final class PostCommentRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()?->can('comment', $this->route('post')) ?? false;
    }

    public function rules(): array
    {
        return [
            'body'      => ['required', 'string', 'min:2', 'max:2000'],
            'parent_id' => ['nullable', 'integer', 'exists:comments,id'],
        ];
    }

    public function toDto(): CommentInput
    {
        $v = $this->validated();
        return new CommentInput(
            userId:   $this->user()->id,
            body:     trim($v['body']),
            parentId: $v['parent_id'] ?? null,
        );
    }
}

// app/DataObjects/CommentInput.php
namespace App\DataObjects;

final readonly class CommentInput
{
    public function __construct(
        public int $userId,
        public string $body,
        public ?int $parentId,
    ) {}
}

// app/Actions/Comments/PostComment.php
namespace App\Actions\Comments;

use App\DataObjects\CommentInput;
use App\Events\CommentPosted;
use App\Models\Comment;
use App\Models\Post;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\Facades\DB;

final readonly class PostComment
{
    public function __construct(private Dispatcher $events) {}

    public function __invoke(Post $post, CommentInput $input): Comment
    {
        return DB::transaction(function () use ($post, $input) {
            $comment = $post->comments()->create([
                'user_id'   => $input->userId,
                'body'      => $input->body,
                'parent_id' => $input->parentId,
            ]);
            $this->events->dispatch(new CommentPosted($comment));
            return $comment->load('user:id,name');
        });
    }
}

// app/Listeners/NotifyAuthorOfNewComment.php
namespace App\Listeners;

use App\Events\CommentPosted;
use App\Jobs\SendNewCommentEmail;

final class NotifyAuthorOfNewComment
{
    public function handle(CommentPosted $event): void
    {
        if ($event->comment->user_id === $event->comment->post->user_id) {
            return; // don't notify the author of their own comment
        }
        SendNewCommentEmail::dispatch($event->comment);
    }
}

// app/Jobs/SendNewCommentEmail.php
namespace App\Jobs;

use App\Mail\NewCommentMail;
use App\Models\Comment;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;

final class SendNewCommentEmail implements ShouldQueue, ShouldBeUnique
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 5;
    public int $timeout = 30;
    public int $uniqueFor = 600;

    public function __construct(public readonly Comment $comment) {}

    public function uniqueId(): string
    {
        return "new-comment-mail:{$this->comment->id}";
    }

    public function backoff(): array
    {
        return [10, 30, 60, 120, 300];
    }

    public function handle(): void
    {
        $this->comment->loadMissing('post.author');

        Mail::to($this->comment->post->author->email)
            ->queue(new NewCommentMail($this->comment));
    }
}

// app/Policies/PostPolicy.php
final class PostPolicy
{
    public function comment(User $user, Post $post): Response
    {
        if ($post->comments_locked) return Response::deny('Comments are closed.');
        if ($user->is_banned) return Response::deny('Your account is suspended.');
        return Response::allow();
    }
}

// app/Http/Resources/V1/CommentResource.php
final class CommentResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'         => $this->id,
            'body'       => $this->body,
            'parent_id'  => $this->parent_id,
            'created_at' => $this->created_at->toIso8601String(),
            'author'     => [
                'id'   => $this->user->id,
                'name' => $this->user->name,
            ],
        ];
    }
}

// tests/Feature/Comments/PostCommentTest.php
use App\Jobs\SendNewCommentEmail;
use App\Models\Post;
use App\Models\User;
use Illuminate\Support\Facades\Bus;

beforeEach(fn () => Bus::fake());

it('lets an authenticated user post a comment on an open post', function () {
    $user = User::factory()->create();
    $post = Post::factory()->create();

    $response = $this->actingAs($user)->postJson("/api/posts/{$post->id}/comments", [
        'body' => 'Great post!',
    ]);

    $response->assertCreated()->assertJsonPath('data.body', 'Great post!');
    $this->assertDatabaseHas('comments', ['post_id' => $post->id, 'user_id' => $user->id]);
    Bus::assertDispatched(SendNewCommentEmail::class);
});

it('rejects comments on locked posts', function () {
    $user = User::factory()->create();
    $post = Post::factory()->create(['comments_locked' => true]);

    $this->actingAs($user)
        ->postJson("/api/posts/{$post->id}/comments", ['body' => 'Hi'])
        ->assertForbidden();

    Bus::assertNotDispatched(SendNewCommentEmail::class);
});

it('does not notify the author of their own comment', function () {
    $author = User::factory()->create();
    $post   = Post::factory()->for($author, 'author')->create();

    $this->actingAs($author)
        ->postJson("/api/posts/{$post->id}/comments", ['body' => 'self'])
        ->assertCreated();

    // Listener returns early; no job dispatched
    Bus::assertNotDispatched(SendNewCommentEmail::class);
});
Enter fullscreen mode Exit fullscreen mode

Thin controller, typed DTO, single Action, queued mailer with retries, policy-gated, resource-shaped response, Pest tests asserting both the success path and the locked-post / self-comment edge cases.


Get the Full Pack

These eight rules cover the Laravel patterns where AI assistants consistently reach for the wrong idiom. Drop them into .cursorrules and the next prompt you write will look different — fillable-explicit, action-driven, FormRequest-validated, eager-loaded, queue-disciplined, resource-wrapped, policy-gated, Pest-tested Laravel, without having to re-prompt.

If you want the expanded pack — these eight plus rules for Livewire 3 component architecture (full-page components, computed properties, event dispatch, lifecycle hooks), Filament 3 admin panels (Resource definitions, Form/Table builders, custom actions, RelationManagers), Inertia.js with Vue/React adapters (typed page props, shared data, partial reloads), advanced Pest patterns (architecture tests, datasets, custom expectations, parallel-safe seeding), Octane deployment (Swoole/RoadRunner state hygiene, container singletons, request lifecycle pitfalls), broadcasting with Reverb (channel auth, presence channels, private channel scoping), queue tuning (Horizon balancing, supervisor configs, memory limits, --max-jobs rotation), multi-tenancy (Tenancy for Laravel, single- vs multi-database, tenant-scoped Policies, queue isolation), Sanctum & Passport token strategies, Eloquent performance (chunkById vs cursor vs lazy benchmarks, raw query escape hatches with parameter binding), Volt single-file components, FluxUI / Alpine integrations, the deploy patterns I use for Laravel on Forge + Vapor, and the Larastan + Rector + Pint configuration that catches AI mistakes before code review — it is bundled in Cursor Rules Pack v2 ($27, one payment, lifetime updates). Drop it in your repo, stop fighting your AI, ship Laravel you would actually merge.

Source: dev.to

arrow_back Back to Tutorials