June 23, 2026
· 12 min readLaravel & PHP Guidelines for AI Code Assistants — A Practical Style Guide
AI assistants generate Laravel code fast, but rarely in a consistent house style. This post distills a battle-tested set of Laravel & PHP conventions — typed properties, happy-path-last control flow, naming rules, and testing structure — into a guide you can drop straight into your AI tooling. Inspired by Spatie's well-known guidelines.

If you've shipped a few Laravel apps with an AI assistant in the loop, you've felt this: the code works, but it doesn't look like your code. Promoted constructors here, docblocks there, else branches nesting three deep, controllers named in five different styles across one PR.
The fix isn't to stop using the assistant — it's to give it a style guide. What follows is a compact set of Laravel & PHP conventions you can hand to Claude Code, Cursor, or Copilot so the generated code matches a consistent house style. It's heavily inspired by Spatie's Laravel & PHP guidelines, reshaped for AI tooling.
💡 A note before the rules: Most of what follows is opinionated, not gospel. PSR-1 and PSR-12 are real standards — follow those. But "happy path last," "avoid
else," "skip docblocks when types suffice," or "mirrorapp/intests/" are stylistic preferences, not framework law. The point isn't that these exact choices are correct — it's that you pick a consistent set and stick to it. Swap any rule here for your own and the codebase is just as clean. That's the beauty of it: the consistency matters more than the specific convention.
First, Pick Your Structure: MVC, DDD, or Beyond
Here's the thing most "Laravel best practices" posts skip: Laravel doesn't force one architecture on you. The framework ships with a default MVC layout, but it's flexible enough to host several structural styles — and your conventions should sit on top of whichever you choose, not replace the choice.
The common options, roughly in order of increasing ceremony:
| Structure | Organizes code by | Best for | Trade-off |
|---|---|---|---|
| Default MVC | Technical layer (Controllers, Models, Views) | Small-to-mid apps, CRUD-heavy work, fast delivery | Business logic scatters across fat controllers and models as the app grows |
| MVC + Actions/Services | Technical layer + single-purpose logic classes | Most production apps | Slight extra indirection; the pragmatic default for many teams |
| Modular Monolith | Business module (Billing, Blog) |
Larger teams, clear feature boundaries | Needs discipline to keep modules from leaking into each other |
| Domain-Driven Design (DDD) | Business domain + layered (Domain / Application / Infrastructure) | Complex enterprise systems, intricate business rules | Upfront modeling cost; overkill for simple CRUD |
| DDD + CQRS / Clean Architecture | Domain + separated read/write paths | High-complexity, high-scale systems | Significant complexity; only pays off at scale |
A few practitioner truths worth internalizing, drawn from how Laravel teams actually argue about this:
- For small projects with basic CRUD, default MVC is usually enough — reaching for DDD there just adds complexity without payoff. Source: Medium — Harry Es Pant
- As apps grow, business logic scattered across controllers, models, and services becomes a maintenance burden — which is what pushes teams toward DDD's clear layer boundaries. Source: DEV Community
- A common DDD-in-Laravel move is organizing
app/by domain, where each domain holds its own Models, Actions, and Repositories. Source: DEV Community - Don't over-engineer. Start with solid Service/Action classes and adopt stricter DDD patterns only as complexity demands. It's easier to grow into DDD than to retrofit it onto a tangled four-year-old codebase. Source: Laracasts discussion
A DDD app/ directory typically separates concerns into layers — pure business logic that doesn't depend on Laravel, an orchestration layer with use cases and DTOs, and a Laravel-specific infrastructure layer:
app/
├── Domain/ # Pure business logic — no Laravel dependency
│ └── Order/
│ ├── Models/
│ ├── Actions/
│ ├── Enums/
│ └── ValueObjects/ # EmailAddress, MoneyAmount (immutable)
├── Application/ # Orchestration — use cases, DTOs
│ └── Order/
└── Infrastructure/ # Laravel-specific — Eloquent, controllers
└── Order/Compare that to default MVC, where the same code lives under app/Http/Controllers, app/Models, and resources/views.
⚠️ The key point: the conventions in this post — naming, control flow, typed properties, testing — apply no matter which structure you pick. A guard clause is a guard clause whether it lives in a fat controller or a domain action. Choose the structure that fits the project's complexity, then layer a consistent style on top. The structure decides where code lives; the conventions decide how it reads.
The Problem: ShopCore's Inconsistent Codebase
Imagine ShopCore, a mid-size Laravel store. Three engineers, two AI assistants, one growing codebase. Within a sprint:
- One controller is
ProductController, another isOrdersController, a third isCart. - Half the value objects use constructor promotion; half assign properties by hand.
- Some methods declare
void, others return nothing silently. env()calls are sprinkled through service classes — and config caching just broke staging.- Validation rules are pipe-strings in one file, arrays in the next.
Nothing is wrong, exactly. But every file reads like a different author wrote it, because effectively one did — a model with no shared convention. Sound familiar? That's exactly where a written guideline comes in.
1. PHP Standards: Set the Baseline
Start with the non-negotiables. These are the rules an assistant should never have to guess about.
// Follow PSR-1, PSR-2, PSR-12.
// Short nullable notation — not string|null.
public ?string $middleName;
// Always declare void when a method returns nothing.
public function handle(): void
{
// ...
}Breaking it down:
?stringoverstring|null— shorter, and the common case (a single nullable type) reads cleaner.voidalways — an explicitvoidtells the reader (and the assistant) that nothing comes back by design.- No
finalorreadonlyby default — add them when you have a reason, not reflexively.
2. Class Structure: Typed Properties First
Lean on the type system instead of docblocks. Promote constructor properties when all of them can be promoted, and put one trait per line.
The Old Way
class Conversation
{
/** @var string */
public $title;
/** @var ConversationStatus */
public $status;
public function __construct(string $title, ConversationStatus $status)
{
$this->title = $title;
$this->status = $status;
}
}The New Way
class Conversation
{
public function __construct(
public string $title,
public ConversationStatus $status,
) {}
}
class User
{
use HasFactory;
use Notifiable;
use SoftDeletes;
}Real-world benefit: less ceremony, fewer places for the type and the docblock to drift out of sync.
3. Docblocks: Only When Types Can't Speak
A fully type-hinted method needs no docblock. Keep them for what types can't express — generics and array shapes — and always import class names rather than writing fully qualified ones.
/** @return Collection<int, User> */
public function getUsers(): Collection
{
// ...
}
use App\Support\Url;
/** @return Url */
public function homepage(): Url
{
// ...
}
/** @return array{
first: SomeClass,
second: SomeClass
} */
public function pair(): arrayBreaking it down:
- Generics like
Collection<int, User>earn their docblock — the type hint alone saysCollection, not what's inside. - Import class names in docblocks; never paste
\App\Support\Urlinline. - If one parameter needs a docblock, document them all. Consistency beats partial annotation.
4. Control Flow: Happy Path Last
This is the single rule that does the most for readability. Handle guards and errors first with early returns, keep the success path un-indented at the bottom, and avoid else.
// Handle the failures first...
if (! $user) {
return null;
}
if (! $user->isActive()) {
return null;
}
// ...success path lives here, flat and clear.
return $this->process($user);A few supporting rules:
- Separate conditions — prefer two
ifstatements over one compound&&. - Always use curly braces, even for a single statement.
- Ternaries break across lines unless they're trivially short:
$name = $isFoo ? 'foo' : 'bar';
$result = $object instanceof Model
? $object->name
: 'A default value';Real-world benefit: the reader never tracks nested branches to find what the method actually does.
5. Laravel Naming: One Rule Per Layer
Most AI inconsistency shows up in naming. Pin it down once.
| Element | Convention | Example |
|---|---|---|
| Classes | PascalCase | UserController, OrderStatus |
| Methods / variables | camelCase | getUserName, $firstName |
| Routes (URL) | kebab-case | /open-source |
| Route names | camelCase | ->name('openSource') |
| Route params | camelCase | {userId} |
| Config files | kebab-case | pdf-generator.php |
| Config keys | snake_case | chrome_path |
| Artisan commands | kebab-case | delete-old-records |
| Controllers | plural + Controller |
PostsController |
| Enum cases / constants | PascalCase | OrderStatus::Pending |
Controllers stick to the seven CRUD methods — index, create, store, show, edit, update, destroy. The moment you reach for a non-CRUD action, extract a new controller instead of bolting a method on.
6. Config Over env(), Always
// Don't — env() returns null once config is cached.
$path = env('CHROME_PATH');
// Do — read env inside config, use config() everywhere else.
$path = config('pdf-generator.chrome_path');Important: php artisan config:cache is standard in production. Any env() call made outside a config file returns null once the cache is built — a class of bug that's painful to trace.
7. Strings, Enums, and Constants
Small rules, but they remove a surprising amount of churn in AI-generated diffs.
// String interpolation over concatenation.
$greeting = "Hi, I am {$name}.";
// PascalCase enum cases.
enum OrderStatus
{
case Pending;
case Processing;
case Completed;
case Cancelled;
}
// PascalCase class constants — same rule as enums.
class Session
{
public const SessionTokenHeader = 'X-Session-Token';
}8. Comments: Make the Code Say It
Be ruthless about comments. They rot, the code moves on, and now the comment lies. Reach for a descriptive name before you reach for a comment.
// Instead of this:
// Get the failed checks for this site
$checks = $site->checks()->where('status', 'failed')->get();
// Do this:
$failedChecks = $site->checks()->where('status', 'failed')->get();Only comment to explain why something non-obvious is done — never what the code does. And never comment tests; the test name should carry the meaning.
9. Testing: Mirror the App, Assert Behavior
Two rules matter most here. First, mirror the app/ structure under tests/ rather than splitting into Feature/ and Unit/:
app/Http/Controllers/UserController.php
tests/Http/Controllers/UserControllerTest.php
app/Domain/User/Actions/CreateUserAction.php
tests/Domain/User/Actions/CreateUserActionTest.phpSecond, assert behavior, not setup. Re-checking a config value you set in the arrange step, or asserting a hardcoded label renders, proves nothing. A good test fails when the code under test breaks — nothing less.
Follow arrange-act-assert, use descriptive method names, and park shared helpers under Tests\TestSupport.
Putting It All Together
Here's a small Action class that respects every rule above — typed promotion, happy-path-last, no stray comments, interpolation, void where it belongs:
namespace App\Domain\Order\Actions;
use App\Models\Order;
use App\Domain\Order\Enums\OrderStatus;
use App\Domain\Order\Notifications\OrderShippedNotification;
class ShipOrderAction
{
public function __construct(
private OrderShippedNotification $notification,
) {}
public function execute(Order $order): void
{
if ($order->status !== OrderStatus::Processing) {
return;
}
if (! $order->hasShippingAddress()) {
return;
}
$order->update(['status' => OrderStatus::Completed]);
$order->customer->notify($this->notification);
}
}No else. No docblock the types already cover. No comment narrating the obvious. An AI assistant fed these guidelines produces this shape by default — which is the entire point.
How to Wire This Into Your Assistant
The guideline only helps if the assistant reads it on every request. Drop the conventions into the context file your tool watches:
Place the file at the project root, keep it short and imperative, and the assistant adapts its output to match your house style on every generation.
Final Thoughts
A style guide for your AI assistant isn't bureaucracy — it's leverage. The same model that produces five naming styles will produce one, the moment you tell it which one:
- Typed properties + promotion — less boilerplate, fewer drift bugs.
- Happy path last — flat, readable methods with no nested-branch hunting.
- One naming rule per layer — diffs that read like a single author wrote them.
config()overenv()— no more config-cache surprises in production.- Mirror tests, assert behavior — a suite that actually fails when code breaks.
And it all holds regardless of how you structure the app. Default MVC, modular monolith, or full DDD — the naming, control flow, and typed-property rules read the same in a fat controller or a domain action. Pick the structure that fits the project's complexity; layer the conventions on top.
And remember: these are opinions, not commandments. Outside of PSR-1 and PSR-12, none of this is a hard standard — it's just a coherent set of choices. Disagree with "happy path last" or want your tests split into Feature/ and Unit/? Fine. Write your own rules and feed those to the assistant. The win is consistency, not conformity — pick a style, commit to it, and let the tooling enforce it.
These conventions are heavily inspired by Spatie's Laravel & PHP guidelines — full credit to them for codifying so much of this. This version simply reshapes it for the AI-assisted workflow.
And really, that's the beauty of Laravel: you're never boxed into one structure. Build a quick MVC CRUD app or architect a full DDD enterprise system — either way you get the entire Laravel toolbox: Eloquent, queues, the service container, Blade, the scheduler, all of it. The framework bends to your architecture instead of dictating it. On top of that sits one of the most helpful communities in the ecosystem — Laracasts threads, package maintainers, and countless write-ups where people genuinely work through these architecture debates with you. Between that flexibility and that community, you're rarely stuck for long.
👉 Drop these rules into your CLAUDE.md (or .cursorrules) today and let your assistant write code that already looks like yours.
FAQ
Do these guidelines force me into MVC or DDD?
No. Laravel supports several structures — default MVC, MVC with Actions/Services, modular monolith, and DDD with layered or CQRS variants. These conventions apply on top of whichever you choose; the structure decides where code lives, the conventions decide how it reads.
When should I move from MVC to DDD in Laravel?
Stick with MVC for simple CRUD apps. Move toward Actions/Services as logic grows, and adopt DDD only when intricate business rules start overwhelming controllers and models. Don't over-engineer early — it's easier to grow into DDD than to retrofit it.
Are these Laravel guidelines official?
No. They're a community convention set, heavily inspired by Spatie's widely adopted Laravel & PHP guidelines. They reflect common practice, not framework mandates.
How do I make Claude Code or Cursor follow these rules?
Save the conventions as a context file (e.g. CLAUDE.md, .cursorrules, or a copilot-instructions file) at your project root. The assistant reads it on every request and adapts its output.
Why happy path last instead of first?
Handling error and guard conditions first with early returns keeps the success path un-indented and easy to read. The reader doesn't have to track nested branches to find the main logic.
Should I avoid env() entirely?
Avoid env() outside config files. Read it once inside a config file, then use the config() helper everywhere else. Config caching breaks any direct env() call made at runtime.
Do I really need to skip docblocks?
Skip them when the method is fully type-hinted and self-explanatory. Keep docblocks for generics (Collection<int, User>), array shapes, and anything types alone can't express.