
Laravel Best Practices
- 1.4k installs
- 58 repo stars
- Updated May 16, 2026
- asyrafhussin/agent-skills
laravel-best-practices is an agent skill for apply laravel 13 conventions for controllers, models, migrations, and services.
About
The laravel-best-practices skill is designed for apply Laravel 13 conventions for controllers, models, migrations, and services. Laravel 13 Best Practices Comprehensive best practices guide for Laravel 13 applications. Contains 31 rules across 7 categories for building scalable, maintainable Laravel applications. Invoke when the user creates Laravel controllers, models, migrations, validation, or services.
- Creating controllers, models, and services.
- Writing migrations and database queries.
- Implementing validation and form requests.
- Building APIs with Laravel.
- Structuring Laravel applications.
Laravel Best Practices by the numbers
- 1,406 all-time installs (skills.sh)
- +73 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #10 of 65 PHP & Laravel skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
laravel-best-practices capabilities & compatibility
- Capabilities
- creating controllers, models, and services · writing migrations and database queries · implementing validation and form requests · building apis with laravel
What laravel-best-practices says it does
Laravel 13 conventions and best practices. Use when creating controllers, models, migrations, validation, services, or structuring Laravel applications. Triggers on tasks involving
Laravel 13 conventions and best practices. Use when creating controllers, models, migrations, validation, services, or structuring Laravel applications. Trigger
npx skills add https://github.com/asyrafhussin/agent-skills --skill laravel-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.4k |
|---|---|
| repo stars | ★ 58 |
| Security audit | 3 / 3 scanners passed |
| Last updated | May 16, 2026 |
| Repository | asyrafhussin/agent-skills ↗ |
How do I apply laravel 13 conventions for controllers, models, migrations, and services?
Apply Laravel 13 conventions for controllers, models, migrations, and services.
Who is it for?
PHP developers structuring Laravel applications with framework conventions.
Skip if: Skip for Symfony or non-Laravel PHP frameworks.
When should I use this skill?
User creates Laravel controllers, models, migrations, validation, or services.
What you get
Completed laravel-best-practices workflow with documented commands, files, and expected deliverables.
- Refactored Laravel code
- Architecture-compliant controllers
- Optimized Eloquent queries
By the numbers
- Contains 31 rules across 7 categories
- Targets Laravel 13.x on PHP 8.3+
- Skill guide version 2.1.0
Files
Laravel 13 Best Practices
Comprehensive best practices guide for Laravel 13 applications. Contains 31 rules across 7 categories for building scalable, maintainable Laravel applications.
When to Apply
Reference these guidelines when:
- Creating controllers, models, and services
- Writing migrations and database queries
- Implementing validation and form requests
- Building APIs with Laravel
- Structuring Laravel applications
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Architecture & Structure | CRITICAL | arch- |
| 2 | Eloquent & Database | CRITICAL | eloquent- |
| 3 | Controllers & Routing | HIGH | controller-, ctrl- |
| 4 | Validation & Requests | HIGH | validation-, valid- |
| 5 | Security | HIGH | sec- |
| 6 | Performance | MEDIUM | perf- |
| 7 | API Design | MEDIUM | api- |
Quick Reference
1. Architecture & Structure (CRITICAL)
arch-service-classes- Extract business logic to servicesarch-action-classes- Single-purpose action classesarch-repository-pattern- When to use repositoriesarch-dto-pattern- Data transfer objectsarch-value-objects- Encapsulate domain conceptsarch-event-driven- Decouple with events and listenersarch-feature-folders- Organize by domain/featurearch-queue-routing- Centralized job queue routing (Laravel 13+)
2. Eloquent & Database (CRITICAL)
eloquent-eager-loading- Prevent N+1 querieseloquent-chunking- Process large datasetseloquent-query-scopes- Reusable query logiceloquent-model-events- Use observers for side effectseloquent-relationships- Define relationships properlyeloquent-casts- Automatic attribute castingeloquent-accessors-mutators- Transform attributeseloquent-soft-deletes- Safe deletion with recoveryeloquent-pruning- Automatic cleanup of old recordseloquent-vector-search- Semantic search with pgvector (Laravel 13+)
3. Controllers & Routing (HIGH)
controller-resource-controllers- Use resource controllerscontroller-single-action- Single action invokable controllerscontroller-resource-methods- RESTful resource methodscontroller-form-requests- Use form requestscontroller-api-resources- Transform API responsescontroller-middleware- Apply middleware properlycontroller-dependency-injection- Inject dependencies
4. Validation & Requests (HIGH)
validation-form-requests- Use form request classesvalidation-custom-rules- Create custom rulesvalidation-conditional-rules- Conditional validationvalidation-array-validation- Validate nested arraysvalidation-after-hooks- Complex validation logic
5. Security (HIGH)
sec-mass-assignment- Protect against mass assignment
6. Performance (MEDIUM)
No rule files exist yet for this category.
7. API Design (MEDIUM)
No rule files exist yet for this category.
Essential Patterns
Controller with Form Request
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use App\Models\Post;
use Illuminate\Http\RedirectResponse;
class PostController extends Controller
{
public function store(StorePostRequest $request): RedirectResponse
{
// Validation happens automatically
$validated = $request->validated();
$post = Post::create($validated);
return redirect()
->route('posts.show', $post)
->with('success', 'Post created successfully.');
}
public function update(UpdatePostRequest $request, Post $post): RedirectResponse
{
$post->update($request->validated());
return redirect()
->route('posts.show', $post)
->with('success', 'Post updated successfully.');
}
}Form Request Class
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Post::class);
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string', 'min:100'],
'category_id' => ['required', 'exists:categories,id'],
'tags' => ['nullable', 'array'],
'tags.*' => ['exists:tags,id'],
'published_at' => ['nullable', 'date', 'after:now'],
];
}
public function messages(): array
{
return [
'body.min' => 'The post body must be at least 100 characters.',
];
}
}Service Class Pattern
<?php
namespace App\Services;
use App\Models\User;
use App\Models\Post;
use App\Events\PostPublished;
use Illuminate\Support\Facades\DB;
class PostService
{
public function __construct(
private readonly NotificationService $notifications,
) {}
public function publish(Post $post): Post
{
return DB::transaction(function () use ($post) {
$post->update([
'published_at' => now(),
'status' => 'published',
]);
event(new PostPublished($post));
$this->notifications->notifyFollowers($post->author, $post);
return $post->fresh();
});
}
}Eloquent Model
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Builder;
class Post extends Model
{
use HasFactory;
protected $fillable = [
'title',
'slug',
'body',
'category_id',
'published_at',
];
protected $casts = [
'published_at' => 'datetime',
];
// Relationships
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class)->withTimestamps();
}
// Scopes
public function scopePublished(Builder $query): Builder
{
return $query->whereNotNull('published_at')
->where('published_at', '<=', now());
}
public function scopeByCategory(Builder $query, int $categoryId): Builder
{
return $query->where('category_id', $categoryId);
}
// Accessors & Mutators
protected function title(): Attribute
{
return Attribute::make(
set: fn (string $value) => ucfirst($value),
);
}
}Migration Best Practices
<?php
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('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('category_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->string('slug')->unique();
$table->text('body');
$table->timestamp('published_at')->nullable();
$table->timestamps();
// Indexes for common queries
$table->index(['user_id', 'published_at']);
$table->index('category_id');
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};Eager Loading
// N+1 Problem
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // Query per post
}
// Eager loading — only 3 queries total
$posts = Post::with(['author', 'category', 'tags'])->get();
foreach ($posts as $post) {
echo $post->author->name; // No additional queries
}
// Nested eager loading
$posts = Post::with([
'author.profile',
'comments.user',
'tags',
])->get();
// Constrained eager loading
$posts = Post::with([
'comments' => fn ($query) => $query->latest()->limit(5),
])->get();How to Use
Read individual rule files for detailed explanations and code examples:
rules/arch-service-classes.md
rules/eloquent-eager-loading.md
rules/validation-form-requests.md
rules/_sections.mdEach rule file contains:
- YAML frontmatter with metadata (title, impact, tags)
- Brief explanation of why it matters
- Bad Example with explanation
- Good Example with explanation
- Laravel 13 and PHP 8.3 specific context and references
Full Compiled Document
For the complete guide with all rules expanded: AGENTS.md
{
"version": "2.1.0",
"organization": "Laravel Community",
"date": "March 2026",
"laravelVersion": "13.x",
"phpVersion": "8.3+",
"rulesTotal": 31,
"abstract": "Comprehensive Laravel 13 best practices guide designed for AI agents and LLMs. Contains 31 rules across 7 categories, prioritized by impact from critical (architecture and database patterns) to incremental (performance optimization). Each rule includes detailed explanations, real-world examples with bad and good implementations using PHP 8.3 and Laravel 13 features, and specific impact metrics to guide automated refactoring and code generation. Focuses on modern Laravel patterns including typed properties, constructor property promotion, enums, readonly properties, PHP attributes, and vector search.",
"references": [
"https://laravel.com",
"https://laravel.com/docs/13.x",
"https://laravel.com/docs/13.x/eloquent",
"https://laravel.com/docs/13.x/controllers",
"https://laravel.com/docs/13.x/validation",
"https://laravel.com/docs/13.x/eloquent-relationships",
"https://laravel.com/docs/13.x/queries",
"https://laravel.com/docs/13.x/security",
"https://laravel.com/docs/13.x/queues",
"https://php.net/manual/en/language.types.declarations.php",
"https://github.com/laravel/laravel"
],
"categories": [
{
"name": "Architecture & Structure",
"prefix": "arch",
"impact": "CRITICAL",
"description": "Foundational patterns for organizing Laravel applications"
},
{
"name": "Eloquent & Database",
"prefix": "eloquent",
"impact": "CRITICAL",
"description": "Efficient database operations and ORM usage"
},
{
"name": "Controllers & Routing",
"prefix": "controller, ctrl",
"impact": "HIGH",
"description": "RESTful conventions and proper request handling"
},
{
"name": "Validation & Requests",
"prefix": "validation, valid",
"impact": "HIGH",
"description": "Form request classes and validation patterns"
},
{
"name": "Security",
"prefix": "sec",
"impact": "HIGH",
"description": "Protection against common vulnerabilities"
},
{
"name": "Performance",
"prefix": "perf",
"impact": "MEDIUM",
"description": "Caching strategies and optimization techniques"
},
{
"name": "API Design",
"prefix": "api",
"impact": "MEDIUM",
"description": "RESTful API patterns and resource transformers"
}
],
"keyFeatures": [
"Service classes for business logic separation",
"Eager loading to prevent N+1 queries",
"Form request classes for validation",
"Resource controllers following REST conventions",
"Eloquent relationships and query scopes",
"Mass assignment protection",
"API resources for response transformation",
"Modern PHP 8.3 syntax (readonly properties, constructor promotion)",
"Laravel 13 patterns and conventions",
"PHP attributes for middleware and authorization (Laravel 13)",
"Vector / semantic search with pgvector (Laravel 13)",
"Centralized queue routing (Laravel 13)"
]
}
Laravel 13 Best Practices
Comprehensive best practices for Laravel 13 applications.
Overview
This skill provides guidance for:
- Application architecture and structure
- Eloquent ORM and database patterns
- Controller and routing conventions
- Validation and form requests
- Security best practices
- Performance optimization
- API design patterns
Categories
1. Architecture & Structure (Critical)
Service classes, actions, repositories, and folder organization.
2. Eloquent & Database (Critical)
Eager loading, query scopes, migrations, and indexing.
3. Controllers & Routing (High)
Resource controllers, route model binding, and API resources.
4. Validation & Requests (High)
Form request classes, custom rules, and authorization.
5. Security (High)
Mass assignment, SQL injection, XSS, and authentication.
6. Performance (Medium)
Caching, queues, and database optimization.
7. API Design (Medium)
Versioning, resources, pagination, and error handling.
8. Testing (Low-Medium)
Feature tests, unit tests, factories, and mocking.
Quick Start
// Form Request
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Post::class);
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string'],
];
}
}
// Controller
class PostController extends Controller
{
public function store(StorePostRequest $request): RedirectResponse
{
$post = Post::create($request->validated());
return redirect()->route('posts.show', $post);
}
}Usage
This skill triggers automatically when:
- Creating Laravel controllers and models
- Writing migrations and queries
- Implementing validation
- Building APIs
References
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Architecture & Structure (arch)
Impact: CRITICAL Description: Foundational patterns for organizing Laravel applications. Service classes, action classes, DTOs, and proper separation of concerns are essential for maintainable, scalable codebases. These patterns determine long-term code quality and team productivity.
2. Eloquent & Database (eloquent)
Impact: CRITICAL Description: Efficient database operations and ORM usage. Preventing N+1 queries through eager loading, using chunking for large datasets, and proper relationship management are critical for performance. Poor database patterns can cripple application performance at scale.
3. Controllers & Routing (controller, ctrl)
Impact: HIGH Description: RESTful conventions, resource controllers, and proper request handling. Well-structured controllers following Laravel conventions improve code predictability, maintainability, and team collaboration. Thin controllers delegate to services for business logic.
4. Validation & Requests (validation, valid)
Impact: HIGH Description: Form request classes, custom validation rules, and authorization patterns. Proper validation ensures data integrity, security, and separation of concerns. Centralized validation logic in form requests keeps controllers clean and validation rules reusable.
5. Security (sec)
Impact: HIGH Description: Protection against common vulnerabilities including mass assignment, SQL injection, XSS, and CSRF attacks. Laravel provides excellent security features, but developers must use them correctly. Security issues can have catastrophic consequences.
6. Performance (perf)
Impact: MEDIUM Description: Caching strategies, queue usage, and optimization techniques for growing applications. While not critical initially, performance patterns become essential as applications scale. Proper caching and queue usage can provide 2-10× improvements.
7. API Design (api)
Impact: MEDIUM Description: RESTful API patterns, resource transformers, versioning, and consistent response formatting. Well-designed APIs are essential for frontend-backend communication, third-party integrations, and mobile applications. API resources provide consistent data transformation.
Rule Title Here
Impact: MEDIUM (optional impact description)
Brief explanation of the rule and why it matters in Laravel 13 applications. This should be clear and concise, explaining the performance, maintainability, or security implications. Focus on Laravel-specific context and patterns.
Bad Example
<?php
// Bad code example here
// Shows the antipattern or incorrect approach
class BadExample
{
public function badMethod()
{
// This demonstrates what NOT to do
}
}Good Example
<?php
// Good code example here
// Shows the recommended Laravel 13 pattern
class GoodExample
{
public function __construct(
private readonly DependencyService $service,
) {}
public function goodMethod(): ReturnType
{
// This demonstrates the correct approach
// Using modern PHP 8.3 and Laravel 13 features
}
}Additional context or variations (optional):
<?php
// Alternative patterns or edge cases
// Advanced usage examples
// Laravel 13 specific featuresWhy It Matters
- Benefit 1: Specific advantage
- Benefit 2: Performance/security/maintainability improvement
- Benefit 3: How it helps in real-world Laravel applications
Reference: Laravel 13 Documentation
Single-Purpose Action Classes
Impact: HIGH (Improves reusability and testability)
Use single-purpose action classes for discrete operations to achieve maximum reusability and testability.
Bad Example
// Service class doing too many things
class UserService
{
public function register(array $data): User
{
// Registration logic...
}
public function updateProfile(User $user, array $data): User
{
// Profile update logic...
}
public function deactivate(User $user): void
{
// Deactivation logic...
}
public function sendWelcomeEmail(User $user): void
{
// Email logic...
}
public function calculateStats(User $user): array
{
// Stats calculation...
}
}Good Example
// app/Actions/User/RegisterUserAction.php
namespace App\Actions\User;
use App\Models\User;
use App\Events\UserRegistered;
use Illuminate\Support\Facades\Hash;
class RegisterUserAction
{
public function __construct(
private SendWelcomeEmailAction $sendWelcomeEmail,
) {}
public function execute(array $data): User
{
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
]);
event(new UserRegistered($user));
$this->sendWelcomeEmail->execute($user);
return $user;
}
}// app/Actions/User/SendWelcomeEmailAction.php
namespace App\Actions\User;
use App\Models\User;
use App\Notifications\WelcomeNotification;
class SendWelcomeEmailAction
{
public function execute(User $user): void
{
$user->notify(new WelcomeNotification());
}
}// app/Actions/User/UpdateUserProfileAction.php
namespace App\Actions\User;
use App\Models\User;
use Illuminate\Support\Facades\Storage;
class UpdateUserProfileAction
{
public function execute(User $user, array $data): User
{
if (isset($data['avatar'])) {
$data['avatar_path'] = $this->storeAvatar($data['avatar']);
unset($data['avatar']);
}
$user->update($data);
return $user->fresh();
}
private function storeAvatar($avatar): string
{
return Storage::disk('public')->put('avatars', $avatar);
}
}// Controller using actions
namespace App\Http\Controllers;
use App\Actions\User\RegisterUserAction;
use App\Actions\User\UpdateUserProfileAction;
use App\Http\Requests\RegisterUserRequest;
use App\Http\Requests\UpdateUserProfileRequest;
use App\Http\Resources\UserResource;
class UserController extends Controller
{
public function store(
RegisterUserRequest $request,
RegisterUserAction $action
) {
$user = $action->execute($request->validated());
return new UserResource($user);
}
public function update(
UpdateUserProfileRequest $request,
UpdateUserProfileAction $action
) {
$user = $action->execute(
auth()->user(),
$request->validated()
);
return new UserResource($user);
}
}Why
- Single responsibility: Each action does exactly one thing
- Highly testable: Small, focused units are easy to test
- Reusable: Actions can be called from controllers, jobs, commands, or other actions
- Self-documenting: Action names clearly describe what they do
- Easy to find: Organized by domain in the Actions folder
- Composable: Actions can be combined to build complex workflows
Data Transfer Objects (DTOs)
Impact: MEDIUM (Type safety and validation between layers)
Use DTOs to transfer data between layers with type safety and validation.
Bad Example
// Passing arrays everywhere
class UserService
{
public function createUser(array $data): User
{
// No type safety, uncertain what keys exist
return User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
'phone' => $data['phone'] ?? null, // May or may not exist
'address' => $data['address'] ?? null,
]);
}
}
// Controller passing raw array
class UserController extends Controller
{
public function store(Request $request, UserService $service)
{
$user = $service->createUser($request->all());
return new UserResource($user);
}
}Good Example
// app/DTOs/CreateUserDTO.php
namespace App\DTOs;
use App\Http\Requests\CreateUserRequest;
use Illuminate\Support\Facades\Hash;
readonly class CreateUserDTO
{
public function __construct(
public string $name,
public string $email,
public string $password,
public ?string $phone = null,
public ?string $address = null,
) {}
public static function fromRequest(CreateUserRequest $request): self
{
return new self(
name: $request->validated('name'),
email: $request->validated('email'),
password: $request->validated('password'),
phone: $request->validated('phone'),
address: $request->validated('address'),
);
}
public static function fromArray(array $data): self
{
return new self(
name: $data['name'],
email: $data['email'],
password: $data['password'],
phone: $data['phone'] ?? null,
address: $data['address'] ?? null,
);
}
public function toArray(): array
{
return [
'name' => $this->name,
'email' => $this->email,
'password' => Hash::make($this->password),
'phone' => $this->phone,
'address' => $this->address,
];
}
}// app/Services/UserService.php
namespace App\Services;
use App\DTOs\CreateUserDTO;
use App\Models\User;
class UserService
{
public function createUser(CreateUserDTO $dto): User
{
return User::create($dto->toArray());
}
}// Controller creating DTO from request
namespace App\Http\Controllers;
use App\DTOs\CreateUserDTO;
use App\Http\Requests\CreateUserRequest;
use App\Http\Resources\UserResource;
use App\Services\UserService;
class UserController extends Controller
{
public function store(CreateUserRequest $request, UserService $service)
{
$dto = CreateUserDTO::fromRequest($request);
$user = $service->createUser($dto);
return new UserResource($user);
}
}// Complex DTO with nested objects
namespace App\DTOs;
readonly class OrderDTO
{
public function __construct(
public int $userId,
public AddressDTO $shippingAddress,
public AddressDTO $billingAddress,
/** @var OrderItemDTO[] */
public array $items,
public ?string $couponCode = null,
) {}
public static function fromRequest(CreateOrderRequest $request): self
{
return new self(
userId: auth()->id(),
shippingAddress: AddressDTO::fromArray($request->validated('shipping_address')),
billingAddress: AddressDTO::fromArray($request->validated('billing_address')),
items: array_map(
fn($item) => OrderItemDTO::fromArray($item),
$request->validated('items')
),
couponCode: $request->validated('coupon_code'),
);
}
}
readonly class AddressDTO
{
public function __construct(
public string $street,
public string $city,
public string $state,
public string $zipCode,
public string $country,
) {}
public static function fromArray(array $data): self
{
return new self(
street: $data['street'],
city: $data['city'],
state: $data['state'],
zipCode: $data['zip_code'],
country: $data['country'],
);
}
}
readonly class OrderItemDTO
{
public function __construct(
public int $productId,
public int $quantity,
public ?string $notes = null,
) {}
public static function fromArray(array $data): self
{
return new self(
productId: $data['product_id'],
quantity: $data['quantity'],
notes: $data['notes'] ?? null,
);
}
}Why
- Type safety: IDE autocompletion and static analysis support
- Self-documenting: DTO properties clearly define expected data
- Immutable: Using
readonlyprevents accidental modifications - Validation: Data is validated before DTO creation
- Refactoring: Easier to track data usage across the codebase
- Testing: Easy to create DTOs with known values for tests
Event-Driven Architecture
Impact: HIGH (Decouples components and enables async processing)
Use events and listeners to decouple components and handle side effects asynchronously.
Bad Example
// Tightly coupled code with mixed concerns
class OrderController extends Controller
{
public function store(StoreOrderRequest $request)
{
$order = Order::create($request->validated());
// Side effects directly in controller
Mail::to($order->user)->send(new OrderConfirmation($order));
// Inventory update
foreach ($order->items as $item) {
$item->product->decrement('stock', $item->quantity);
}
// Analytics tracking
Analytics::track('order_placed', [
'order_id' => $order->id,
'total' => $order->total,
]);
// Notify admin
$admin = User::where('role', 'admin')->first();
Notification::send($admin, new NewOrderNotification($order));
// Update customer loyalty points
$order->user->increment('loyalty_points', $order->total / 10);
// Sync to external systems
Http::post('https://crm.example.com/orders', $order->toArray());
Http::post('https://shipping.example.com/orders', $order->toArray());
return redirect()->route('orders.show', $order);
}
}Good Example
// Event class
namespace App\Events;
use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Broadcasting\InteractsWithSockets;
class OrderPlaced
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public readonly Order $order
) {}
}// Listeners for different concerns
namespace App\Listeners;
use App\Events\OrderPlaced;
use App\Mail\OrderConfirmation;
use Illuminate\Support\Facades\Mail;
use Illuminate\Contracts\Queue\ShouldQueue;
class SendOrderConfirmation implements ShouldQueue
{
public function handle(OrderPlaced $event): void
{
Mail::to($event->order->user)->send(
new OrderConfirmation($event->order)
);
}
}
class UpdateInventory implements ShouldQueue
{
public function handle(OrderPlaced $event): void
{
foreach ($event->order->items as $item) {
$item->product->decrement('stock', $item->quantity);
}
}
}
class TrackOrderAnalytics implements ShouldQueue
{
public function handle(OrderPlaced $event): void
{
Analytics::track('order_placed', [
'order_id' => $event->order->id,
'total' => $event->order->total,
'items_count' => $event->order->items->count(),
]);
}
}
class NotifyAdminOfNewOrder implements ShouldQueue
{
public function handle(OrderPlaced $event): void
{
$admins = User::where('role', 'admin')->get();
Notification::send($admins, new NewOrderNotification($event->order));
}
}
class UpdateLoyaltyPoints implements ShouldQueue
{
public function handle(OrderPlaced $event): void
{
$points = (int) ($event->order->total / 10);
$event->order->user->increment('loyalty_points', $points);
}
}
class SyncOrderToExternalSystems implements ShouldQueue
{
public $tries = 3;
public $backoff = [10, 60, 300];
public function handle(OrderPlaced $event): void
{
$this->syncToCRM($event->order);
$this->syncToShipping($event->order);
}
private function syncToCRM(Order $order): void
{
Http::post('https://crm.example.com/orders', $order->toArray());
}
private function syncToShipping(Order $order): void
{
Http::post('https://shipping.example.com/orders', $order->toArray());
}
}// Register listeners in AppServiceProvider::boot() (Laravel 11+)
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Event;
use App\Events\OrderPlaced;
use App\Listeners\SendOrderConfirmation;
use App\Listeners\UpdateInventory;
use App\Listeners\TrackOrderAnalytics;
use App\Listeners\NotifyAdminOfNewOrder;
use App\Listeners\UpdateLoyaltyPoints;
use App\Listeners\SyncOrderToExternalSystems;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Event::listen(OrderPlaced::class, [
SendOrderConfirmation::class,
UpdateInventory::class,
TrackOrderAnalytics::class,
NotifyAdminOfNewOrder::class,
UpdateLoyaltyPoints::class,
SyncOrderToExternalSystems::class,
]);
}
}// Clean controller
class OrderController extends Controller
{
public function store(StoreOrderRequest $request)
{
$order = Order::create($request->validated());
event(new OrderPlaced($order));
return redirect()->route('orders.show', $order);
}
}// Or dispatch from model using events property
class Order extends Model
{
protected $dispatchesEvents = [
'created' => OrderPlaced::class,
];
}Why
- Decoupling: Components don't know about each other
- Single responsibility: Each listener handles one concern
- Async processing: Heavy tasks run in background via queues
- Scalability: Easy to add new listeners without modifying existing code
- Testability: Test each listener in isolation
- Failure isolation: One listener failing doesn't affect others
- Open/closed principle: Open for extension, closed for modification
Feature Folders (Domain-Driven Structure)
Impact: MEDIUM (Better cohesion and discoverability)
Organize code by feature/domain rather than by type for better cohesion and discoverability.
Bad Example
app/
├── Http/
│ └── Controllers/
│ ├── OrderController.php
│ ├── ProductController.php
│ ├── UserController.php
│ ├── CartController.php
│ ├── PaymentController.php
│ └── ShippingController.php
├── Models/
│ ├── Order.php
│ ├── OrderItem.php
│ ├── Product.php
│ ├── User.php
│ ├── Cart.php
│ └── Payment.php
├── Services/
│ ├── OrderService.php
│ ├── ProductService.php
│ ├── CartService.php
│ ├── PaymentService.php
│ └── ShippingService.php
├── Repositories/
│ ├── OrderRepository.php
│ ├── ProductRepository.php
│ └── UserRepository.php
├── Events/
│ ├── OrderPlaced.php
│ ├── OrderShipped.php
│ ├── ProductCreated.php
│ └── PaymentProcessed.php
├── Listeners/
│ ├── SendOrderConfirmation.php
│ ├── UpdateInventory.php
│ └── NotifyShipping.php
└── Requests/
├── StoreOrderRequest.php
├── UpdateOrderRequest.php
├── StoreProductRequest.php
└── UpdateProductRequest.phpGood Example
app/
├── Domain/
│ ├── Order/
│ │ ├── Actions/
│ │ │ ├── CreateOrderAction.php
│ │ │ ├── CancelOrderAction.php
│ │ │ └── RefundOrderAction.php
│ │ ├── DTOs/
│ │ │ ├── CreateOrderDTO.php
│ │ │ └── OrderItemDTO.php
│ │ ├── Events/
│ │ │ ├── OrderPlaced.php
│ │ │ ├── OrderCancelled.php
│ │ │ └── OrderShipped.php
│ │ ├── Listeners/
│ │ │ ├── SendOrderConfirmation.php
│ │ │ └── UpdateInventory.php
│ │ ├── Models/
│ │ │ ├── Order.php
│ │ │ └── OrderItem.php
│ │ ├── Policies/
│ │ │ └── OrderPolicy.php
│ │ ├── Repositories/
│ │ │ ├── OrderRepositoryInterface.php
│ │ │ └── OrderRepository.php
│ │ └── Services/
│ │ └── OrderService.php
│ │
│ ├── Product/
│ │ ├── Actions/
│ │ │ ├── CreateProductAction.php
│ │ │ └── UpdateStockAction.php
│ │ ├── Models/
│ │ │ ├── Product.php
│ │ │ └── Category.php
│ │ ├── Repositories/
│ │ │ └── ProductRepository.php
│ │ └── Services/
│ │ └── ProductService.php
│ │
│ ├── Payment/
│ │ ├── Actions/
│ │ │ ├── ProcessPaymentAction.php
│ │ │ └── RefundPaymentAction.php
│ │ ├── Contracts/
│ │ │ └── PaymentGatewayInterface.php
│ │ ├── Gateways/
│ │ │ ├── StripeGateway.php
│ │ │ └── PayPalGateway.php
│ │ └── Models/
│ │ └── Payment.php
│ │
│ └── User/
│ ├── Actions/
│ │ ├── RegisterUserAction.php
│ │ └── UpdateProfileAction.php
│ ├── Models/
│ │ └── User.php
│ └── Services/
│ └── UserService.php
│
├── Http/
│ ├── Controllers/
│ │ ├── Order/
│ │ │ └── OrderController.php
│ │ ├── Product/
│ │ │ └── ProductController.php
│ │ └── User/
│ │ └── UserController.php
│ └── Requests/
│ ├── Order/
│ │ ├── StoreOrderRequest.php
│ │ └── UpdateOrderRequest.php
│ └── Product/
│ └── StoreProductRequest.php
│
└── Infrastructure/
├── Providers/
│ ├── OrderServiceProvider.php
│ └── PaymentServiceProvider.php
└── Caching/
└── CacheManager.php// Domain service provider for registering domain bindings
namespace App\Infrastructure\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use App\Domain\Order\Events\OrderPlaced;
use App\Domain\Order\Listeners\SendOrderConfirmation;
use App\Domain\Order\Listeners\UpdateInventory;
use App\Domain\Order\Repositories\OrderRepositoryInterface;
use App\Domain\Order\Repositories\OrderRepository;
class OrderServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(
OrderRepositoryInterface::class,
OrderRepository::class
);
}
public function boot(): void
{
// Register order-related event listeners
Event::listen(
OrderPlaced::class,
[SendOrderConfirmation::class, UpdateInventory::class]
);
}
}
// composer.json autoload section
{
"autoload": {
"psr-4": {
"App\\": "app/",
"Domain\\": "app/Domain/"
}
}
}Why
- Discoverability: All related code in one place
- Cohesion: High cohesion within feature, low coupling between features
- Team scalability: Teams can own entire features
- Bounded contexts: Clear boundaries between domains
- Refactoring: Easy to extract features into packages/microservices
- Navigation: Quickly find all code related to a feature
- Independence: Features can evolve independently
Queue Routing
Impact: MEDIUM (Centralized job queue/connection configuration)
Laravel 13 adds Queue::route() to define default queue and connection routing for jobs in a central place, eliminating scattered $connection and $queue properties across job classes.
Bad Example
// Queue/connection config scattered across every job class
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ProcessPodcast implements ShouldQueue
{
use Queueable;
public $connection = 'redis';
public $queue = 'podcasts';
public function handle(): void
{
// ...
}
}
class SendNewsletter implements ShouldQueue
{
use Queueable;
public $connection = 'redis';
public $queue = 'emails';
public function handle(): void
{
// ...
}
}
class GenerateReport implements ShouldQueue
{
use Queueable;
public $connection = 'sqs';
public $queue = 'reports';
public function handle(): void
{
// ...
}
}
// Problem: To change where podcasts are processed, you must find
// and update every job class individually.Good Example
// Centralized queue routing in a service provider (Laravel 13+)
namespace App\Providers;
use App\Jobs\GenerateReport;
use App\Jobs\ProcessPodcast;
use App\Jobs\SendNewsletter;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Define default queue/connection routing per job class
Queue::route(ProcessPodcast::class, connection: 'redis', queue: 'podcasts');
Queue::route(SendNewsletter::class, connection: 'redis', queue: 'emails');
Queue::route(GenerateReport::class, connection: 'sqs', queue: 'reports');
}
}// Job classes stay clean — no routing config needed
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ProcessPodcast implements ShouldQueue
{
use Queueable;
public function handle(): void
{
// Automatically dispatched to redis/podcasts
}
}// Dispatch normally — routing is applied automatically
ProcessPodcast::dispatch($podcast);
SendNewsletter::dispatch($newsletter);
GenerateReport::dispatch($report);Why
- Single source of truth: All queue routing in one place — easy to audit and change
- Clean job classes: Jobs contain only business logic, not infrastructure config
- Environment flexibility: Override routing per environment without touching job code
- Discoverable: New developers see all routing decisions in the service provider
Reference: Laravel 13 Documentation — Queues
Repository Pattern
Impact: MEDIUM (Abstracts data access from business logic)
Abstract database queries into repository classes to decouple business logic from data access.
Bad Example
// Eloquent queries scattered in controllers
class ProductController extends Controller
{
public function index(Request $request)
{
$products = Product::query()
->when($request->category, fn($q, $cat) => $q->where('category_id', $cat))
->when($request->min_price, fn($q, $price) => $q->where('price', '>=', $price))
->when($request->max_price, fn($q, $price) => $q->where('price', '<=', $price))
->when($request->search, fn($q, $search) => $q->where('name', 'like', "%{$search}%"))
->with(['category', 'reviews'])
->where('is_active', true)
->orderBy('created_at', 'desc')
->paginate(20);
return view('products.index', compact('products'));
}
public function featured()
{
// Same complex query duplicated
$products = Product::query()
->where('is_featured', true)
->where('is_active', true)
->with(['category', 'reviews'])
->orderBy('featured_at', 'desc')
->take(10)
->get();
return view('products.featured', compact('products'));
}
}Good Example
// Repository interface
namespace App\Repositories\Contracts;
use App\Models\Product;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
interface ProductRepositoryInterface
{
public function find(int $id): ?Product;
public function findOrFail(int $id): Product;
public function all(): Collection;
public function paginate(int $perPage = 15): LengthAwarePaginator;
public function search(array $filters): LengthAwarePaginator;
public function featured(int $limit = 10): Collection;
public function create(array $data): Product;
public function update(Product $product, array $data): Product;
public function delete(Product $product): bool;
}// Repository implementation
namespace App\Repositories;
use App\Models\Product;
use App\Repositories\Contracts\ProductRepositoryInterface;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
class ProductRepository implements ProductRepositoryInterface
{
public function __construct(
private Product $model
) {}
public function find(int $id): ?Product
{
return $this->model->find($id);
}
public function findOrFail(int $id): Product
{
return $this->model->findOrFail($id);
}
public function all(): Collection
{
return $this->model->active()->get();
}
public function paginate(int $perPage = 15): LengthAwarePaginator
{
return $this->model
->active()
->with(['category', 'reviews'])
->latest()
->paginate($perPage);
}
public function search(array $filters): LengthAwarePaginator
{
return $this->model
->active()
->when(
$filters['category'] ?? null,
fn($q, $cat) => $q->where('category_id', $cat)
)
->when(
$filters['min_price'] ?? null,
fn($q, $price) => $q->where('price', '>=', $price)
)
->when(
$filters['max_price'] ?? null,
fn($q, $price) => $q->where('price', '<=', $price)
)
->when(
$filters['search'] ?? null,
fn($q, $search) => $q->where('name', 'like', "%{$search}%")
)
->with(['category', 'reviews'])
->latest()
->paginate($filters['per_page'] ?? 20);
}
public function featured(int $limit = 10): Collection
{
return $this->model
->active()
->featured()
->with(['category', 'reviews'])
->orderBy('featured_at', 'desc')
->take($limit)
->get();
}
public function create(array $data): Product
{
return $this->model->create($data);
}
public function update(Product $product, array $data): Product
{
$product->update($data);
return $product->fresh();
}
public function delete(Product $product): bool
{
return $product->delete();
}
}// Bind in service provider
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class RepositoryServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(
\App\Repositories\Contracts\ProductRepositoryInterface::class,
\App\Repositories\ProductRepository::class
);
}
}// Clean controller
class ProductController extends Controller
{
public function __construct(
private ProductRepositoryInterface $products
) {}
public function index(Request $request)
{
$products = $this->products->search($request->all());
return view('products.index', compact('products'));
}
public function featured()
{
$products = $this->products->featured();
return view('products.featured', compact('products'));
}
}Why
- Testability: Easy to mock the repository interface in tests
- Reusability: Same queries used consistently across the application
- Maintainability: Query logic changes in one place
- Abstraction: Business logic doesn't depend on Eloquent specifics
- Swappable: Can swap implementations (e.g., cache decorator, different database)
- Clean controllers: Controllers only handle HTTP concerns
Service Classes for Business Logic
Impact: CRITICAL (Improves maintainability, testability, and code reusability)
Why It Matters
Controllers should be thin - they handle HTTP requests and delegate business logic. Service classes encapsulate complex business operations, making code reusable, testable, and maintainable.
Bad Example
// Fat controller with business logic
class OrderController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'items' => 'required|array',
'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|integer|min:1',
]);
DB::beginTransaction();
try {
// Calculate totals
$subtotal = 0;
foreach ($validated['items'] as $item) {
$product = Product::find($item['product_id']);
$subtotal += $product->price * $item['quantity'];
// Check stock
if ($product->stock < $item['quantity']) {
throw new Exception("Insufficient stock for {$product->name}");
}
}
$tax = $subtotal * 0.1;
$total = $subtotal + $tax;
// Create order
$order = Order::create([
'user_id' => auth()->id(),
'subtotal' => $subtotal,
'tax' => $tax,
'total' => $total,
]);
// Create order items and update stock
foreach ($validated['items'] as $item) {
$product = Product::find($item['product_id']);
OrderItem::create([
'order_id' => $order->id,
'product_id' => $product->id,
'quantity' => $item['quantity'],
'price' => $product->price,
]);
$product->decrement('stock', $item['quantity']);
}
// Send notifications
Mail::to($order->user)->send(new OrderConfirmation($order));
event(new OrderPlaced($order));
DB::commit();
return redirect()->route('orders.show', $order);
} catch (Exception $e) {
DB::rollBack();
return back()->withErrors(['error' => $e->getMessage()]);
}
}
}Good Example
// Service class with business logic
namespace App\Services;
use App\Models\Order;
use App\Models\Product;
use App\Events\OrderPlaced;
use App\Mail\OrderConfirmation;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use App\Exceptions\InsufficientStockException;
class OrderService
{
public function createOrder(array $items, int $userId): Order
{
return DB::transaction(function () use ($items, $userId) {
$this->validateStock($items);
$orderData = $this->calculateTotals($items);
$orderData['user_id'] = $userId;
$order = Order::create($orderData);
$this->createOrderItems($order, $items);
$this->updateProductStock($items);
$this->sendNotifications($order);
return $order;
});
}
private function validateStock(array $items): void
{
foreach ($items as $item) {
$product = Product::findOrFail($item['product_id']);
if ($product->stock < $item['quantity']) {
throw new InsufficientStockException(
"Insufficient stock for {$product->name}"
);
}
}
}
private function calculateTotals(array $items): array
{
$subtotal = collect($items)->sum(function ($item) {
$product = Product::find($item['product_id']);
return $product->price * $item['quantity'];
});
$tax = $subtotal * 0.1;
return [
'subtotal' => $subtotal,
'tax' => $tax,
'total' => $subtotal + $tax,
];
}
private function createOrderItems(Order $order, array $items): void
{
foreach ($items as $item) {
$product = Product::find($item['product_id']);
$order->items()->create([
'product_id' => $product->id,
'quantity' => $item['quantity'],
'price' => $product->price,
]);
}
}
private function updateProductStock(array $items): void
{
foreach ($items as $item) {
Product::where('id', $item['product_id'])
->decrement('stock', $item['quantity']);
}
}
private function sendNotifications(Order $order): void
{
Mail::to($order->user)->queue(new OrderConfirmation($order));
event(new OrderPlaced($order));
}
}// Thin controller
namespace App\Http\Controllers;
use App\Http\Requests\StoreOrderRequest;
use App\Services\OrderService;
use App\Exceptions\InsufficientStockException;
class OrderController extends Controller
{
public function __construct(
private readonly OrderService $orderService,
) {}
public function store(StoreOrderRequest $request)
{
try {
$order = $this->orderService->createOrder(
$request->validated('items'),
auth()->id()
);
return redirect()
->route('orders.show', $order)
->with('success', 'Order placed successfully!');
} catch (InsufficientStockException $e) {
return back()->withErrors(['stock' => $e->getMessage()]);
}
}
}Service Class Guidelines
class UserService
{
// Constructor injection
public function __construct(
private readonly NotificationService $notifications,
private readonly PaymentGateway $payments,
) {}
// Always declare return types
public function findOrFail(int $id): User
{
return User::findOrFail($id);
}
// Handle exceptions explicitly
public function processPayment(Order $order): PaymentResult
{
try {
return $this->payments->charge($order->total);
} catch (PaymentFailedException $e) {
Log::error('Payment failed', ['order' => $order->id, 'error' => $e->getMessage()]);
throw $e;
}
}
}When to Create a Service
- Complex business logic
- Operations involving multiple models
- Reusable operations (used in multiple controllers)
- Operations with side effects (email, events)
- Logic that needs unit testing
Benefits
- Testable in isolation (mock dependencies)
- Reusable across controllers, commands, jobs
- Single responsibility
- Easier to understand and maintain
Value Objects
Impact: MEDIUM (Enforces business rules and improves type safety)
Encapsulate domain concepts with value objects to enforce business rules and improve type safety.
Bad Example
// Primitive obsession - using strings/numbers for domain concepts
class User extends Model
{
public function setEmailAttribute(string $value): void
{
// Validation scattered across the codebase
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email');
}
$this->attributes['email'] = strtolower($value);
}
}
class Order extends Model
{
public function calculateTotal(): float
{
// Money as float - precision issues
return $this->subtotal + $this->tax - $this->discount;
}
public function applyDiscount(float $amount): void
{
// No validation of negative values
$this->discount = $amount;
}
}
// Phone number without structure
$user->phone = '+1-555-123-4567';
// Later...
$cleanPhone = preg_replace('/[^0-9]/', '', $user->phone); // Manual parsingGood Example
// Email value object
namespace App\ValueObjects;
use InvalidArgumentException;
readonly class Email
{
public function __construct(
private string $value
) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("Invalid email address: {$value}");
}
}
public function value(): string
{
return strtolower($this->value);
}
public function domain(): string
{
return substr($this->value, strpos($this->value, '@') + 1);
}
public function equals(Email $other): bool
{
return $this->value() === $other->value();
}
public function __toString(): string
{
return $this->value();
}
}// Money value object with proper precision
namespace App\ValueObjects;
use InvalidArgumentException;
readonly class Money
{
public function __construct(
private int $cents,
private string $currency = 'USD'
) {
if ($cents < 0) {
throw new InvalidArgumentException('Money cannot be negative');
}
}
public static function fromDollars(float $dollars, string $currency = 'USD'): self
{
return new self((int) round($dollars * 100), $currency);
}
public function cents(): int
{
return $this->cents;
}
public function dollars(): float
{
return $this->cents / 100;
}
public function currency(): string
{
return $this->currency;
}
public function add(Money $other): self
{
$this->ensureSameCurrency($other);
return new self($this->cents + $other->cents, $this->currency);
}
public function subtract(Money $other): self
{
$this->ensureSameCurrency($other);
return new self($this->cents - $other->cents, $this->currency);
}
public function multiply(float $factor): self
{
return new self((int) round($this->cents * $factor), $this->currency);
}
public function format(): string
{
return number_format($this->dollars(), 2) . ' ' . $this->currency;
}
private function ensureSameCurrency(Money $other): void
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Cannot operate on different currencies');
}
}
}// Phone number value object
namespace App\ValueObjects;
readonly class PhoneNumber
{
public function __construct(
private string $countryCode,
private string $number
) {}
public static function fromString(string $phone): self
{
$cleaned = preg_replace('/[^0-9+]/', '', $phone);
if (str_starts_with($cleaned, '+')) {
$countryCode = substr($cleaned, 0, 2);
$number = substr($cleaned, 2);
} else {
$countryCode = '+1';
$number = $cleaned;
}
return new self($countryCode, $number);
}
public function countryCode(): string
{
return $this->countryCode;
}
public function number(): string
{
return $this->number;
}
public function format(): string
{
return sprintf('%s-%s-%s',
$this->countryCode,
substr($this->number, 0, 3),
substr($this->number, 3)
);
}
public function __toString(): string
{
return $this->countryCode . $this->number;
}
}// Using value objects with Eloquent casts
namespace App\Casts;
use App\ValueObjects\Email;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
class EmailCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): ?Email
{
return $value ? new Email($value) : null;
}
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
{
if ($value instanceof Email) {
return $value->value();
}
return $value ? (new Email($value))->value() : null;
}
}// Model using value objects
class User extends Model
{
protected $casts = [
'email' => EmailCast::class,
];
}
// Usage
$user = new User();
$user->email = new Email('John@Example.com');
echo $user->email->domain(); // example.com
$price = Money::fromDollars(99.99);
$tax = $price->multiply(0.1);
$total = $price->add($tax);
echo $total->format(); // 109.99 USDWhy
- Encapsulation: Business rules live with the data they validate
- Type safety: Cannot pass wrong type accidentally
- Immutability: Value objects are safer to pass around
- Self-validating: Invalid states cannot exist
- Domain clarity: Code speaks the language of the business
- Reusability: Same value object used consistently everywhere
API Resources for Response Transformation
Impact: HIGH (Consistent API responses and data transformation)
Use API Resources to transform models into consistent JSON responses.
Bad Example
// Manual array transformation in controller
class UserController extends Controller
{
public function show(User $user)
{
return response()->json([
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'avatar_url' => $user->avatar ? asset('storage/' . $user->avatar) : null,
'created_at' => $user->created_at->toISOString(),
// Forgot to include some fields? Inconsistent across endpoints?
]);
}
public function index()
{
$users = User::paginate(20);
// Different transformation logic duplicated
return response()->json([
'data' => $users->map(function ($user) {
return [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
// Missing avatar_url? Inconsistent!
];
}),
'meta' => [
'total' => $users->total(),
'page' => $users->currentPage(),
],
]);
}
}Good Example
// app/Http/Resources/UserResource.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'avatar_url' => $this->avatar
? asset('storage/' . $this->avatar)
: null,
'email_verified' => !is_null($this->email_verified_at),
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
// Conditional attributes
'is_admin' => $this->when($request->user()?->isAdmin(), $this->is_admin),
// Include relationships when loaded
'posts' => PostResource::collection($this->whenLoaded('posts')),
'profile' => new ProfileResource($this->whenLoaded('profile')),
// Counts
'posts_count' => $this->whenCounted('posts'),
// Pivot data
'role' => $this->whenPivotLoaded('role_user', function () {
return $this->pivot->role;
}),
];
}
/**
* Add additional data to the response.
*/
public function with(Request $request): array
{
return [
'meta' => [
'api_version' => '1.0',
],
];
}
}
// app/Http/Resources/UserCollection.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class UserCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'meta' => [
'total_users' => $this->collection->count(),
'has_admins' => $this->collection->contains('is_admin', true),
],
];
}
}
// app/Http/Resources/PostResource.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Str;
class PostResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'excerpt' => Str::limit($this->body, 150),
'body' => $this->when(
$request->routeIs('posts.show'),
$this->body
),
'published_at' => $this->published_at?->toISOString(),
'is_published' => $this->isPublished(),
// Always include author summary
'author' => [
'id' => $this->author->id,
'name' => $this->author->name,
],
// Full author resource when loaded
'author_full' => new UserResource($this->whenLoaded('author')),
'comments' => CommentResource::collection($this->whenLoaded('comments')),
'tags' => TagResource::collection($this->whenLoaded('tags')),
'links' => [
'self' => route('posts.show', $this->resource),
],
];
}
}
// Clean API controller
namespace App\Http\Controllers;
use App\Http\Resources\UserResource;
use App\Models\User;
class UserController extends Controller
{
public function index()
{
$users = User::with('profile')
->withCount('posts')
->paginate(20);
return UserResource::collection($users);
}
public function show(User $user)
{
$user->load(['posts' => fn($q) => $q->latest()->limit(5), 'profile']);
return new UserResource($user);
}
public function store(StoreUserRequest $request)
{
$user = User::create($request->validated());
return new UserResource($user);
}
}Response structure is consistent:
{
"data": {
"id": 1,
"name": "John Doe",
"email": "john@example.com",
"avatar_url": "https://...",
"created_at": "2024-01-15T10:30:00.000000Z"
},
"meta": {
"api_version": "1.0"
}
}JSON:API Resources (Laravel 13+)
Laravel 13 adds first-party JSON:API resource support for APIs that need JSON:API specification compliance. Use these when building APIs that must follow the JSON:API spec; for standard APIs, JsonResource above remains the recommended approach.
// JSON:API resources handle:
// - Resource object serialization with type/id/attributes
// - Relationship inclusion (included member)
// - Sparse fieldsets (?fields[users]=name,email)
// - JSON:API-compliant links and response headers
// Use JsonResource for most APIs.
// Use JSON:API resources only when clients require JSON:API compliance.Why
- Consistency: Same model always produces same JSON structure
- Reusability: Resource used across multiple endpoints
- Conditional data: Include data based on context
- Relationships: Handle nested resources elegantly
- Pagination: Automatic pagination metadata
- API versioning: Easy to create v2 resources
- Documentation: Resource classes document your API structure
Dependency Injection in Controllers
Impact: HIGH (Testable and loosely coupled controllers)
Use constructor and method injection to provide dependencies instead of using facades or creating instances.
Bad Example
// Using facades and manual instantiation
class OrderController extends Controller
{
public function store(Request $request)
{
// Tight coupling to concrete classes
$paymentGateway = new StripePaymentGateway();
$result = $paymentGateway->charge($request->total);
// Static facade calls - hard to mock
$order = Order::create($request->validated());
Mail::to($order->user)->send(new OrderConfirmation($order));
Log::info('Order created', ['order_id' => $order->id]);
Cache::forget('user-orders-' . auth()->id());
return redirect()->route('orders.show', $order);
}
public function export()
{
// Creating services manually
$exporter = new CsvExporter();
$orders = Order::all();
return $exporter->export($orders);
}
}Good Example
// Constructor injection for shared dependencies
class OrderController extends Controller
{
public function __construct(
private readonly PaymentGatewayInterface $paymentGateway,
private readonly OrderService $orderService,
) {}
// Method injection for action-specific dependencies
public function store(
StoreOrderRequest $request,
NotificationService $notifications
) {
$order = $this->orderService->create(
$request->validated(),
auth()->user()
);
$notifications->sendOrderConfirmation($order);
return redirect()->route('orders.show', $order);
}
public function export(CsvExporter $exporter)
{
$orders = Order::with('items')->get();
return $exporter->export($orders);
}
}// Interface binding in service provider
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(
PaymentGatewayInterface::class,
StripePaymentGateway::class
);
// Conditional binding
$this->app->bind(PaymentGatewayInterface::class, function ($app) {
return match (config('payments.default')) {
'stripe' => new StripePaymentGateway(config('payments.stripe')),
'paypal' => new PayPalPaymentGateway(config('payments.paypal')),
default => throw new InvalidArgumentException('Invalid payment gateway'),
};
});
}
}// Service class with injected dependencies
namespace App\Services;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class OrderService
{
public function __construct(
private readonly PaymentGatewayInterface $payment,
private readonly InventoryService $inventory,
private readonly LoggerInterface $logger,
) {}
public function create(array $data, User $user): Order
{
return DB::transaction(function () use ($data, $user) {
$order = Order::create([
'user_id' => $user->id,
...$data,
]);
$this->inventory->reserve($order->items);
$this->logger->info('Order created', ['order_id' => $order->id]);
return $order;
});
}
}// Testing with injected dependencies
class OrderControllerTest extends TestCase
{
public function test_store_creates_order_and_charges_payment()
{
// Mock the payment gateway
$mockGateway = $this->mock(PaymentGatewayInterface::class);
$mockGateway->expects('charge')
->once()
->with(100.00)
->andReturn(new PaymentResult(success: true));
$response = $this->actingAs($user)
->post('/orders', [
'items' => [...],
'total' => 100.00,
]);
$response->assertRedirect();
}
}// Route model binding is also dependency injection
class PostController extends Controller
{
// Laravel automatically resolves {post} from the route
public function show(Post $post)
{
return view('posts.show', compact('post'));
}
// Custom resolution
public function showBySlug(Post $post)
{
return view('posts.show', compact('post'));
}
}
// In RouteServiceProvider or model
public function resolveRouteBinding($value, $field = null)
{
return $this->where($field ?? 'slug', $value)->firstOrFail();
}Why
- Testability: Dependencies easily mocked in tests
- Loose coupling: Code depends on interfaces, not implementations
- Flexibility: Swap implementations via configuration
- Explicit dependencies: Clear what a class needs to function
- Single responsibility: Controller doesn't create its dependencies
- IDE support: Type hints enable autocompletion and refactoring
Form Requests in Controllers
Impact: HIGH (Separates validation logic from controllers)
Move validation and authorization logic from controllers to dedicated Form Request classes.
Bad Example
// Validation logic cluttering the controller
class ArticleController extends Controller
{
public function store(Request $request)
{
// Validation in controller
$validated = $request->validate([
'title' => 'required|string|max:255|unique:articles,title',
'slug' => 'required|string|max:255|unique:articles,slug',
'body' => 'required|string|min:100',
'category_id' => 'required|exists:categories,id',
'tags' => 'array',
'tags.*' => 'exists:tags,id',
'published_at' => 'nullable|date|after:now',
'featured_image' => 'nullable|image|max:2048',
]);
// Authorization mixed in
if (!auth()->user()->can('create', Article::class)) {
abort(403);
}
$article = Article::create($validated);
return redirect()->route('articles.show', $article);
}
public function update(Request $request, Article $article)
{
// Same validation duplicated
$validated = $request->validate([
'title' => 'required|string|max:255|unique:articles,title,' . $article->id,
'slug' => 'required|string|max:255|unique:articles,slug,' . $article->id,
'body' => 'required|string|min:100',
'category_id' => 'required|exists:categories,id',
'tags' => 'array',
'tags.*' => 'exists:tags,id',
'published_at' => 'nullable|date',
'featured_image' => 'nullable|image|max:2048',
]);
$article->update($validated);
return redirect()->route('articles.show', $article);
}
}Good Example
// Store request
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class StoreArticleRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()->can('create', Article::class);
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255', 'unique:articles,title'],
'slug' => ['required', 'string', 'max:255', 'unique:articles,slug'],
'body' => ['required', 'string', 'min:100'],
'category_id' => ['required', 'exists:categories,id'],
'tags' => ['array'],
'tags.*' => ['exists:tags,id'],
'published_at' => ['nullable', 'date', 'after:now'],
'featured_image' => ['nullable', 'image', 'max:2048'],
];
}
/**
* Get custom attributes for validator errors.
*/
public function attributes(): array
{
return [
'category_id' => 'category',
'published_at' => 'publication date',
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'title.unique' => 'An article with this title already exists.',
'body.min' => 'The article body must be at least :min characters.',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'slug' => $this->slug ?? Str::slug($this->title),
]);
}
/**
* Handle a passed validation attempt.
*/
protected function passedValidation(): void
{
// Called after validation passes — use for side effects, not data modification
// To add extra fields like user_id, do so in the controller:
// Article::create([...$request->validated(), 'user_id' => $request->user()->id])
}
}// Update request extending store
namespace App\Http\Requests;
class UpdateArticleRequest extends StoreArticleRequest
{
public function authorize(): bool
{
return $this->user()->can('update', $this->route('article'));
}
public function rules(): array
{
$article = $this->route('article');
return [
...parent::rules(),
'title' => ['required', 'string', 'max:255', "unique:articles,title,{$article->id}"],
'slug' => ['required', 'string', 'max:255', "unique:articles,slug,{$article->id}"],
'published_at' => ['nullable', 'date'], // Remove 'after:now' for updates
];
}
}// Clean controller
class ArticleController extends Controller
{
public function store(StoreArticleRequest $request)
{
$article = Article::create($request->validated());
return redirect()->route('articles.show', $article)
->with('success', 'Article created successfully');
}
public function update(UpdateArticleRequest $request, Article $article)
{
$article->update($request->validated());
return redirect()->route('articles.show', $article)
->with('success', 'Article updated successfully');
}
}# Generate form request
php artisan make:request StoreArticleRequestWhy
- Separation of concerns: Controllers handle HTTP, requests handle validation
- Reusability: Same validation rules in multiple places
- Testability: Validation logic tested independently
- Authorization: Access control in one place
- Custom messages: User-friendly error messages
- Data preparation: Transform input before validation
- Thin controllers: Controllers stay focused on their job
Controller Middleware
Impact: MEDIUM (Reusable request filtering and modification)
Use middleware to handle cross-cutting concerns like authentication, rate limiting, and request modification.
Bad Example
// Authentication and authorization checks in every method
class AdminController extends Controller
{
public function index()
{
if (!auth()->check()) {
return redirect()->route('login');
}
if (!auth()->user()->isAdmin()) {
abort(403);
}
return view('admin.dashboard');
}
public function users()
{
if (!auth()->check()) {
return redirect()->route('login');
}
if (!auth()->user()->isAdmin()) {
abort(403);
}
return view('admin.users');
}
public function settings()
{
if (!auth()->check()) {
return redirect()->route('login');
}
if (!auth()->user()->isAdmin()) {
abort(403);
}
return view('admin.settings');
}
}Good Example
// PHP Attributes (Laravel 13+) — preferred declarative approach
use App\Models\Comment;
use App\Models\Post;
use Illuminate\Routing\Attributes\Controllers\Authorize;
use Illuminate\Routing\Attributes\Controllers\Middleware;
#[Middleware('auth')]
#[Middleware('admin')]
class AdminController extends Controller
{
public function index()
{
return view('admin.dashboard');
}
#[Middleware('verified')]
#[Authorize('manage', [Post::class])]
public function posts()
{
return view('admin.posts');
}
#[Middleware('subscribed')]
#[Authorize('create', [Comment::class, 'post'])]
public function store(Post $post)
{
// ...
}
}// HasMiddleware interface (Laravel 11+) — still supported
use Illuminate\Routing\Controllers\HasMiddleware;
use Illuminate\Routing\Controllers\Middleware;
class AdminController extends Controller implements HasMiddleware
{
public static function middleware(): array
{
return [
'auth',
'admin',
new Middleware('verified', only: ['store', 'update']),
new Middleware('throttle:10,1', only: ['store']),
];
}
public function index()
{
return view('admin.dashboard');
}
public function users()
{
return view('admin.users');
}
}// Using middleware in routes
Route::middleware(['auth', 'admin'])->group(function () {
Route::get('/admin', [AdminController::class, 'index']);
Route::resource('admin/users', AdminUserController::class);
});// Custom middleware
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsAdmin
{
public function handle(Request $request, Closure $next): Response
{
if (!$request->user()?->isAdmin()) {
abort(403, 'Unauthorized. Admin access required.');
}
return $next($request);
}
}// Register middleware alias in bootstrap/app.php (Laravel 11+)
// Note: The $middleware parameter here is Illuminate\Foundation\Configuration\Middleware,
// not Illuminate\Routing\Controllers\Middleware used above in the controller.
return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'admin' => EnsureUserIsAdmin::class,
'subscribed' => EnsureUserIsSubscribed::class,
]);
})
->create();// Middleware with parameters
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckRole
{
public function handle(Request $request, Closure $next, string ...$roles): Response
{
if (!$request->user()?->hasAnyRole($roles)) {
abort(403);
}
return $next($request);
}
}// Usage with parameters
Route::get('/reports', [ReportController::class, 'index'])
->middleware('role:admin,manager');
// Middleware for API rate limiting
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
Route::apiResource('posts', PostController::class);
});// Conditional middleware using HasMiddleware
class ApiController extends Controller implements HasMiddleware
{
public static function middleware(): array
{
return [
'auth:sanctum',
];
}
}// Middleware groups for common patterns
// bootstrap/app.php
$middleware->group('api', [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
]);Why
- DRY: Authentication/authorization logic in one place
- Reusability: Same middleware used across multiple controllers
- Separation of concerns: Controllers focus on business logic
- Composability: Stack multiple middleware for complex requirements
- Testability: Middleware tested independently
- Declarative: PHP attributes (Laravel 13+) make middleware and authorization visible at the class/method level
- Colocated:
#[Authorize]keeps policy checks next to the method they protect
Use Resource Controllers
Impact: HIGH (RESTful conventions and consistent routing)
Why It Matters
Resource controllers provide a consistent, RESTful structure for CRUD operations. They follow Laravel conventions, making code predictable and easier for other developers to understand.
Bad Example
// Inconsistent naming and structure
Route::get('/posts', [PostController::class, 'getAllPosts']);
Route::get('/posts/{id}', [PostController::class, 'getPost']);
Route::post('/posts/new', [PostController::class, 'createPost']);
Route::put('/posts/{id}/edit', [PostController::class, 'updatePost']);
Route::delete('/posts/{id}/delete', [PostController::class, 'removePost']);
// Non-standard controller methods
class PostController extends Controller
{
public function getAllPosts() { }
public function getPost($id) { }
public function createPost() { }
public function updatePost($id) { }
public function removePost($id) { }
}Good Example
Resource Route
// Single line defines all 7 RESTful routes
Route::resource('posts', PostController::class);
// Generated routes:
// GET /posts index posts.index
// GET /posts/create create posts.create
// POST /posts store posts.store
// GET /posts/{post} show posts.show
// GET /posts/{post}/edit edit posts.edit
// PUT /posts/{post} update posts.update
// DELETE /posts/{post} destroy posts.destroyResource Controller
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use App\Models\Post;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class PostController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(): View
{
$posts = Post::with('author')
->latest()
->paginate(15);
return view('posts.index', compact('posts'));
}
/**
* Show the form for creating a new resource.
*/
public function create(): View
{
$categories = Category::all();
return view('posts.create', compact('categories'));
}
/**
* Store a newly created resource in storage.
*/
public function store(StorePostRequest $request): RedirectResponse
{
$post = Post::create([
...$request->validated(),
'user_id' => auth()->id(),
]);
return redirect()
->route('posts.show', $post)
->with('success', 'Post created successfully.');
}
/**
* Display the specified resource.
*/
public function show(Post $post): View
{
$post->load(['author', 'comments.user', 'tags']);
return view('posts.show', compact('post'));
}
/**
* Show the form for editing the specified resource.
*/
public function edit(Post $post): View
{
$this->authorize('update', $post);
$categories = Category::all();
return view('posts.edit', compact('post', 'categories'));
}
/**
* Update the specified resource in storage.
*/
public function update(UpdatePostRequest $request, Post $post): RedirectResponse
{
$post->update($request->validated());
return redirect()
->route('posts.show', $post)
->with('success', 'Post updated successfully.');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Post $post): RedirectResponse
{
$this->authorize('delete', $post);
$post->delete();
return redirect()
->route('posts.index')
->with('success', 'Post deleted successfully.');
}
}Partial Resource Routes
// Only specific actions
Route::resource('posts', PostController::class)
->only(['index', 'show']);
// All except specific actions
Route::resource('posts', PostController::class)
->except(['destroy']);API Resource Controller
// API routes (no create/edit - those are for forms)
Route::apiResource('posts', Api\PostController::class);
// Generated routes:
// GET /posts index
// POST /posts store
// GET /posts/{post} show
// PUT /posts/{post} update
// DELETE /posts/{post} destroyNested Resources
// Nested resource routes
Route::resource('posts.comments', CommentController::class);
// Generated: /posts/{post}/comments/{comment}
// Shallow nesting (recommended)
Route::resource('posts.comments', CommentController::class)->shallow();
// Generated:
// /posts/{post}/comments (index, store)
// /comments/{comment} (show, update, destroy)Generate Resource Controller
# Generate with all methods
php artisan make:controller PostController --resource
# Generate with model binding
php artisan make:controller PostController --resource --model=Post
# Generate API controller
php artisan make:controller Api/PostController --api --model=PostRoute Model Binding
// Automatic model binding - Laravel resolves Post from {post}
public function show(Post $post): View
{
// $post is automatically fetched or 404
return view('posts.show', compact('post'));
}
// Custom binding key
// Route: /posts/{post:slug}
public function show(Post $post): View
{
// Resolved by slug instead of id
}
// In model
class Post extends Model
{
public function getRouteKeyName(): string
{
return 'slug';
}
}Benefits
- Consistent URL structure
- Predictable controller methods
- Automatic route model binding
- Easy to generate views (posts.index, posts.show, etc.)
- Clear conventions for team collaboration
RESTful Resource Methods
Impact: HIGH (Standard CRUD operations following REST conventions)
Use resource controllers with standard RESTful methods for CRUD operations.
Bad Example
// Non-standard method names
class ArticleController extends Controller
{
public function list() { /* ... */ }
public function view($id) { /* ... */ }
public function add() { /* ... */ }
public function save(Request $request) { /* ... */ }
public function modify($id) { /* ... */ }
public function change(Request $request, $id) { /* ... */ }
public function remove($id) { /* ... */ }
}
// Inconsistent routes
Route::get('/articles', [ArticleController::class, 'list']);
Route::get('/articles/{id}', [ArticleController::class, 'view']);
Route::get('/articles/add', [ArticleController::class, 'add']);
Route::post('/articles/save', [ArticleController::class, 'save']);
Route::get('/articles/{id}/modify', [ArticleController::class, 'modify']);
Route::post('/articles/{id}/change', [ArticleController::class, 'change']);
Route::post('/articles/{id}/remove', [ArticleController::class, 'remove']);Good Example
// Standard resource controller
namespace App\Http\Controllers;
class ArticleController extends Controller
{
/**
* Display a listing of articles.
* GET /articles
*/
public function index()
{
$articles = Article::with('author')
->published()
->latest()
->paginate(20);
return view('articles.index', compact('articles'));
}
/**
* Show the form for creating a new article.
* GET /articles/create
*/
public function create()
{
$categories = Category::all();
return view('articles.create', compact('categories'));
}
/**
* Store a newly created article.
* POST /articles
*/
public function store(StoreArticleRequest $request)
{
$article = auth()->user()->articles()->create(
$request->validated()
);
return redirect()
->route('articles.show', $article)
->with('success', 'Article created successfully');
}
/**
* Display the specified article.
* GET /articles/{article}
*/
public function show(Article $article)
{
$article->load(['author', 'comments.user']);
return view('articles.show', compact('article'));
}
/**
* Show the form for editing the specified article.
* GET /articles/{article}/edit
*/
public function edit(Article $article)
{
$this->authorize('update', $article);
$categories = Category::all();
return view('articles.edit', compact('article', 'categories'));
}
/**
* Update the specified article.
* PUT/PATCH /articles/{article}
*/
public function update(UpdateArticleRequest $request, Article $article)
{
$article->update($request->validated());
return redirect()
->route('articles.show', $article)
->with('success', 'Article updated successfully');
}
/**
* Remove the specified article.
* DELETE /articles/{article}
*/
public function destroy(Article $article)
{
$this->authorize('delete', $article);
$article->delete();
return redirect()
->route('articles.index')
->with('success', 'Article deleted successfully');
}
}
// Simple resource route
Route::resource('articles', ArticleController::class);
// Partial resource
Route::resource('articles', ArticleController::class)
->only(['index', 'show']);
Route::resource('articles', ArticleController::class)
->except(['create', 'edit']);
// API resource (without create/edit)
Route::apiResource('articles', ArticleController::class);
// Nested resources
Route::resource('articles.comments', CommentController::class);
// Creates: articles/{article}/comments
// Shallow nesting
Route::resource('articles.comments', CommentController::class)->shallow();
// Nests only index, create, store
// Uses /comments/{comment} for show, update, destroy
// Named routes are automatic:
// articles.index, articles.create, articles.store
// articles.show, articles.edit, articles.update
// articles.destroyWhy
- Convention over configuration: Standard names everyone understands
- Automatic routing: Single route declaration handles all methods
- Named routes: Automatic, predictable route names
- HTTP verbs: Proper use of GET, POST, PUT, DELETE
- Framework support: Form method spoofing, CSRF protection built-in
- Team consistency: All developers use the same patterns
- Documentation: Self-documenting API following REST conventions
Single Action Controllers
Impact: MEDIUM (Focused controllers with single responsibility)
Use invokable controllers for actions that don't fit RESTful resource methods.
Bad Example
// Controller with unrelated methods
class UserController extends Controller
{
public function index() { /* list users */ }
public function show(User $user) { /* show user */ }
public function store(Request $request) { /* create user */ }
// Non-RESTful actions crammed into resource controller
public function exportToCsv() { /* ... */ }
public function importFromCsv(Request $request) { /* ... */ }
public function sendNewsletter(User $user) { /* ... */ }
public function generateReport() { /* ... */ }
public function toggleStatus(User $user) { /* ... */ }
}
// Routes become messy
Route::get('/users/export', [UserController::class, 'exportToCsv']);
Route::post('/users/import', [UserController::class, 'importFromCsv']);
Route::post('/users/{user}/newsletter', [UserController::class, 'sendNewsletter']);Good Example
// Single action (invokable) controller
namespace App\Http\Controllers\User;
class ExportUsersController extends Controller
{
public function __invoke(ExportUsersRequest $request)
{
$users = User::query()
->when($request->role, fn($q, $role) => $q->where('role', $role))
->get();
return Excel::download(
new UsersExport($users),
'users-' . now()->format('Y-m-d') . '.csv'
);
}
}// Another single action controller
namespace App\Http\Controllers\User;
class ImportUsersController extends Controller
{
public function __construct(
private UserImportService $importService
) {}
public function __invoke(ImportUsersRequest $request)
{
$result = $this->importService->import(
$request->file('csv')
);
return back()->with('success', "{$result->count} users imported");
}
}// Newsletter sending as single action
namespace App\Http\Controllers\User;
class SendUserNewsletterController extends Controller
{
public function __invoke(User $user, SendNewsletterRequest $request)
{
SendNewsletterJob::dispatch($user, $request->validated());
return back()->with('success', 'Newsletter queued for sending');
}
}// Toggle status as single action
namespace App\Http\Controllers\User;
class ToggleUserStatusController extends Controller
{
public function __invoke(User $user)
{
$user->update([
'is_active' => !$user->is_active,
]);
return back()->with('success', 'User status updated');
}
}// Clean routes
Route::resource('users', UserController::class);
// Single action routes (no method needed)
Route::get('users/export', ExportUsersController::class)
->name('users.export');
Route::post('users/import', ImportUsersController::class)
->name('users.import');
Route::post('users/{user}/newsletter', SendUserNewsletterController::class)
->name('users.send-newsletter');
Route::patch('users/{user}/toggle-status', ToggleUserStatusController::class)
->name('users.toggle-status');# Generate single action controller
php artisan make:controller ExportUsersController --invokableWhy
- Single responsibility: Each controller does exactly one thing
- Focused testing: Easy to test one action in isolation
- Clear naming: Controller name describes exactly what it does
- Organization: Actions grouped in folders by domain
- Discoverable: Find the action by its descriptive name
- Smaller files: Each controller is small and focused
- Better routes: Routes are cleaner without specifying method names
Eloquent Accessors and Mutators
Impact: MEDIUM (Clean data transformation at model layer)
Use accessors and mutators to transform attribute values when getting or setting them.
Bad Example
// Manual transformations scattered in code
class UserController extends Controller
{
public function show(User $user)
{
// Manual formatting everywhere
$fullName = $user->first_name . ' ' . $user->last_name;
$formattedPhone = preg_replace('/(\d{3})(\d{3})(\d{4})/', '($1) $2-$3', $user->phone);
return view('users.show', [
'fullName' => $fullName,
'formattedPhone' => $formattedPhone,
]);
}
public function store(Request $request)
{
// Manual normalization
User::create([
'email' => strtolower(trim($request->email)),
'phone' => preg_replace('/[^0-9]/', '', $request->phone),
'name' => ucwords(strtolower($request->name)),
]);
}
}In views, more manual formatting:
<p>{{ strtoupper($user->first_name) }} {{ strtoupper($user->last_name) }}</p>Good Example
// Modern accessors and mutators (Laravel 9+)
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
// Accessor - computed attribute
protected function fullName(): Attribute
{
return Attribute::make(
get: fn () => "{$this->first_name} {$this->last_name}",
);
}
// Mutator - transform on set
protected function email(): Attribute
{
return Attribute::make(
get: fn (string $value) => $value,
set: fn (string $value) => strtolower(trim($value)),
);
}
// Accessor with formatting
protected function phone(): Attribute
{
return Attribute::make(
get: function (string $value) {
return preg_replace('/(\d{3})(\d{3})(\d{4})/', '($1) $2-$3', $value);
},
set: function (string $value) {
return preg_replace('/[^0-9]/', '', $value);
},
);
}
// Accessor for formatted dates
protected function birthDate(): Attribute
{
return Attribute::make(
get: fn ($value) => Carbon::parse($value)->format('F j, Y'),
);
}
// Accessor with caching for expensive operations
protected function profileCompleteness(): Attribute
{
return Attribute::make(
get: function () {
$fields = ['name', 'email', 'phone', 'avatar', 'bio'];
$filled = collect($fields)->filter(fn ($field) => !empty($this->$field))->count();
return ($filled / count($fields)) * 100;
},
)->shouldCache();
}
// Accessor that depends on relationships
protected function totalOrders(): Attribute
{
return Attribute::make(
get: fn () => $this->orders()->count(),
);
}
// Make accessors available in JSON/arrays
protected $appends = ['full_name', 'profile_completeness'];
}
// Usage is clean and consistent
$user = User::find(1);
echo $user->full_name; // "John Doe"
echo $user->phone; // "(555) 123-4567"
echo $user->profile_completeness; // 80
// Mutators work automatically on assignment
$user->email = ' JOHN@EXAMPLE.COM ';
$user->save();
// Stored as: john@example.com
In views — just use attributes directly:
<p>{{ $user->full_name }}</p>
<p>{{ $user->phone }}</p>// Legacy syntax (still works but not recommended for new code)
class User extends Model
{
// Legacy accessor
public function getFullNameAttribute(): string
{
return "{$this->first_name} {$this->last_name}";
}
// Legacy mutator
public function setEmailAttribute(string $value): void
{
$this->attributes['email'] = strtolower(trim($value));
}
}Why
- Consistency: Data transformation happens in one place
- Clean code: No repeated formatting logic in views or controllers
- Encapsulation: Models handle their own data presentation
- Automatic: Works on assignment and retrieval without extra code
- Computed attributes: Create virtual attributes from existing data
- Serialization: Appended accessors included in JSON automatically
Eloquent Attribute Casting
Impact: HIGH (Automatic type conversion and data handling)
Use Eloquent casts to automatically convert attributes to appropriate types.
Bad Example
// Manual type handling
class Order extends Model
{
public function getMetadataAttribute($value)
{
return json_decode($value, true);
}
public function setMetadataAttribute($value)
{
$this->attributes['metadata'] = json_encode($value);
}
public function getIsPaidAttribute($value)
{
return (bool) $value;
}
public function getTotalAttribute($value)
{
return (float) $value;
}
}
// In controller
$order = Order::find(1);
$isPaid = (bool) $order->is_paid;
$total = (float) $order->total;
$createdAt = Carbon::parse($order->created_at);Good Example
// Built-in casts
namespace App\Models;
use App\Enums\OrderStatus;
use App\Enums\PaymentMethod;
use Illuminate\Database\Eloquent\Casts\AsStringable;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
protected $casts = [
// Primitives
'is_paid' => 'boolean',
'total' => 'decimal:2',
'quantity' => 'integer',
'rating' => 'float',
// Arrays and JSON
'metadata' => 'array',
'settings' => 'json',
'tags' => 'collection',
// Dates
'paid_at' => 'datetime',
'shipped_date' => 'date',
'created_at' => 'immutable_datetime',
// Encrypted (automatically encrypts/decrypts)
'secret_token' => 'encrypted',
'api_keys' => 'encrypted:array',
// Enums (PHP 8.1+)
'status' => OrderStatus::class,
'payment_method' => PaymentMethod::class,
// As object
'address' => AsStringable::class,
];
}
// Usage - types are automatically converted
$order = Order::find(1);
$order->is_paid; // bool
$order->total; // "99.99" (decimal string)
$order->metadata; // array
$order->paid_at; // Carbon instance
$order->status; // OrderStatus enum// Enums
enum OrderStatus: string
{
case Pending = 'pending';
case Processing = 'processing';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
public function label(): string
{
return match($this) {
self::Pending => 'Pending',
self::Processing => 'Processing',
self::Shipped => 'Shipped',
self::Delivered => 'Delivered',
self::Cancelled => 'Cancelled',
};
}
public function color(): string
{
return match($this) {
self::Pending => 'yellow',
self::Processing => 'blue',
self::Shipped => 'purple',
self::Delivered => 'green',
self::Cancelled => 'red',
};
}
}// Custom cast class
namespace App\Casts;
use App\ValueObjects\Money;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
class MoneyCast implements CastsAttributes
{
public function __construct(
private string $currency = 'USD'
) {}
public function get(Model $model, string $key, mixed $value, array $attributes): ?Money
{
if (is_null($value)) {
return null;
}
return new Money((int) $value, $this->currency);
}
public function set(Model $model, string $key, mixed $value, array $attributes): ?int
{
if (is_null($value)) {
return null;
}
if ($value instanceof Money) {
return $value->cents();
}
return (int) ($value * 100);
}
}// Use custom cast
class Product extends Model
{
protected $casts = [
'price' => MoneyCast::class,
'cost' => MoneyCast::class . ':EUR',
];
}// Cast with parameters via method (alternative to $casts property)
namespace App\Models;
use App\Casts\AddressCast;
use App\Casts\MoneyCast;
use Illuminate\Database\Eloquent\Casts\AsCollection;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
protected function casts(): array
{
return [
'price' => MoneyCast::class,
'options' => AsCollection::class,
'address' => AddressCast::class,
];
}
}// Inbound-only casting (only on set)
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Hash;
class HashCast implements CastsInboundAttributes
{
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
return Hash::make($value);
}
}Why
- Type safety: Attributes are always the expected type
- Less boilerplate: No manual type conversions
- Automatic serialization: JSON encoding/decoding handled
- Consistency: Same behavior everywhere the attribute is used
- Enum support: Type-safe status fields with IDE support
- Encryption: Sensitive data encrypted at rest automatically
- Custom logic: Complex types handled via custom casts
Chunking for Large Datasets
Impact: CRITICAL (Prevents memory exhaustion on large datasets)
Process large datasets in chunks to prevent memory exhaustion and timeout issues.
Bad Example
// Loading all records into memory - will crash with large datasets
class ReportController extends Controller
{
public function export()
{
$users = User::all(); // 1 million users = memory exhausted
foreach ($users as $user) {
// Process each user
}
}
}
// Also bad: using get() on large datasets
$orders = Order::where('status', 'completed')->get();
// Memory-intensive collection operations
$total = User::all()->sum('balance'); // Loads all usersGood Example
// Chunk for processing large datasets
class UserService
{
public function processAllUsers(): void
{
User::chunk(1000, function ($users) {
foreach ($users as $user) {
$this->processUser($user);
}
});
}
}
// ChunkById for safer chunking (prevents issues with modifications)
User::chunkById(1000, function ($users) {
foreach ($users as $user) {
$user->update(['processed' => true]);
}
});
// Lazy collections - memory efficient iteration
User::lazy()->each(function ($user) {
// Process one user at a time
// Only one model in memory at a time
});
// Lazy with chunk size
User::lazyById(500)->each(function ($user) {
$this->sendNotification($user);
});
// Cursor for read-only operations
foreach (User::cursor() as $user) {
// Uses PHP generator, very memory efficient
echo $user->name;
}
// Database aggregates instead of loading data
$total = User::sum('balance'); // Single query
$average = Order::avg('total');
$count = Product::where('active', true)->count();
// Batch updates without loading models
User::where('last_login', '<', now()->subYear())
->update(['status' => 'inactive']);
// Batch delete
Order::where('created_at', '<', now()->subYears(5))
->delete();
// Export large datasets efficiently
class ExportUsersJob implements ShouldQueue
{
public function handle()
{
$filename = 'users-' . now()->format('Y-m-d') . '.csv';
$file = fopen(storage_path("exports/{$filename}"), 'w');
// Write header
fputcsv($file, ['ID', 'Name', 'Email', 'Created']);
User::chunk(1000, function ($users) use ($file) {
foreach ($users as $user) {
fputcsv($file, [
$user->id,
$user->name,
$user->email,
$user->created_at,
]);
}
});
fclose($file);
}
}
// Query with chunk for complex operations
Order::query()
->where('status', 'pending')
->with('items')
->chunkById(500, function ($orders) {
foreach ($orders as $order) {
ProcessOrderJob::dispatch($order);
}
});Why
- Memory efficiency: Only loads a subset of records at a time
- Prevents crashes: Avoids memory exhaustion on large datasets
- Prevents timeouts: Work is done in manageable batches
- Database friendly: Reduces database connection time
- Scalable: Works regardless of dataset size
- Production safe: Essential for background jobs processing bulk data
Eager Loading Relationships
Impact: CRITICAL (10-100× query performance improvement)
Always eager load relationships to prevent N+1 query problems and improve performance.
Bad Example
// N+1 query problem - executes 101 queries for 100 posts
class PostController extends Controller
{
public function index()
{
$posts = Post::all(); // 1 query
return view('posts.index', compact('posts'));
}
}In the view — N additional queries:
@foreach ($posts as $post)
<p>{{ $post->author->name }}</p> {{-- 1 query per post --}}
<p>{{ $post->category->name }}</p> {{-- 1 query per post --}}
@endforeach// Also bad: loading in loop
$posts = Post::all();
foreach ($posts as $post) {
echo $post->comments->count(); // Query for each post
}Good Example
// Eager load with 'with' - only 3 queries total
class PostController extends Controller
{
public function index()
{
$posts = Post::with(['author', 'category'])->get();
return view('posts.index', compact('posts'));
}
}
// Nested eager loading
$posts = Post::with([
'author',
'category',
'comments.user', // Nested relationship
])->get();
// Eager loading with constraints
$posts = Post::with([
'comments' => function ($query) {
$query->where('approved', true)
->orderBy('created_at', 'desc')
->limit(5);
},
'author:id,name,avatar', // Select specific columns
])->get();
// Conditional eager loading
$posts = Post::query()
->when($includeComments, fn($q) => $q->with('comments'))
->get();
// Eager load count without loading the relationship
$posts = Post::withCount('comments')->get();
// Access: $post->comments_count
// Multiple counts
$posts = Post::withCount(['comments', 'likes'])
->withSum('orderItems', 'quantity')
->get();
// Default eager loading in model
class Post extends Model
{
protected $with = ['author']; // Always eager loaded
}
// Lazy eager loading when you already have models
$posts = Post::all();
$posts->load(['author', 'category']); // Load after the fact
// Prevent lazy loading in development (add to AppServiceProvider)
public function boot(): void
{
Model::preventLazyLoading(!app()->isProduction());
}Why
- Performance: Reduces database queries from N+1 to just 2-3
- Predictability: Know exactly how many queries will run
- Scalability: Application performs consistently regardless of data size
- Resource efficiency: Less database connections and memory usage
- Debugging: Easier to identify and fix query issues
- Prevention:
preventLazyLoading()catches N+1 issues during development
Model Events and Observers
Impact: HIGH (Clean lifecycle hooks and side effects)
Use model events and observers to react to model lifecycle changes cleanly.
Bad Example
// Logic scattered in controllers
class UserController extends Controller
{
public function store(Request $request)
{
$user = User::create($request->validated());
// Side effects in controller
$user->profile()->create();
Mail::to($user)->send(new WelcomeEmail($user));
Log::info('User created', ['user_id' => $user->id]);
Cache::forget('users-count');
return redirect()->route('users.show', $user);
}
public function destroy(User $user)
{
// Manual cleanup
$user->posts()->delete();
$user->comments()->delete();
Storage::delete($user->avatar_path);
Cache::forget("user-{$user->id}");
$user->delete();
return redirect()->route('users.index');
}
}Good Example
// Using model events in the model itself
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class User extends Model
{
protected static function booted(): void
{
// Before creating
static::creating(function (User $user) {
$user->uuid = Str::uuid();
$user->api_token = Str::random(60);
});
// After creating
static::created(function (User $user) {
$user->profile()->create();
});
// Before updating
static::updating(function (User $user) {
if ($user->isDirty('email')) {
$user->email_verified_at = null;
}
});
// Before deleting
static::deleting(function (User $user) {
$user->posts()->delete();
$user->comments()->delete();
});
// After deleting
static::deleted(function (User $user) {
Storage::delete($user->avatar_path);
});
}
}// Using an Observer for more complex scenarios
namespace App\Observers;
use App\Events\UserRegistered;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class UserObserver
{
public function creating(User $user): void
{
$user->uuid = Str::uuid();
}
public function created(User $user): void
{
$user->profile()->create();
event(new UserRegistered($user));
}
public function updating(User $user): void
{
if ($user->isDirty('email')) {
$user->email_verified_at = null;
}
}
public function updated(User $user): void
{
Cache::forget("user-{$user->id}");
}
public function deleting(User $user): void
{
// Cascade soft deletes
$user->posts()->delete();
}
public function deleted(User $user): void
{
Storage::delete($user->avatar_path);
Cache::forget("user-{$user->id}");
Log::info('User deleted', ['user_id' => $user->id]);
}
public function restored(User $user): void
{
// Restore related soft deleted records
$user->posts()->restore();
}
public function forceDeleted(User $user): void
{
// Permanent deletion cleanup
$user->posts()->forceDelete();
}
}// Register observer in AppServiceProvider
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
User::observe(UserObserver::class);
}
}// Or use the ObservedBy attribute (Laravel 10+)
namespace App\Models;
use App\Observers\UserObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Model;
#[ObservedBy([UserObserver::class])]
class User extends Model
{
// ...
}// Clean controller
class UserController extends Controller
{
public function store(StoreUserRequest $request)
{
$user = User::create($request->validated());
// All side effects handled by observer
return redirect()->route('users.show', $user);
}
public function destroy(User $user)
{
$user->delete();
// Cleanup handled by observer
return redirect()->route('users.index');
}
}// Available model events
// creating, created
// updating, updated
// saving, saved (fires for both create and update)
// deleting, deleted
// restoring, restored (for soft deletes)
// forceDeleting, forceDeleted
// replicating
// retrievedWhy
- Separation of concerns: Controllers stay focused on HTTP
- Consistency: Same behavior regardless of how model is created/updated
- Single responsibility: Observer handles all model lifecycle logic
- Testable: Can test observer behavior independently
- DRY: Side effects defined once, triggered automatically
- Maintainability: Easy to find all model-related logic in one place
Model Pruning
Impact: MEDIUM (Automatic cleanup of old records)
Use model pruning to automatically clean up old or obsolete database records.
Bad Example
// Manual cleanup in random places
class CleanupController extends Controller
{
public function cleanup()
{
// Deleting old records manually
ActivityLog::where('created_at', '<', now()->subMonths(6))->delete();
PasswordReset::where('created_at', '<', now()->subDay())->delete();
Session::where('last_activity', '<', now()->subWeek())->delete();
return response()->json(['message' => 'Cleanup complete']);
}
}
// Or in a poorly organized command
class CleanupOldRecords extends Command
{
protected $signature = 'app:cleanup';
public function handle()
{
// All cleanup logic mixed together
$this->info('Cleaning activity logs...');
ActivityLog::where('created_at', '<', now()->subMonths(6))->delete();
$this->info('Cleaning password resets...');
PasswordReset::where('created_at', '<', now()->subDay())->delete();
// Easy to forget to add new models
}
}Good Example
// Model with Prunable trait
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Prunable;
use Illuminate\Support\Facades\Storage;
class ActivityLog extends Model
{
use Prunable;
/**
* Get the prunable model query.
*/
public function prunable(): Builder
{
// Delete activity logs older than 6 months
return static::where('created_at', '<', now()->subMonths(6));
}
/**
* Prepare the model for pruning (optional).
*/
protected function pruning(): void
{
// Clean up related resources before deletion
Storage::delete($this->attachment_path);
}
}// For soft-deletable models, use MassPrunable for efficiency
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\MassPrunable;
use Illuminate\Database\Eloquent\Model;
class PasswordReset extends Model
{
use MassPrunable;
public function prunable(): Builder
{
// Delete password reset tokens older than 24 hours
return static::where('created_at', '<', now()->subDay());
}
}// Complex pruning conditions
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\MassPrunable;
use Illuminate\Database\Eloquent\Model;
class Session extends Model
{
use MassPrunable;
public function prunable(): Builder
{
return static::where('last_activity', '<', now()->subWeek())
->orWhere(function ($query) {
$query->whereNull('user_id')
->where('created_at', '<', now()->subDay());
});
}
}// Prunable with related cleanup
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Prunable;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
class Order extends Model
{
use SoftDeletes, Prunable;
public function prunable(): Builder
{
// Only prune soft-deleted orders older than 1 year
return static::onlyTrashed()
->where('deleted_at', '<', now()->subYear());
}
protected function pruning(): void
{
// Clean up related records
$this->items()->forceDelete();
$this->payments()->forceDelete();
// Clean up files
Storage::delete($this->invoice_path);
}
}// Prunable notifications
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\MassPrunable;
use Illuminate\Database\Eloquent\Model;
class DatabaseNotification extends Model
{
use MassPrunable;
public function prunable(): Builder
{
return static::whereNotNull('read_at')
->where('read_at', '<', now()->subMonths(3));
}
}// Schedule pruning in routes/console.php (Laravel 11+)
use Illuminate\Support\Facades\Schedule;
Schedule::command('model:prune')->daily();
// Prune specific models
Schedule::command('model:prune', [
'--model' => [ActivityLog::class, Session::class],
])->daily();
// With chunk size for large datasets
Schedule::command('model:prune', ['--chunk' => 1000])->daily();# Run manually
php artisan model:prune
php artisan model:prune --model=App\\Models\\ActivityLog
php artisan model:prune --pretendWhy
- Automatic cleanup: Database stays clean without manual intervention
- Self-documenting: Pruning logic lives with the model
- Discoverable: Laravel automatically finds all prunable models
- Memory efficient: MassPrunable uses bulk deletes
- Lifecycle hooks: Can clean up related resources before deletion
- Testable: Pruning logic can be unit tested
- Scheduled: Built-in Artisan command for scheduling
Related skills
How it compares
Use laravel-best-practices instead of generic PHP linting when Laravel-specific architecture, Eloquent, and service-layer conventions must drive code generation.
FAQ
What does laravel-best-practices do?
Apply Laravel 13 conventions for controllers, models, migrations, and services.
When should I use laravel-best-practices?
User creates Laravel controllers, models, migrations, validation, or services.
Is laravel-best-practices safe to install?
Review the Security Audits panel on this page before installing in production.