agentleFS
aegnticaegntic/compound-engineering/skills/laravel-conventions/SKILL.md

laravel-conventions

Skill1 starsChanged 5 months agoMITView on GitHub

Modern Laravel 11+ / PHP 8.3+ coding standards reference. Use when writing or reviewing PHP/Laravel code to ensure convention compliance.

2 other projects use a version of this.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

10 rules. About 0 are common, 5 are distinctive, and 5 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈5

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 5

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Always include down() method for rollbacks

    line 171
  2. Distinctive:

    Never modify migrations that have been run in production

    line 172
  3. Very specific:

    Use foreignId() and constrained() for foreign keys

    line 173
  4. Very specific:

    Use after() to control column order

    line 174
  5. Distinctive:

    Use Laravel Sanctum or Passport for API authentication

    line 282
  6. Distinctive:

    Always validate with FormRequest classes

    line 283
  7. Distinctive:

    Use Policies for authorization (not Gates in controllers)

    line 284
  8. Distinctive:

    Never expose internal IDs if security-sensitive -- use UUIDs or hashids

    line 285
  9. Very specific:

    Use $request->validated() to only access validated data

    line 286
  10. Very specific:

    Use encrypt()/decrypt() for sensitive data at rest

    line 288

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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 classes for DTOs
readonly class CreateUserData {
    public function __construct(
        public string $name,
        public string $email,
        public ?string $phone = null,
    ) {}
}

// Enums instead of constants
enum UserStatus: string {
    case Active = 'active';
    case Suspended = 'suspended';
    case Pending = 'pending';
}

// First-class callable syntax
$users->map($this->transformUser(...));

// Named arguments for clarity
Cache::put(key: $cacheKey, value: $data, ttl: 3600);

// Match expressions over switch
$label = match($status) {
    UserStatus::Active => 'Active User',
    UserStatus::Suspended => 'Account Suspended',
    default => 'Unknown',
};

Naming Conventions

TypeConventionExample
ClassesPascalCaseUserService
MethodscamelCasegetUserById
Properties/VariablescamelCase$userData
ConstantsUPPER_SNAKE_CASEMAX_RETRIES
Database tablesplural, snake_caseuser_accounts
Database columnssnake_casecreated_at
Routeskebab-case/user-profiles
Config keyssnake_casecache.default_ttl
EnumsPascalCaseUserStatus::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

// Use $casts property (not method unless dynamic)
protected $casts = [
    'email_verified_at' => 'datetime',
    'status' => UserStatus::class,
    'settings' => 'array',
    'is_admin' => 'boolean',
];

// Scope queries for reuse
public function scopeActive(Builder $query): Builder
{
    return $query->where('status', UserStatus::Active);
}

// Prevent lazy loading in development
// In AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());

// Use strict mode
Model::shouldBeStrict(! app()->isProduction());

// Eager load relationships
User::with(['posts', 'profile'])->get();

// Use whenLoaded in API Resources
'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

// Always use API Resources for JSON responses
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(),
        ];
    }
}

// In controller
return UserResource::make($user);
return UserResource::collection($users);

Queues and Jobs

// Implement ShouldQueue for async processing
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
    {
        // Notify team of failure
    }
}

Events and Listeners

// Use typed events
class OrderPlaced
{
    public function __construct(
        public readonly Order $order,
    ) {}
}

// Auto-discovered listeners (Laravel 11+)
// Just type-hint the event in handle()
class SendOrderConfirmation
{
    public function handle(OrderPlaced $event): void
    {
        $event->order->user->notify(new OrderConfirmationNotification($event->order));
    }
}

Routes

// Use controller class references
Route::apiResource('users', UserController::class);

// Group related routes
Route::prefix('admin')->middleware('auth:sanctum', 'admin')->group(function () {
    Route::apiResource('users', Admin\UserController::class);
});

// Single-action controllers
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

// Custom exceptions with render method
class InsufficientFundsException extends Exception
{
    public function render(Request $request): JsonResponse
    {
        return response()->json([
            'message' => 'Insufficient funds for this transaction.',
        ], 422);
    }
}

// Use abort helpers
abort_if(! $user->canAccess($resource), 403);
abort_unless($order->isPending(), 422, 'Order is no longer pending.');

Testing with Pest

// Feature test
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']);
});

// Unit test with mocking
it('calculates order total with tax', function () {
    $order = Order::factory()->create(['subtotal' => 10000]);

    expect($order->totalWithTax())->toBe(10800);
});

// Use datasets for parameterized tests
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

# All tests
php artisan test

# With Pest directly
./vendor/bin/pest

# Specific test file
./vendor/bin/pest tests/Feature/UserTest.php

# Filter by name
./vendor/bin/pest --filter "creates a user"

# Parallel execution
php artisan test --parallel

Running Linter

./vendor/bin/pint

# Check only (no changes)
./vendor/bin/pint --test

# Static analysis
./vendor/bin/phpstan analyse

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.