Skip to main content
Kodelyth ECC
Skill

laravel-patterns

Laravel architecture patterns, routing/controllers, Eloquent ORM, service layers, queues, events, caching, and API resources for production apps.

Invoke via:use laravel-patterns
Origin:ECC

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., conversation vs conversations).
  • Use a single parameter name that matches the bound model (e.g., {conversation} for Conversation).
  • 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_case and 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 .env and config in config/*.php
  • Use per-environment config overrides and config:cache in production