Skill v1.0.0
currentAutomated scan96/100name: laravel-patterns description: Laravel architecture patterns, routing/controllers, Eloquent ORM, service layers, queues, events, caching, and API resources for production apps. origin: ECC metadata: version: "1.0.0"
Laravel Development Patterns
Production-grade Laravel architecture patterns for scalable, maintainable applications.
When to Use
- Building Laravel web applications or APIs
- Structuring controllers, services, and domain logic
- Working with Eloquent models and relationships
- Designing APIs with resources and pagination
- Adding queues, events, caching, and background jobs
How It Works
- Structure the app around clear boundaries (controllers -> services/actions -> models).
- Use explicit bindings and scoped bindings to keep routing predictable; still enforce authorization for access control.
- Favor typed models, casts, and scopes to keep domain logic consistent.
- Keep IO-heavy work in queues and cache expensive reads.
- Centralize config in
config/*and keep environments explicit.
Examples
Project Structure
Use a conventional Laravel layout with clear layer boundaries (HTTP, services/actions, models).
Recommended Layout
app/├── Actions/ # Single-purpose use cases├── Console/├── Events/├── Exceptions/├── Http/│ ├── Controllers/│ ├── Middleware/│ ├── Requests/ # Form request validation│ └── Resources/ # API resources├── Jobs/├── Models/├── Policies/├── Providers/├── Services/ # Coordinating domain services└── Support/config/database/├── factories/├── migrations/└── seeders/resources/├── views/└── lang/routes/├── api.php├── web.php└── console.php
Controllers -> Services -> Actions
Keep controllers thin. Put orchestration in services and single-purpose logic in actions.
final class CreateOrderAction{public function __construct(private OrderRepository $orders) {}public function handle(CreateOrderData $data): Order{return $this->orders->create($data);}}final class OrdersController extends Controller{public function __construct(private CreateOrderAction $createOrder) {}public function store(StoreOrderRequest $request): JsonResponse{$order = $this->createOrder->handle($request->toDto());return response()->json(['success' => true,'data' => OrderResource::make($order),'error' => null,'meta' => null,], 201);}}
Routing and Controllers
Prefer route-model binding and resource controllers for clarity.
use Illuminate\Support\Facades\Route;Route::middleware('auth:sanctum')->group(function () {Route::apiResource('projects', ProjectController::class);});
Route Model Binding (Scoped)
Use scoped bindings to prevent cross-tenant access.
Route::scopeBindings()->group(function () {Route::get('/accounts/{account}/projects/{project}', [ProjectController::class, 'show']);});
Nested Routes and Binding Names
- Keep prefixes and paths consistent to avoid double nesting (e.g.,
conversationvsconversations). - Use a single parameter name that matches the bound model (e.g.,
{conversation}forConversation). - Prefer scoped bindings when nesting to enforce parent-child relationships.
use App\Http\Controllers\Api\ConversationController;use App\Http\Controllers\Api\MessageController;use Illuminate\Support\Facades\Route;Route::middleware('auth:sanctum')->prefix('conversations')->group(function () {Route::post('/', [ConversationController::class, 'store'])->name('conversations.store');Route::scopeBindings()->group(function () {Route::get('/{conversation}', [ConversationController::class, 'show'])->name('conversations.show');Route::post('/{conversation}/messages', [MessageController::class, 'store'])->name('conversation-messages.store');Route::get('/{conversation}/messages/{message}', [MessageController::class, 'show'])->name('conversation-messages.show');});});
If you want a parameter to resolve to a different model class, define explicit binding. For custom binding logic, use Route::bind() or implement resolveRouteBinding() on the model.
use App\Models\AiConversation;use Illuminate\Support\Facades\Route;Route::model('conversation', AiConversation::class);
Service Container Bindings
Bind interfaces to implementations in a service provider for clear dependency wiring.
use App\Repositories\EloquentOrderRepository;use App\Repositories\OrderRepository;use Illuminate\Support\ServiceProvider;final class AppServiceProvider extends ServiceProvider{public function register(): void{$this->app->bind(OrderRepository::class, EloquentOrderRepository::class);}}
Eloquent Model Patterns
Model Configuration
final class Project extends Model{use HasFactory;protected $fillable = ['name', 'owner_id', 'status'];protected $casts = ['status' => ProjectStatus::class,'archived_at' => 'datetime',];public function owner(): BelongsTo{return $this->belongsTo(User::class, 'owner_id');}public function scopeActive(Builder $query): Builder{return $query->whereNull('archived_at');}}
Custom Casts and Value Objects
Use enums or value objects for strict typing.
use Illuminate\Database\Eloquent\Casts\Attribute;protected $casts = ['status' => ProjectStatus::class,];
protected function budgetCents(): Attribute{return Attribute::make(get: fn (int $value) => Money::fromCents($value),set: fn (Money $money) => $money->toCents(),);}
Eager Loading to Avoid N+1
$orders = Order::query()->with(['customer', 'items.product'])->latest()->paginate(25);
Query Objects for Complex Filters
final class ProjectQuery{public function __construct(private Builder $query) {}public function ownedBy(int $userId): self{$query = clone $this->query;return new self($query->where('owner_id', $userId));}public function active(): self{$query = clone $this->query;return new self($query->whereNull('archived_at'));}public function builder(): Builder{return $this->query;}}
Global Scopes and Soft Deletes
Use global scopes for default filtering and SoftDeletes for recoverable records. Use either a global scope or a named scope for the same filter, not both, unless you intend layered behavior.
use Illuminate\Database\Eloquent\SoftDeletes;use Illuminate\Database\Eloquent\Builder;final class Project extends Model{use SoftDeletes;protected static function booted(): void{static::addGlobalScope('active', function (Builder $builder): void {$builder->whereNull('archived_at');});}}
Query Scopes for Reusable Filters
use Illuminate\Database\Eloquent\Builder;final class Project extends Model{public function scopeOwnedBy(Builder $query, int $userId): Builder{return $query->where('owner_id', $userId);}}// In service, repository etc.$projects = Project::ownedBy($user->id)->get();
Transactions for Multi-Step Updates
use Illuminate\Support\Facades\DB;DB::transaction(function (): void {$order->update(['status' => 'paid']);$order->items()->update(['paid_at' => now()]);});
Migrations
Naming Convention
- File names use timestamps:
YYYY_MM_DD_HHMMSS_create_users_table.php - Migrations use anonymous classes (no named class); the filename communicates intent
- Table names are
snake_caseand plural by default
Example Migration
use Illuminate\Database\Migrations\Migration;use Illuminate\Database\Schema\Blueprint;use Illuminate\Support\Facades\Schema;return new class extends Migration{public function up(): void{Schema::create('orders', function (Blueprint $table): void {$table->id();$table->foreignId('customer_id')->constrained()->cascadeOnDelete();$table->string('status', 32)->index();$table->unsignedInteger('total_cents');$table->timestamps();});}public function down(): void{Schema::dropIfExists('orders');}};
Form Requests and Validation
Keep validation in form requests and transform inputs to DTOs.
use App\Models\Order;final class StoreOrderRequest extends FormRequest{public function authorize(): bool{return $this->user()?->can('create', Order::class) ?? false;}public function rules(): array{return ['customer_id' => ['required', 'integer', 'exists:customers,id'],'items' => ['required', 'array', 'min:1'],'items.*.sku' => ['required', 'string'],'items.*.quantity' => ['required', 'integer', 'min:1'],];}public function toDto(): CreateOrderData{return new CreateOrderData(customerId: (int) $this->validated('customer_id'),items: $this->validated('items'),);}}
API Resources
Keep API responses consistent with resources and pagination.
$projects = Project::query()->active()->paginate(25);return response()->json(['success' => true,'data' => ProjectResource::collection($projects->items()),'error' => null,'meta' => ['page' => $projects->currentPage(),'per_page' => $projects->perPage(),'total' => $projects->total(),],]);
Events, Jobs, and Queues
- Emit domain events for side effects (emails, analytics)
- Use queued jobs for slow work (reports, exports, webhooks)
- Prefer idempotent handlers with retries and backoff
Caching
- Cache read-heavy endpoints and expensive queries
- Invalidate caches on model events (created/updated/deleted)
- Use tags when caching related data for easy invalidation
Configuration and Environments
- Keep secrets in
.envand config inconfig/*.php - Use per-environment config overrides and
config:cachein production