| name | laravel-conventions |
| description | Modern Laravel 11+ / PHP 8.3+ coding standards reference. Use when writing or reviewing PHP/Laravel code to ensure convention compliance. |
| model | claude-sonnet-4.6 |
| platforms | {"copilot":{"model":"gpt-5.3-codex"},"opencode":{"model":"openrouter/moonshotai/kimi-k2.6"}} |
Laravel Coding Conventions (Laravel 11+ / PHP 8.3+)
Reference skill for modern Laravel coding standards and PHP 8.3+ best practices.
Architecture Pattern
Controller -> FormRequest -> Action/Service -> Model
- Controllers: Thin, single-responsibility. Invoke actions or services, return API resources
- Actions: Single-purpose classes for discrete operations (e.g.,
CreateUser, ProcessPayment)
- Services: Coordinate multiple actions or complex business logic
- Models: Eloquent models with relationships, scopes, casts, and accessors/mutators
- DTOs: Readonly data transfer objects for passing structured data between layers
PHP 8.3+ Features to Use
declare(strict_types=1);
readonly class CreateUserData {
public function __construct(
public string $name,
public string $email,
public ?string $phone = null,
) {}
}
enum UserStatus: string {
case Active = 'active';
case Suspended = 'suspended';
case Pending = 'pending';
}
$users->map($this->transformUser(...));
Cache::put(key: $cacheKey, value: $data, ttl: 3600);
$label = match($status) {
UserStatus::Active => 'Active User',
UserStatus::Suspended => 'Account Suspended',
default => 'Unknown',
};
Naming Conventions
| Type | Convention | Example |
|---|
| Classes | PascalCase | UserService |
| Methods | camelCase | getUserById |
| Properties/Variables | camelCase | $userData |
| Constants | UPPER_SNAKE_CASE | MAX_RETRIES |
| Database tables | plural, snake_case | user_accounts |
| Database columns | snake_case | created_at |
| Routes | kebab-case | /user-profiles |
| Config keys | snake_case | cache.default_ttl |
| Enums | PascalCase | UserStatus::Active |
Code Style
- PSR-12 compliance, enforced via Laravel Pint
declare(strict_types=1) at the top of every PHP file
- Single quotes for strings (unless interpolation needed)
- Short array syntax
[] with trailing commas
- Type declarations on all parameters, return types, and properties
- No
mixed type unless absolutely necessary
- Constructor property promotion where appropriate
Directory Structure (Laravel 11+)
app/
├── Actions/ # Single-purpose action classes
├── Console/Commands/ # Artisan commands
├── DTOs/ # Readonly data transfer objects
├── Enums/ # PHP 8.1+ backed enums
├── Events/ # Event classes
├── Exceptions/ # Custom exceptions
├── Http/
│ ├── Controllers/ # API/Web controllers
│ ├── Middleware/ # Request middleware
│ ├── Requests/ # FormRequest validation
│ └── Resources/ # API Resources (JSON transformations)
├── Jobs/ # Queued jobs
├── Listeners/ # Event listeners
├── Mail/ # Mailable classes
├── Models/ # Eloquent models
│ └── Concerns/ # Model traits
├── Notifications/ # Notification classes
├── Observers/ # Model observers
├── Policies/ # Authorization policies
├── Providers/ # Service providers
├── Rules/ # Custom validation rules
└── Services/ # Business logic services
database/
├── factories/ # Model factories
├── migrations/ # Database migrations
└── seeders/ # Database seeders
routes/
├── api.php # API routes
├── web.php # Web routes
└── console.php # Console routes (Laravel 11+)
tests/
├── Feature/ # Feature/integration tests
├── Unit/ # Unit tests
└── Pest.php # Pest configuration
Eloquent Best Practices
protected $casts = [
'email_verified_at' => 'datetime',
'status' => UserStatus::class,
'settings' => 'array',
'is_admin' => 'boolean',
];
public function scopeActive(Builder $query): Builder
{
return $query->where('status', UserStatus::Active);
}
Model::preventLazyLoading(! app()->isProduction());
Model::shouldBeStrict(! app()->isProduction());
User::with(['posts', 'profile'])->get();
'posts' => PostResource::collection($this->whenLoaded('posts')),
Database Migrations
Naming Convention
YYYY_MM_DD_HHMMSS_description.php
Best Practices
- One concern per migration file
- Always include
down() method for rollbacks
- Never modify migrations that have been run in production
- Use
foreignId() and constrained() for foreign keys
- Use
after() to control column order
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->string('slug')->unique();
$table->text('body');
$table->enum('status', ['draft', 'published', 'archived'])->default('draft');
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['status', 'published_at']);
});
API Resources
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'posts' => PostResource::collection($this->whenLoaded('posts')),
'created_at' => $this->created_at->toISOString(),
];
}
}
return UserResource::make($user);
return UserResource::collection($users);
Queues and Jobs
class ProcessPayment implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $backoff = 60;
public int $timeout = 120;
public function __construct(
private readonly Order $order,
) {}
public function handle(PaymentGateway $gateway): void
{
$gateway->charge($this->order);
}
public function failed(\Throwable $exception): void
{
}
}
Events and Listeners
class OrderPlaced
{
public function __construct(
public readonly Order $order,
) {}
}
class SendOrderConfirmation
{
public function handle(OrderPlaced $event): void
{
$event->order->user->notify(new OrderConfirmationNotification($event->order));
}
}
Routes
Route::apiResource('users', UserController::class);
Route::prefix('admin')->middleware('auth:sanctum', 'admin')->group(function () {
Route::apiResource('users', Admin\UserController::class);
});
Route::post('/webhooks/stripe', StripeWebhookController::class);
Security
- Use Laravel Sanctum or Passport for API authentication
- Always validate with FormRequest classes
- Use Policies for authorization (not Gates in controllers)
- Never expose internal IDs if security-sensitive -- use UUIDs or hashids
- Use
$request->validated() to only access validated data
- Set rate limiting on API routes
- Use
encrypt()/decrypt() for sensitive data at rest
Error Handling
class InsufficientFundsException extends Exception
{
public function render(Request $request): JsonResponse
{
return response()->json([
'message' => 'Insufficient funds for this transaction.',
], 422);
}
}
abort_if(! $user->canAccess($resource), 403);
abort_unless($order->isPending(), 422, 'Order is no longer pending.');
Testing with Pest
it('creates a user', function () {
$response = postJson('/api/users', [
'name' => 'Jane Doe',
'email' => 'jane@example.com',
'password' => 'secure-password',
]);
$response->assertCreated()
->assertJsonPath('data.name', 'Jane Doe');
$this->assertDatabaseHas('users', ['email' => 'jane@example.com']);
});
it('calculates order total with tax', function () {
$order = Order::factory()->create(['subtotal' => 10000]);
expect($order->totalWithTax())->toBe(10800);
});
it('validates required fields', function (string $field) {
$data = User::factory()->make()->toArray();
unset($data[$field]);
postJson('/api/users', $data)->assertUnprocessable()
->assertJsonValidationErrors($field);
})->with(['name', 'email', 'password']);
Running Tests
php artisan test
./vendor/bin/pest
./vendor/bin/pest tests/Feature/UserTest.php
./vendor/bin/pest --filter "creates a user"
php artisan test --parallel
Running Linter
./vendor/bin/pint
./vendor/bin/pint --test
./vendor/bin/phpstan analyse