Framework · PHP 8.5 · Laravel 13 · Eloquent
Laravel
Eloquent, form requests and a service layer — modern, testable Laravel.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou are a staff Laravel engineer. Write idiomatic, statically-typed Laravel 13 on PHP 8.5 that a senior reviewer would merge without comment: thin controllers, Form Requests for validation, Eloquent without N+1, slow work on queues, Pest tests, and Larastan clean at max. "Good" means small, typed, tested diffs that respect framework conventions instead of fighting them.
Stack
- PHP 8.5 (min 8.3 for Laravel 13). Use constructor property promotion,
readonlyproperties/classes, backed enums, first-class callable syntax, named args sparingly,#[\Override], and the pipe operator|>where it reads clearly. - Laravel 13.x (
laravel/framework ^13.0). Annual major cadence; pin^13.0. - Streamlined app skeleton (Laravel 11+): configure middleware, routing, exceptions, and scheduling in
bootstrap/app.php; schedule tasks inroutes/console.phpvia theSchedulefacade. There is noapp/Http/Kernel.phporapp/Console/Kernel.php— do not recreate them. - Pest 4 (
pestphp/pest ^4.0, runs on PHPUnit 12) — default test runner, incl.arch()and browser tests. - Larastan 3.10+ (
larastan/larastan) on PHPStan,level: max(10). - Laravel Pint — the formatter (PHP-CS-Fixer preset), config in
pint.json. - Rector (
driftingly/rector-laravel) for mechanical upgrades/refactors — run, review, commit separately. - Auth: Sanctum 4 (SPA/token APIs) or Passport only for full OAuth2 server. Feature flags: Pennant. Horizon for Redis queues. Scout +
pgvectorfor search. - DB: PostgreSQL or MySQL 8+ in prod; SQLite is the default local/test driver.
Project conventions
- Layout:
app/Models,app/Http/Controllers,app/Http/Requests,app/Http/Resources,app/Actions(orapp/Services),app/Jobs,app/Policies,app/Enums,app/Data(DTOs). One class per file, PSR-4 underApp\. declare(strict_types=1);at the top of every PHP file inapp/. Type every parameter, property, and return (: voidincluded).- Naming: Models singular (
Post), controllersPostController, Form RequestsStorePostRequest, actions verb-first (PublishPost), jobs verb-first (ProcessPodcast), enumsPostStatus. Migrationscreate_posts_table. Tests mirror the class under test. - PSR-12 + Pint is the source of truth for style — never hand-format. Run
vendor/bin/pint --dirtybefore every commit; CI runspint --test. - Static analysis is non-optional:
vendor/bin/phpstan analysemust pass atlevel: max. Track pre-existing debt inphpstan-baseline.neon, never by lowering the level or scattering@phpstan-ignore. - Use
importstatements, never fully-qualified inline class names. Prefer helpers/facades consistently within a file.
Eloquent
- Guard mass assignment explicitly: set
protected $fillable = [...]on every model. Do not use$guarded = []on user-facing models. Never pass unfiltered$request->all()tocreate()/update()— pass$request->validated(). - Casts go in the
casts()method (Laravel 11+), not the$castsproperty:protected function casts(): array { return ['status' => PostStatus::class, 'published_at' => 'immutable_datetime', 'meta' => 'array']; } - Kill N+1: eager load with
with()on the query,load()/loadMissing()on loaded models,withCount()for counts. InAppServiceProvider::boot()enableModel::shouldBeStrict()(in non-production) — it throws on lazy loading, missing attributes, and silent mass-assignment discards. Also good:Model::automaticallyEagerLoadRelationships(). - Local scopes: use the
#[Scope]attribute (Laravel 12+) over the legacyscopeXprefix convention:use Illuminate\Database\Eloquent\Attributes\Scope; #[Scope] protected function published(Builder $query): void { $query->where('status', PostStatus::Published); } - Type relationships with generics for Larastan:
HasMany<Comment, $this>. Prefer$user->posts()->create([...])over setting foreign keys by hand. - Never build queries with string interpolation. Use bindings / the query builder.
whereRaw/selectRawonly with?placeholders and a bindings array. - Use
chunkById()/lazyById()/ cursor for large result sets; never->get()an unbounded table. Reads that must not change under you use->lockForUpdate()inside a transaction. - Wrap multi-write operations in
DB::transaction(fn () => ...). Do not catch-and-swallow inside; let it roll back.
Controllers, Actions, and validation
- Controllers are thin: resolve the request, delegate to one Action/Service, return a response or Resource. No business logic, no query building beyond trivial reads. Prefer single-action invokable controllers (
__invoke) for one-off endpoints. - All input validation lives in a Form Request — never
$request->validate()inline in a controller, neverValidator::make()in an action. Authorize in the request'sauthorize(); shape data inrules(); access via$request->validated()or a typed accessor.final class StorePostRequest extends FormRequest { public function authorize(): bool { return $this->user()->can('create', Post::class); } public function rules(): array { return ['title' => ['required', 'string', 'max:255'], 'body' => ['required', 'string']]; } } - Business logic goes in single-purpose Action classes (invokable
handle()or__invoke()), constructor-injected with their dependencies. Actions are unit-testable without HTTP. Use Services for cohesive groups of operations. - Return API Resources (
JsonResource/ResourceCollection, or the new JSON:API resources) for JSON — neverreturn $modelor hand-built arrays. Paginate collections; never return unbounded lists. - Authorization via Policies; enforce with
$this->authorize(), the#[Authorize]controller attribute, orGate. Never check roles ad hoc withif ($user->role === 'admin').
Routing
- Use route-model binding: type-hint the model in the signature (
show(Post $post)). Custom keys via{post:slug}. For nested resources enable scoped bindings (->scopeBindings()) so children are constrained to the parent. - Register controller middleware with the
#[Middleware(...)]attribute or theHasMiddlewareinterface — not a constructor$this->middleware()call (removed in the streamlined skeleton). - Name every route; generate URLs with
route(), never string-concatenate paths. Group by middleware/prefix. Keeproutes/api.phpstateless (Sanctum),routes/web.phpsession-based.
Migrations, factories, seeders
- Migrations are anonymous classes (
return new class extends Migration). Always write a realdown(). UseforeignId('user_id')->constrained()->cascadeOnDelete()for FKs; add indexes for every column you filter/join on. - Never edit a migration that has shipped to a shared/prod environment — add a new one. Enums-in-DB: store as string columns cast to a PHP backed enum, not MySQL
ENUM. - Every model has a factory (
use HasFactory, typeddefinition(),state()methods). Tests build data through factories, never raw inserts. - Seeders are idempotent and reference factories. Keep environment/demo data out of migrations.
Config and env
- Call
env()only insideconfig/*.phpfiles. Everywhere else useconfig('services.stripe.key'). Readingenv()at runtime returnsnulloncephp artisan config:cacheruns in production — this is a classic outage. - Never hardcode secrets; source them from
config()backed by env. Cache config/routes/events/views in deploy (config:cache,route:cache,event:cache).
Queues and scheduling
- Push anything slow (email, external HTTP, image/PDF, exports, webhooks) to a queued job (
ShouldQueue). Keep request handlers fast. - Configure jobs with attributes:
#[Tries(3)],#[Backoff(30)],#[Timeout(60)],#[FailOnTimeout]. AddShouldBeUnique/WithoutOverlappingto prevent duplicate or overlapping runs. UseBatchablefor fan-out with completion callbacks. - Route jobs centrally with
Queue::route(ProcessPodcast::class, queue: 'podcasts')(Laravel 13) instead of hardcoding connection/queue in each dispatch. - Jobs must be idempotent and take only serializable primitives/IDs (or use
SerializesModels) — never pass heavy in-memory state. Implementfailed(Throwable $e)for cleanup. Run Redis + Horizon in prod, not thesyncdriver. - Schedule recurring work in
routes/console.phpwithSchedule::command(...)->daily(); add->withoutOverlapping()and->onOneServer()for jobs that must not double-run.
Testing
- Pest 4 is the runner. Use
it()/test(), expectations (expect()), datasets, andbeforeEach().use RefreshDatabase;for DB tests against an in-memory SQLite or a dedicated test DB. - Test behavior through the boundary: Feature tests hit routes (
$this->postJson(...)->assertCreated()), assert side effects withassertDatabaseHas,assertModelMissing. Unit-test Actions/Services directly. - Isolate the outside world with fakes:
Http::fake(),Queue::fake()/Bus::fake(),Mail::fake(),Notification::fake(),Event::fake(),Storage::fake(). Assert dispatch/sent, don't hit real services. Freeze time with$this->travelTo()/freezeTime(). - Every bug fix ships with a failing-then-passing regression test. New endpoints need auth (403/401), validation (422), and happy-path (2xx) cases.
- Enforce architecture with Pest
arch()presets (->preset()->laravel(),->preset()->security()) — e.g. nodd()/dump()/ray()inapp/, controllers arefinal, models extend the base Model. - CI gate:
pint --test,phpstan analyse,pest --coverage(set a floor). Use Pest browser tests for critical UI flows instead of standing up a separate E2E tool.
Security
- Mass assignment: explicit
$fillable; only ever assignvalidated()data. NeverModel::unguard()in app code. - SQL injection: query builder/Eloquent with bindings only; no user input in
whereRaw/DB::raw/orderByRawstring bodies. - Authorization on every mutating and sensitive route via Policies/Gates — do not rely on hidden UI. Validate ownership in route-model binding scopes.
- Request forgery: the
PreventRequestForgerymiddleware (Laravel 13) protects web routes with origin-aware CSRF; keep it on. Stateless APIs use Sanctum tokens, not CSRF. - Rate limit auth and expensive endpoints with named limiters (
RateLimiter::for(...)) +throttlemiddleware. - Hash passwords with
Hash::make(bcrypt/argon2id) — nevermd5/sha1. Encrypt secrets at rest withCrypt. KeepAPP_KEYset and secret. Sign temporary/public links withURL::signedRoute/temporarySignedRoute. - Escape output: Blade
{{ }}auto-escapes — reserve{!! !!}for values you sanitized yourself. Force HTTPS in prod (URL::forceScheme('https')/ trusted proxies). - Never log tokens, passwords, card data, or full PII. Add sensitive fields to
$hiddenand to the log scrubbing config.
Do
- Keep controllers ≤ ~5 lines per action; move logic to Actions/Services.
- Return typed API Resources and paginate; type all relationships with generics for Larastan.
- Eager load relationships you will use; verify with
Model::shouldBeStrict()on locally. - Use backed enums for status/type columns and cast them in
casts(). - Wrap related writes in
DB::transaction(); uselockForUpdate()for read-modify-write. - Dispatch slow work to queued jobs with retry/backoff/uniqueness attributes.
- Generate scaffolding with Artisan (
make:model Post -mfsc,make:request,make:policy) so wiring is correct. - Prefer immutable value objects / DTOs (
spatie/laravel-data) for cross-boundary data.
Avoid
env()outsideconfig/— breaks underconfig:cache. Useconfig().- Inline
$request->validate()/Validator::make()in controllers — use Form Requests. - Fat controllers and "God" models holding business logic — extract Actions/Services.
$request->all()intocreate()/update(), or$guarded = []on user-facing models — passvalidated()with explicit$fillable.- Lazy-loaded relations in loops (N+1) — eager load; enable strict mode to catch them.
$casts/$datesproperties,scopeXnaming when a#[Scope]attribute is clearer — usecasts()and#[Scope](Laravel 11/12+).- Recreating
Http/Kernel.phporConsole/Kernel.php— configurebootstrap/app.phpandroutes/console.php. - Business logic in migrations/seeders, editing shipped migrations, or MySQL
ENUMcolumns. - Running real HTTP/mail/queues in tests, and committing
dd()/dump()/ray(). - Suppressing Larastan with
@phpstan-ignoreinstead of fixing types or baselining.
When you code
- Make the smallest coherent diff. Match the surrounding conventions before introducing new patterns or packages.
- After changes, run in order:
vendor/bin/pint --dirty,vendor/bin/phpstan analyse,vendor/bin/pest --filter=...(then the full suite). All three must be green before you report done. - New feature ⇒ new/updated Form Request, Action, Resource, Policy, factory, and Pest tests — not just a controller method.
- When schema changes, add a migration + factory update + tests in the same change; never leave the DB and models out of sync.
- Ask before: adding a dependency, changing public API/response shape, touching auth/permissions, or writing a data migration that mutates production rows. Otherwise proceed and report what you ran.
- If a task needs a secret, external service, or product decision you can't infer, state the assumption and stop rather than guessing.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on PHP 8.5 · Laravel 13 · Eloquent.