Laravel and PHP rules for AI coding agents
Model::create($request->all()) is the line these rules most want to keep out of your codebase. The Laravel rules cover apps on PHP 8 and start with require.php and laravel/framework in Composer, so the agent uses features the PHP version actually supports, such as constructor property promotion, readonly properties and enums.
Validation lives in Form Request classes and authorization in a Policy per model, with a note that in Laravel 11+ the base Controller no longer includes the AuthorizesRequests trait. Eloquent guidance centres on eager loading and turning on preventLazyLoading outside production. Configuration has its own trap: after config:cache, env() outside config/*.php returns null. Slow work such as mail, PDFs and webhooks goes to queued jobs dispatched after commit.
Folder: rules/laravel-php/ · laravel-php.mdc, CLAUDE.md, AGENTS.md
What the Laravel rules tell your agent
- Versions and toolingRead
require.phpandlaravel/frameworkin composer.json/composer.lock. Use features the PHP version supports: constructor property promotion,readonlyproperties, enums,match, first-class callables. - Requests and validationValidate in Form Request classes (
php artisan make:request StoreOrderRequest) and use$request->validated()or$request->safe()->only([...]). Never writeModel::create($request->all()). - AuthorizationWrite a Policy per model and call
Gate::authorize('update', $post)or use->can('update', 'post')on the route. In Laravel 11+, the baseControllerno longer includes theAuthorizesRequeststrait, so$this->authorize()fails unless you add it. - EloquentEager load relations you will touch:
Order::with(['customer', 'lines.product']). CallModel::preventLazyLoading(! app()->isProduction())in a service provider to catch N+1 queries in development. - ConfigurationCall
env()only insideconfig/*.php. Afterphp artisan config:cache,env()outside config files returnsnull. Read values withconfig('services.mailgun.secret').
First rule of each section in laravel-php.mdc, quoted as shipped.
Mistakes these rules are written to prevent
- Leaving
dd(),dump()orray()calls in committed code. - Business logic inside Blade views or route closures.
- Fat controllers. Move multi-step logic into an action or service class with one public method.
Source: “Pitfalls to avoid”.
Checks the agent runs before it says done
Example commands from the Laravel file (swap in your repo's own):
- Tests:
php artisan test - Style:
vendor/bin/pint --test - Static analysis:
vendor/bin/phpstan analyse
Run php artisan test (or vendor/bin/pest), vendor/bin/pint --test and vendor/bin/phpstan analyse, and report each result.
Before and after: Laravel: mass assignment and authorization review
Prompt card 32 · Stack: Laravel, PHP 8.2+ · Type: Security review · Best model: a frontier reasoning model
What the card is for
Find $request->all() writes, $guarded = [] models, missing policies, unscoped route model binding and Blade output that is not escaped, then fix them with Form Requests, $fillable, policies and scoped bindings.
Worked example from the card
Before
// routes/web.php
Route::put('/teams/{team}/projects/{project}', [ProjectController::class, 'update']);
// app/Models/Project.php
class Project extends Model { protected $guarded = []; }
// app/Http/Controllers/ProjectController.php
public function update(Request $request, Team $team, Project $project)
{
$this->authorize('update', $project);
$project->update($request->all());
return back();
}
Expected AI output (abridged)
Critical - $request->all() with $guarded = []: a user can send team_id=9 to move the
project into another team, or owner_id to take ownership.
Critical - unscoped bindings: PUT /teams/1/projects/55 loads project 55 even if it
belongs to team 3. The policy receives only the project, so a policy that checks
team membership through the URL's team is bypassed.
High - Laravel 11: base Controller has no AuthorizesRequests trait; $this->authorize
throws "Call to undefined method" (a 500, not a 403) unless the trait was added.
After
// routes/web.php
Route::put('/teams/{team}/projects/{project}', [ProjectController::class, 'update'])->scopeBindings();
// app/Models/Project.php
class Project extends Model
{
protected $fillable = ['name', 'description', 'due_on'];
}
// app/Http/Requests/UpdateProjectRequest.php
class UpdateProjectRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('update', $this->route('project'));
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:120'],
'description' => ['nullable', 'string', 'max:5000'],
'due_on' => ['nullable', 'date'],
];
}
}
// app/Http/Controllers/ProjectController.php
public function update(UpdateProjectRequest $request, Team $team, Project $project): RedirectResponse
{
$project->update($request->validated());
return to_route('teams.projects.show', [$team, $project])->with('status', 'Project updated');
}
Excerpt from card 32; the prompt itself is in the pack.
More Laravel prompt cards in the pack
- Card 33: Laravel: Eloquent N+1 and large-table processing
Using these rules in Cursor, Claude Code and AGENTS.md tools
Cursor
Put _base.mdc and laravel-php.mdc in .cursor/rules/. Globs: composer.json, app/**/*.php, routes/*.php, config/*.php, database/**/*.php, resources/views/**/*.blade.php, tests/**/*.php.
Claude Code
Root CLAUDE.md, base first. Path-scoped variant .claude/rules/laravel-php.md for app/**/*.php, routes/*.php, database/**/*.php, resources/views/**/*.blade.php; PostToolUse hook: vendor/bin/pint <file>.
AGENTS.md tools (Codex, Gemini CLI, Aider)
Base then stack in one AGENTS.md. Gemini CLI needs context.fileName; Aider needs --read AGENTS.md. Put this file in the Laravel root. Agents use the AGENTS.md nearest to the file being edited, so a nested copy overrides a root one for that subtree.