| name | laravel-patterns |
| description | Laravel 12 best practices, design patterns, and coding standards. Use when creating controllers, models, services, middleware, or any PHP backend code in Laravel projects. |
| allowed-tools | Read, Grep, Glob, Edit, Write |
Laravel Best Practices Skill
This skill provides guidance for writing clean, maintainable Laravel 12 code following modern PHP and Laravel conventions.
Project Structure
Service Layer Pattern
app/
├── Http/
│ ├── Controllers/ # Thin controllers, delegate to services
│ ├── Requests/ # Form request validation
│ ├── Resources/ # API resources
│ └── Middleware/ # Request/response middleware
├── Models/ # Eloquent models
├── Services/ # Business logic
├── Repositories/ # Data access (optional)
├── Actions/ # Single-purpose action classes
├── DTOs/ # Data transfer objects
├── Enums/ # PHP 8.1+ enums
└── Exceptions/ # Custom exceptions
Controllers
Thin Controllers
Controllers should only handle HTTP concerns. Delegate business logic to services.
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreEmployeeRequest;
use App\Http\Resources\EmployeeResource;
use App\Services\EmployeeService;
use Illuminate\Http\JsonResponse;
class EmployeeController extends Controller
{
public function __construct(
private readonly EmployeeService $employeeService
) {}
public function store(StoreEmployeeRequest $request): JsonResponse
{
$employee = $this->employeeService->create($request->validated());
return EmployeeResource::make($employee)
->response()
->setStatusCode(201);
}
public function index(): JsonResponse
{
$employees = $this->employeeService->paginate();
return EmployeeResource::collection($employees)->response();
}
}
Resource Controllers
Use resource controllers for CRUD operations:
Route::resource('employees', EmployeeController::class);
Route::apiResource('api/employees', Api\EmployeeController::class);
Form Requests
Validation Logic
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreEmployeeRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Employee::class);
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:employees,email'],
'employee_id' => ['required', 'string', 'unique:employees'],
'department' => ['required', Rule::in(['IT', 'HR', 'Finance'])],
'salary' => ['required', 'numeric', 'min:0'],
'hire_date' => ['required', 'date', 'before_or_equal:today'],
];
}
public function messages(): array
{
return [
'email.unique' => 'Email sudah terdaftar.',
'hire_date.before_or_equal' => 'Tanggal tidak boleh di masa depan.',
];
}
}
Services
Service Class Pattern
<?php
namespace App\Services;
use App\Models\Employee;
use App\DTOs\EmployeeData;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class EmployeeService
{
public function __construct(
private readonly AttendanceService $attendanceService
) {}
public function create(array $data): Employee
{
return DB::transaction(function () use ($data) {
$employee = Employee::create($data);
// Related operations
$this->attendanceService->initializeForEmployee($employee);
return $employee->fresh(['department', 'schedules']);
});
}
public function paginate(int $perPage = 15): LengthAwarePaginator
{
return Employee::query()
->with(['department', 'latestAttendance'])
->withCount('attendances')
->latest()
->paginate($perPage);
}
public function findOrFail(string $id): Employee
{
return Employee::with(['department', 'schedules', 'attendances'])
->findOrFail($id);
}
}
Models
Model Best Practices
<?php
namespace App\Models;
use App\Enums\EmployeeStatus;
use App\Enums\EmployeeType;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Employee extends Model
{
use HasFactory, HasUuids, SoftDeletes;
protected $fillable = [
'name',
'email',
'employee_id',
'department_id',
'position',
'salary',
'hire_date',
'status',
'type',
];
protected function casts(): array
{
return [
'hire_date' => 'date',
'salary' => 'decimal:2',
'status' => EmployeeStatus::class,
'type' => EmployeeType::class,
'metadata' => 'array',
];
}
// Relationships
public function department(): BelongsTo
{
return $this->belongsTo(Department::class);
}
public function attendances(): HasMany
{
return $this->hasMany(Attendance::class);
}
public function latestAttendance(): HasOne
{
return $this->hasOne(Attendance::class)->latestOfMany();
}
// Scopes
public function scopeActive(Builder $query): Builder
{
return $query->where('status', EmployeeStatus::Active);
}
public function scopeByDepartment(Builder $query, string $departmentId): Builder
{
return $query->where('department_id', $departmentId);
}
// Accessors
protected function fullName(): Attribute
{
return Attribute::get(fn () => "{$this->first_name} {$this->last_name}");
}
}
Enums (PHP 8.1+)
<?php
namespace App\Enums;
enum EmployeeStatus: string
{
case Active = 'active';
case Inactive = 'inactive';
case OnLeave = 'on_leave';
case Terminated = 'terminated';
public function label(): string
{
return match($this) {
self::Active => 'Aktif',
self::Inactive => 'Tidak Aktif',
self::OnLeave => 'Cuti',
self::Terminated => 'Diberhentikan',
};
}
public function color(): string
{
return match($this) {
self::Active => 'green',
self::Inactive => 'gray',
self::OnLeave => 'yellow',
self::Terminated => 'red',
};
}
}
Query Optimization
Eager Loading
// BAD - N+1 problem
$employees = Employee::all();
foreach ($employees as $employee) {
echo $employee->department->name; // N queries
}
// GOOD - Eager load
$employees = Employee::with(['department', 'schedules'])->get();
Chunking Large Datasets
Employee::query()
->where('status', 'active')
->chunk(100, function ($employees) {
foreach ($employees as $employee) {
// Process each employee
}
});
// Or with lazy loading for memory efficiency
Employee::query()
->where('status', 'active')
->lazy()
->each(function ($employee) {
// Process
});
Query Scopes
// In Model
public function scopeAttendedToday(Builder $query): Builder
{
return $query->whereHas('attendances', function ($q) {
$q->whereDate('date', today());
});
}
// Usage
$presentEmployees = Employee::active()->attendedToday()->get();
API Resources
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EmployeeResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'employee_id' => $this->employee_id,
'position' => $this->position,
'status' => [
'value' => $this->status->value,
'label' => $this->status->label(),
'color' => $this->status->color(),
],
'department' => DepartmentResource::make($this->whenLoaded('department')),
'attendances_count' => $this->whenCounted('attendances'),
'latest_attendance' => AttendanceResource::make($this->whenLoaded('latestAttendance')),
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
];
}
}
Exception Handling
Custom Exceptions
<?php
namespace App\Exceptions;
use Exception;
use Illuminate\Http\JsonResponse;
class EmployeeNotFoundException extends Exception
{
public function __construct(string $employeeId)
{
parent::__construct("Employee with ID {$employeeId} not found.");
}
public function render(): JsonResponse
{
return response()->json([
'error' => 'employee_not_found',
'message' => $this->getMessage(),
], 404);
}
}
Middleware
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureEmployeeIsActive
{
public function handle(Request $request, Closure $next): Response
{
$employee = $request->user()->employee;
if (!$employee || !$employee->status->isActive()) {
abort(403, 'Employee account is not active.');
}
return $next($request);
}
}
Testing
Feature Tests
<?php
namespace Tests\Feature;
use App\Models\Employee;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class EmployeeControllerTest extends TestCase
{
use RefreshDatabase;
public function test_can_list_employees(): void
{
$user = User::factory()->admin()->create();
Employee::factory()->count(5)->create();
$response = $this->actingAs($user)
->getJson('/api/employees');
$response->assertOk()
->assertJsonCount(5, 'data')
->assertJsonStructure([
'data' => [
'*' => ['id', 'name', 'email', 'status']
],
'meta' => ['current_page', 'total']
]);
}
public function test_can_create_employee(): void
{
$user = User::factory()->admin()->create();
$response = $this->actingAs($user)
->postJson('/api/employees', [
'name' => 'John Doe',
'email' => 'john@example.com',
'employee_id' => 'EMP001',
]);
$response->assertCreated()
->assertJsonPath('data.name', 'John Doe');
$this->assertDatabaseHas('employees', [
'email' => 'john@example.com'
]);
}
}
Security Best Practices
Mass Assignment Protection
// Always use $fillable, never use $guarded = []
protected $fillable = ['name', 'email', 'position'];
Authorization with Policies
// Policy
public function update(User $user, Employee $employee): bool
{
return $user->hasRole('admin') || $user->employee_id === $employee->id;
}
// Controller
$this->authorize('update', $employee);
Sensitive Data
// Hide sensitive attributes
protected $hidden = ['password', 'salary', 'remember_token'];
// Or explicitly select
Employee::select(['id', 'name', 'email'])->get();
Performance Tips
Cache expensive queries
Cache::remember('dashboard.stats', 3600, fn() => $this->calculateStats());Use database transactions
DB::transaction(function () { // Multiple related operations });Index frequently queried columns
$table->index(['department_id', 'status']);Use queue for heavy operations
ProcessPayroll::dispatch($employee)->onQueue('payroll');