MCP Agents Guide: Laravel & PHPStan Rules

A field manual for autonomous or human agents working in Laravel 13 repos using PHPStan and Larastan

A field manual for autonomous or human agents working in Laravel 13 + PHP 8.3+ repos using PHPStan 2.x and Larastan 3.x. Copy this into your project’s docs/agents/phpstan.md and adapt as needed.

This guide assumes:

  • Laravel ^13.0 (released March 2026, PHP 8.3 minimum, PHP 8.4 recommended)
  • PHPStan ^2.0
  • Larastan ^3.0 (the package moved to the larastan/larastan namespace; nunomaduro/larastan is the old name)

1) Mission & Scope

  • Mission: Keep code safe, boring, and predictable by enforcing static analysis with PHPStan + Larastan at a consistent level across services.
  • Scope: All PHP source and tests, with framework-specific add-ons (Larastan for Laravel, Carbon extension for date types).
  • Success Criteria:
    1. No increase in the PHPStan baseline.
    2. New/changed lines meet the target level.
    3. Deprecated/unsafe APIs trend to zero.

2) Defaults & Conventions

  • Levels: PHPStan 2.x exposes levels 0–10 plus the max alias (which always points to the current highest level — currently 10). Use max for new packages, 7–8 for legacy apps, and ratchet up by +1 each sprint.
  • Bleeding edge: Enable in CI for canary runs to surface upcoming breakages; optional locally.
  • Paths: Analyse app/, src/, database/factories, database/seeders, and tests/. Exclude generated code.
  • Cache: Use the PHPStan result cache (default sys_get_temp_dir()); never commit it.
  • Parallel: PHPStan runs in parallel by default — leave it on in CI.

3) How to Run

# local dev (fast, max level)
vendor/bin/phpstan analyse --level=max --configuration=phpstan.neon --memory-limit=1G

# generate (or refresh) the baseline
vendor/bin/phpstan analyse --configuration=phpstan.neon --generate-baseline

# CI strict (fail on new errors, GitHub annotations)
vendor/bin/phpstan analyse --configuration=phpstan.neon --error-format=github --no-progress

For very large baselines, generate to a .php file instead of .neon — PHPStan parses it faster:

vendor/bin/phpstan analyse --generate-baseline=phpstan-baseline.php

4) Config Structure (example)

# phpstan.neon
includes:
  - vendor/larastan/larastan/extension.neon
  - vendor/nesbot/carbon/extension.neon
  - phpstan-baseline.neon

parameters:
  level: max

  paths:
    - app
    - database/factories
    - database/seeders
    - tests

  excludePaths:
    - app/**/Generated/*
    - bootstrap/cache/*

  # Larastan-specific
  checkModelProperties: true
  checkModelAppends: true
  parseModelCastsMethod: true
  checkConfigTypes: true
  generalizeEnvReturnType: false

  # PHPStan core
  checkUninitializedProperties: true
  checkMissingCallableSignature: true
  checkTooWideReturnTypesInProtectedAndPublic: true
  reportUnmatchedIgnoredErrors: true
  treatPhpDocTypesAsCertain: true

  universalObjectCratesClasses:
    - stdClass

  bootstrapFiles:
    - vendor/autoload.php

  ignoreErrors:
    # Always comment *why* and a tracking ticket
    # ENG-1234: legacy query-builder calls in feature tests, remove by 2026-Q3
    - message: '#Call to an undefined method [^\s]+::whereJsonContains\(\)#'
      paths:
        - tests/Feature/*
      count: 5

Notes on parameters that no longer exist:

  • checkMissingIterableValueType and checkGenericClassInNonGenericObjectType were removed in PHPStan 2.0 — their behaviour is folded into the rule levels and can no longer be configured. If your config still references them, delete those lines.
  • autoload_files was renamed to bootstrapFiles (the old key is no longer documented; prefer bootstrapFiles).

5) Rule Categories & Expectations

5.1 Types & Signatures

  • Return and parameter types must be declared on every method and function.
  • Generics: Prefer concrete type parameters for collections (Collection<int, User>). Avoid mixed — level 10 flags implicit mixed.
  • Nullability: Avoid nullable where business rules forbid it; use Value Objects instead of ?string.
  • Callable shapes must be specified (callable(int): string); don’t use untyped callbacks.

5.2 Properties & Initialization

  • Default to readonly + private; initialise in the constructor.
  • Enable checkUninitializedProperties to catch missed assignments.

5.3 Exceptions & Control Flow

  • Throw precise exceptions; document with @throws and keep catch blocks specific.
  • Avoid using exceptions for normal control flow. Prefer Result/Either objects where suited.

5.4 Arrays vs Objects

  • Replace associative arrays with DTOs/Value Objects. If arrays are unavoidable, document shapes with array{id: int, name: string}.

5.5 Inheritance & Visibility

  • Prefer composition over inheritance. Don’t widen visibility or return types in children.
  • Mark internal methods with @internal and keep them small and testable.

5.6 Deprecations & Unsafe APIs

  • Forbid new uses of deprecated functions/classes; allow only in migration shims.
  • Ban dynamic property creation (PHP 8.2+ already deprecates it); declare real properties.

5.7 Framework-specific

  • Validate container bindings and facades via the Larastan extension.
  • Type every Eloquent relationship with generics — see §10 for the full set.

6) Error Triage Workflow (Agent SOP)

  1. Reproduce locally with the same flags as CI.
  2. Classify the error:
    • Bug (real type/logic issue)
    • Design smell (over-broad types, hidden nulls)
    • Tooling false positive (rare; prefer code changes first)
  3. Fix in priority order:
    1. Add/strengthen native types
    2. Refactor to DTO/VO
    3. Tighten generics
    4. Adjust PHPDoc
    5. Last resort: scoped ignoreErrors
  4. Test: add/adjust unit + feature tests proving the contract.
  5. Document: if ignoring, add a comment with a ticket and a removal date.

7) Suppression Policy

  • Defaults: ignoreErrors allowed only with message regex + path + count and a commented reason.
  • Bans: global ignoreErrors without path scoping; flipping treatPhpDocTypesAsCertain: false to mask bad annotations.
  • Expiry: Each ignore carries a ticket; review weekly. reportUnmatchedIgnoredErrors: true makes CI fail when a suppression no longer matches.

8) Baseline Policy

  • Maintain phpstan-baseline.neon (or .php for very large baselines) for legacy debt only.
  • No growth rule: CI blocks PRs that increase the baseline count.
  • Shrink rule: When fixing code, regenerate the baseline so resolved entries are removed.
  • Regenerate with:
vendor/bin/phpstan analyse --configuration=phpstan.neon --generate-baseline

9) CI Gate (template)

# .github/workflows/phpstan.yml
name: PHPStan
on: [pull_request]
jobs:
  analyse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          coverage: none
          extensions: mbstring, intl, json, pdo, pdo_sqlite
      - uses: ramsey/composer-install@v3
      - run: vendor/bin/phpstan analyse --configuration=phpstan.neon --error-format=github --no-progress

10) Eloquent Relationships — Larastan 3 Generic Signatures

Larastan 3 (matching Laravel 11.15+ / 12.x / 13.x) requires two template parameters on most relationship classes: the related model and the declaring model (use $this so subclasses are inferred correctly). Through-relationships take a third intermediate model parameter.

Always import the relationship class explicitly and put the generic in a @return docblock — native return types alone can’t carry the generic.

10.1 HasOne<TRelatedModel, TDeclaringModel>

use App\Models\Phone;
use Illuminate\Database\Eloquent\Relations\HasOne;

/** @return HasOne<Phone, $this> */
public function phone(): HasOne
{
    return $this->hasOne(Phone::class);
}

10.2 HasMany<TRelatedModel, TDeclaringModel>

use App\Models\Comment;
use Illuminate\Database\Eloquent\Relations\HasMany;

/** @return HasMany<Comment, $this> */
public function comments(): HasMany
{
    return $this->hasMany(Comment::class);
}

10.3 BelongsTo<TRelatedModel, TDeclaringModel>

use App\Models\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
    return $this->belongsTo(User::class);
}

10.4 BelongsToMany<TRelatedModel, TDeclaringModel>

BelongsToMany also accepts an optional third (TPivotModel) and fourth (TAccessor) parameter, both with sensible defaults. Specify the pivot only when you’ve extended Pivot.

use App\Models\Role;
use App\Models\Pivots\RoleUser;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

/** @return BelongsToMany<Role, $this> */
public function roles(): BelongsToMany
{
    return $this->belongsToMany(Role::class);
}

/** @return BelongsToMany<Role, $this, RoleUser> */
public function rolesWithCustomPivot(): BelongsToMany
{
    return $this->belongsToMany(Role::class)->using(RoleUser::class);
}

10.5 HasOneThrough<TRelatedModel, TIntermediateModel, TDeclaringModel>

use App\Models\Car;
use App\Models\Owner;
use Illuminate\Database\Eloquent\Relations\HasOneThrough;

/** @return HasOneThrough<Owner, Car, $this> */
public function carOwner(): HasOneThrough
{
    return $this->hasOneThrough(Owner::class, Car::class);
}

10.6 HasManyThrough<TRelatedModel, TIntermediateModel, TDeclaringModel>

use App\Models\Deployment;
use App\Models\Environment;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;

/** @return HasManyThrough<Deployment, Environment, $this> */
public function deployments(): HasManyThrough
{
    return $this->hasManyThrough(Deployment::class, Environment::class);
}

10.7 MorphOne<TRelatedModel, TDeclaringModel>

use App\Models\Image;
use Illuminate\Database\Eloquent\Relations\MorphOne;

/** @return MorphOne<Image, $this> */
public function image(): MorphOne
{
    return $this->morphOne(Image::class, 'imageable');
}

10.8 MorphMany<TRelatedModel, TDeclaringModel>

use App\Models\Comment;
use Illuminate\Database\Eloquent\Relations\MorphMany;

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

10.9 MorphTo<TRelatedModel, TDeclaringModel>

The related side of a MorphTo is intentionally a union — typehint the broadest base class your morph map allows, or Model if it’s truly heterogeneous.

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

/** @return MorphTo<Model, $this> */
public function imageable(): MorphTo
{
    return $this->morphTo();
}

10.10 MorphToMany<TRelatedModel, TDeclaringModel>

use App\Models\Tag;
use Illuminate\Database\Eloquent\Relations\MorphToMany;

/** @return MorphToMany<Tag, $this> */
public function tags(): MorphToMany
{
    return $this->morphToMany(Tag::class, 'taggable');
}

10.11 MorphedByMany (uses MorphToMany as its return type)

use App\Models\Post;
use Illuminate\Database\Eloquent\Relations\MorphToMany;

/** @return MorphToMany<Post, $this> */
public function posts(): MorphToMany
{
    return $this->morphedByMany(Post::class, 'taggable');
}

10.12 Eloquent collections

When typing collection returns from queries or ->get(), use the framework’s typed collection:

use App\Models\Order;
use Illuminate\Database\Eloquent\Collection;

/** @return Collection<int, Order> */
public function recentOrders(): Collection
{
    return $this->orders()->latest()->take(10)->get();
}

11) Larastan-Specific Config Parameters

These are the documented Larastan 3.x parameters (all sit at the top level of parameters:, not under a larastan: namespace):

ParameterDefaultPurpose
checkModelPropertiesfalseValidate dynamic property access against migrations & $casts. Strongly recommended.
checkModelAppendstrueValidate every entry in $appends has a matching accessor.
parseModelCastsMethodfalseRead the new casts() method body to infer attribute types. Enable for Laravel 11+.
checkConfigTypesfalseValidate return types of config() helper calls against config/*.php.
generalizeEnvReturnTypefalseWiden env() returns from inferred types to string|null (useful in some legacy setups).
databaseMigrationsPathExtra paths to scan for migrations (array of strings).
squashedMigrationsPathPaths to schema dumps so Larastan can read squashed migrations.
enableMigrationCachefalseCache parsed migrations between runs.
disableMigrationScanfalseStop scanning migrations entirely (use schema dumps instead).
disableSchemaScanfalseStop scanning Schema::create/Schema::table calls.
noUnnecessaryCollectionCalltrueFlag ->count() / ->first() calls that should be DB-side.
configDirectoriesExtra directories containing config/*.php files.

Older guides reference parameters like larastan.container_path, larastan.model_properties_scan_depth, larastan.concise, checkMissingMorphToParameters, and checkPhpDocMissingReturnType. These are not part of Larastan 3.x — remove them.

12) Common Pitfalls & Fix Patterns

  • mixed everywhere → introduce interfaces + generics.
  • Array payloads → shape types (array{...}) or DTO classes.
  • Untyped factories → static named constructors returning concrete types.
  • Facade / static calls → delegate into typed services; Larastan will still type-check the facade call.
  • Magic dynamic properties → define real properties; rely on checkModelProperties for Eloquent attributes.
  • Untyped relationships → add the @return docblock from §10 above — native return types alone don’t carry the generic.

13) Agent Playbooks

  • New module

    1. Start at level: max with an empty baseline.
    2. Add native types, DTOs, and Result objects from the first commit.
    3. Type every relationship with the §10 patterns.
  • Legacy increment

    • Raise the level one notch after the baseline shrinks by ≥15%.
    • Prefer migrating a single bounded context to level: max over flattening everything to level: 7.
  • PR checklist

    • Native types on all new/changed public APIs.
    • Generic return docblocks on all relationship methods.
    • No new deprecations.
    • No baseline growth.
    • Tests cover typed contracts.

14) FAQ

  • Why did PHPStan flag my array? It can’t infer the shape — convert to a DTO or add array{...}.
  • Can I ignore false positives? Only after exhausting code fixes, with a scoped ignoreErrors + ticket + removal date.
  • What level should we use? max for new code; otherwise 7–8 and climb.
  • How do I type a MorphTo that points anywhere? Use MorphTo<Model, $this> and narrow with instanceof at call sites — or, better, define a base class / interface that every morph target extends.
  • Why two template params on HasMany? Larastan 3 needs the declaring model so static/$this resolution works correctly in subclasses.

15) Glossary

  • DTO — Data Transfer Object: typed structure instead of arrays.
  • VO — Value Object: immutable, validated type.
  • Baseline — Snapshot of known issues; must shrink over time.
  • Suppression — Scoped, justified ignoreErrors entry with expiry.
  • Bleeding edge — Opt-in PHPStan flag that enables rules slated for the next major release.

16) Keep it Green

  • Treat new PHPStan errors as regressions.
  • Prefer refactoring code over tweaking config.
  • Small, steady reductions beat big-bang rewrites.