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.
php
1final class CreateOrderAction
2{
3 public function __construct(private OrderRepository $orders) {}
4
5 public function handle(CreateOrderData $data): Order
6 {
7 return $this->orders->create($data);
8 }
9}
10
11final class OrdersController extends Controller
12{
13 public function __construct(private CreateOrderAction $createOrder) {}
14
15 public function store(StoreOrderRequest $request): JsonResponse
16 {
17 $order = $this->createOrder->handle($request->toDto());
18
19 return response()->json([
20 'success' => true,
21 'data' => OrderResource::make($order),
22 'error' => null,
23 'meta' => null,
24 ], 201);
25 }
26}
Routing and Controllers
Prefer route-model binding and resource controllers for clarity.
php
1use Illuminate\Support\Facades\Route;
2
3Route::middleware('auth:sanctum')->group(function () {
4 Route::apiResource('projects', ProjectController::class);
5});
Route Model Binding (Scoped)
Use scoped bindings to prevent cross-tenant access.
php
1Route::scopeBindings()->group(function () {
2 Route::get('/accounts/{account}/projects/{project}', [ProjectController::class, 'show']);
3});
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.
php
1use App\Http\Controllers\Api\ConversationController;
2use App\Http\Controllers\Api\MessageController;
3use Illuminate\Support\Facades\Route;
4
5Route::middleware('auth:sanctum')->prefix('conversations')->group(function () {
6 Route::post('/', [ConversationController::class, 'store'])->name('conversations.store');
7
8 Route::scopeBindings()->group(function () {
9 Route::get('/{conversation}', [ConversationController::class, 'show'])
10 ->name('conversations.show');
11
12 Route::post('/{conversation}/messages', [MessageController::class, 'store'])
13 ->name('conversation-messages.store');
14
15 Route::get('/{conversation}/messages/{message}', [MessageController::class, 'show'])
16 ->name('conversation-messages.show');
17 });
18});
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.
php
1use App\Models\AiConversation;
2use Illuminate\Support\Facades\Route;
3
4Route::model('conversation', AiConversation::class);
Service Container Bindings
Bind interfaces to implementations in a service provider for clear dependency wiring.
php
1use App\Repositories\EloquentOrderRepository;
2use App\Repositories\OrderRepository;
3use Illuminate\Support\ServiceProvider;
4
5final class AppServiceProvider extends ServiceProvider
6{
7 public function register(): void
8 {
9 $this->app->bind(OrderRepository::class, EloquentOrderRepository::class);
10 }
11}
Eloquent Model Patterns
Model Configuration
php
1final class Project extends Model
2{
3 use HasFactory;
4
5 protected $fillable = ['name', 'owner_id', 'status'];
6
7 protected $casts = [
8 'status' => ProjectStatus::class,
9 'archived_at' => 'datetime',
10 ];
11
12 public function owner(): BelongsTo
13 {
14 return $this->belongsTo(User::class, 'owner_id');
15 }
16
17 public function scopeActive(Builder $query): Builder
18 {
19 return $query->whereNull('archived_at');
20 }
21}
Custom Casts and Value Objects
Use enums or value objects for strict typing.
php
1use Illuminate\Database\Eloquent\Casts\Attribute;
2
3protected $casts = [
4 'status' => ProjectStatus::class,
5];
php
1protected function budgetCents(): Attribute
2{
3 return Attribute::make(
4 get: fn (int $value) => Money::fromCents($value),
5 set: fn (Money $money) => $money->toCents(),
6 );
7}
Eager Loading to Avoid N+1
php
1$orders = Order::query()
2 ->with(['customer', 'items.product'])
3 ->latest()
4 ->paginate(25);
Query Objects for Complex Filters
php
1final class ProjectQuery
2{
3 public function __construct(private Builder $query) {}
4
5 public function ownedBy(int $userId): self
6 {
7 $query = clone $this->query;
8
9 return new self($query->where('owner_id', $userId));
10 }
11
12 public function active(): self
13 {
14 $query = clone $this->query;
15
16 return new self($query->whereNull('archived_at'));
17 }
18
19 public function builder(): Builder
20 {
21 return $this->query;
22 }
23}
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.
php
1use Illuminate\Database\Eloquent\SoftDeletes;
2use Illuminate\Database\Eloquent\Builder;
3
4final class Project extends Model
5{
6 use SoftDeletes;
7
8 protected static function booted(): void
9 {
10 static::addGlobalScope('active', function (Builder $builder): void {
11 $builder->whereNull('archived_at');
12 });
13 }
14}
Query Scopes for Reusable Filters
php
1use Illuminate\Database\Eloquent\Builder;
2
3final class Project extends Model
4{
5 public function scopeOwnedBy(Builder $query, int $userId): Builder
6 {
7 return $query->where('owner_id', $userId);
8 }
9}
10
11// In service, repository etc.
12$projects = Project::ownedBy($user->id)->get();
Transactions for Multi-Step Updates
php
1use Illuminate\Support\Facades\DB;
2
3DB::transaction(function (): void {
4 $order->update(['status' => 'paid']);
5 $order->items()->update(['paid_at' => now()]);
6});
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
php
1use Illuminate\Database\Migrations\Migration;
2use Illuminate\Database\Schema\Blueprint;
3use Illuminate\Support\Facades\Schema;
4
5return new class extends Migration
6{
7 public function up(): void
8 {
9 Schema::create('orders', function (Blueprint $table): void {
10 $table->id();
11 $table->foreignId('customer_id')->constrained()->cascadeOnDelete();
12 $table->string('status', 32)->index();
13 $table->unsignedInteger('total_cents');
14 $table->timestamps();
15 });
16 }
17
18 public function down(): void
19 {
20 Schema::dropIfExists('orders');
21 }
22};
Keep validation in form requests and transform inputs to DTOs.
php
1use App\Models\Order;
2
3final class StoreOrderRequest extends FormRequest
4{
5 public function authorize(): bool
6 {
7 return $this->user()?->can('create', Order::class) ?? false;
8 }
9
10 public function rules(): array
11 {
12 return [
13 'customer_id' => ['required', 'integer', 'exists:customers,id'],
14 'items' => ['required', 'array', 'min:1'],
15 'items.*.sku' => ['required', 'string'],
16 'items.*.quantity' => ['required', 'integer', 'min:1'],
17 ];
18 }
19
20 public function toDto(): CreateOrderData
21 {
22 return new CreateOrderData(
23 customerId: (int) $this->validated('customer_id'),
24 items: $this->validated('items'),
25 );
26 }
27}
API Resources
Keep API responses consistent with resources and pagination.
php
1$projects = Project::query()->active()->paginate(25);
2
3return response()->json([
4 'success' => true,
5 'data' => ProjectResource::collection($projects->items()),
6 'error' => null,
7 'meta' => [
8 'page' => $projects->currentPage(),
9 'per_page' => $projects->perPage(),
10 'total' => $projects->total(),
11 ],
12]);
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