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.mdand 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 thelarastan/larastannamespace;nunomaduro/larastanis 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:
- No increase in the PHPStan baseline.
- New/changed lines meet the target level.
- Deprecated/unsafe APIs trend to zero.
2) Defaults & Conventions
- Levels: PHPStan 2.x exposes levels 0–10 plus the
maxalias (which always points to the current highest level — currently10). Usemaxfor new packages,7–8for 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, andtests/. 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:
checkMissingIterableValueTypeandcheckGenericClassInNonGenericObjectTypewere 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_fileswas renamed tobootstrapFiles(the old key is no longer documented; preferbootstrapFiles).
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>). Avoidmixed— 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
checkUninitializedPropertiesto catch missed assignments.
5.3 Exceptions & Control Flow
- Throw precise exceptions; document with
@throwsand keepcatchblocks 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
@internaland 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)
- Reproduce locally with the same flags as CI.
- Classify the error:
- Bug (real type/logic issue)
- Design smell (over-broad types, hidden nulls)
- Tooling false positive (rare; prefer code changes first)
- Fix in priority order:
- Add/strengthen native types
- Refactor to DTO/VO
- Tighten generics
- Adjust PHPDoc
- Last resort: scoped
ignoreErrors
- Test: add/adjust unit + feature tests proving the contract.
- Document: if ignoring, add a comment with a ticket and a removal date.
7) Suppression Policy
- Defaults:
ignoreErrorsallowed only with message regex + path + count and a commented reason. - Bans: global
ignoreErrorswithout path scoping; flippingtreatPhpDocTypesAsCertain: falseto mask bad annotations. - Expiry: Each ignore carries a ticket; review weekly.
reportUnmatchedIgnoredErrors: truemakes CI fail when a suppression no longer matches.
8) Baseline Policy
- Maintain
phpstan-baseline.neon(or.phpfor 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):
| Parameter | Default | Purpose |
|---|---|---|
checkModelProperties | false | Validate dynamic property access against migrations & $casts. Strongly recommended. |
checkModelAppends | true | Validate every entry in $appends has a matching accessor. |
parseModelCastsMethod | false | Read the new casts() method body to infer attribute types. Enable for Laravel 11+. |
checkConfigTypes | false | Validate return types of config() helper calls against config/*.php. |
generalizeEnvReturnType | false | Widen env() returns from inferred types to string|null (useful in some legacy setups). |
databaseMigrationsPath | – | Extra paths to scan for migrations (array of strings). |
squashedMigrationsPath | – | Paths to schema dumps so Larastan can read squashed migrations. |
enableMigrationCache | false | Cache parsed migrations between runs. |
disableMigrationScan | false | Stop scanning migrations entirely (use schema dumps instead). |
disableSchemaScan | false | Stop scanning Schema::create/Schema::table calls. |
noUnnecessaryCollectionCall | true | Flag ->count() / ->first() calls that should be DB-side. |
configDirectories | – | Extra directories containing config/*.php files. |
Older guides reference parameters like
larastan.container_path,larastan.model_properties_scan_depth,larastan.concise,checkMissingMorphToParameters, andcheckPhpDocMissingReturnType. These are not part of Larastan 3.x — remove them.
12) Common Pitfalls & Fix Patterns
mixedeverywhere → 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
checkModelPropertiesfor Eloquent attributes. - Untyped relationships → add the
@returndocblock from §10 above — native return types alone don’t carry the generic.
13) Agent Playbooks
-
New module
- Start at
level: maxwith an empty baseline. - Add native types, DTOs, and Result objects from the first commit.
- Type every relationship with the §10 patterns.
- Start at
-
Legacy increment
- Raise the level one notch after the baseline shrinks by ≥15%.
- Prefer migrating a single bounded context to
level: maxover flattening everything tolevel: 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?
maxfor new code; otherwise7–8and climb. - How do I type a
MorphTothat points anywhere? UseMorphTo<Model, $this>and narrow withinstanceofat 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 sostatic/$thisresolution 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
ignoreErrorsentry 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.