Introduction
Writing clean, maintainable code is the difference between a project that scales gracefully and one that becomes a nightmare to maintain. As applications grow, poorly structured code leads to bugs, slower development, and frustrated teams.
Clean code isn't just about making your code work—it's about making it understandable, testable, and easy to modify. Laravel provides excellent tools and conventions, but it's up to developers to use them effectively.
In this comprehensive guide, we'll explore best practices for writing clean Laravel 12 code, covering SOLID principles, design patterns, code organization, naming conventions, and refactoring techniques that will make your codebase a joy to work with.
SOLID Principles in Laravel
1. Single Responsibility Principle (SRP)
Each class should have one reason to change.
// ❌ Controller doing too much
class UserController extends Controller
{
public function store(Request $request)
{
// Validation
$validated = $request->validate([
'name' => 'required',
'email' => 'required|email|unique:users',
]);
// Business logic
$user = User::create($validated);
// Send email
Mail::to($user)->send(new WelcomeEmail($user));
// Log activity
Log::info('User created', ['user_id' => $user->id]);
// Generate notification
$user->notify(new AccountCreated());
return redirect()->route('users.show', $user);
}
}
// ✅ Separated responsibilities
class UserController extends Controller
{
public function __construct(
private UserService $userService
) {}
public function store(StoreUserRequest $request)
{
$user = $this->userService->createUser($request->validated());
return redirect()
->route('users.show', $user)
->with('success', 'User created successfully');
}
}
// Service handles business logic
class UserService
{
public function __construct(
private UserRepository $userRepository,
private NotificationService $notificationService
) {}
public function createUser(array $data): User
{
$user = $this->userRepository->create($data);
$this->notificationService->sendWelcomeNotification($user);
return $user;
}
}
2. Open/Closed Principle (OCP)
Classes should be open for extension but closed for modification.
// ❌ Modifying existing code for new payment methods
class PaymentProcessor
{
public function process($amount, $method)
{
if ($method === 'stripe') {
// Stripe logic
} elseif ($method === 'paypal') {
// PayPal logic
} elseif ($method === 'bank') {
// Bank logic
}
}
}
// ✅ Using interface for extensibility
interface PaymentGateway
{
public function charge(int $amount): PaymentResult;
}
class StripeGateway implements PaymentGateway
{
public function charge(int $amount): PaymentResult
{
// Stripe-specific implementation
return new PaymentResult(true, 'txn_123');
}
}
class PayPalGateway implements PaymentGateway
{
public function charge(int $amount): PaymentResult
{
// PayPal-specific implementation
return new PaymentResult(true, 'pp_456');
}
}
class PaymentProcessor
{
public function __construct(
private PaymentGateway $gateway
) {}
public function process(int $amount): PaymentResult
{
return $this->gateway->charge($amount);
}
}
3. Liskov Substitution Principle (LSP)
Derived classes must be substitutable for their base classes.
// ✅ Proper abstraction
interface Notifiable
{
public function send(string $message): bool;
}
class EmailNotification implements Notifiable
{
public function send(string $message): bool
{
// Send email
return true;
}
}
class SmsNotification implements Notifiable
{
public function send(string $message): bool
{
// Send SMS
return true;
}
}
class NotificationService
{
public function notify(Notifiable $channel, string $message): void
{
$channel->send($message); // Works with any implementation
}
}
4. Interface Segregation Principle (ISP)
No client should depend on methods it doesn't use.
// ❌ Fat interface
interface Worker
{
public function work();
public function eat();
public function sleep();
}
// ✅ Segregated interfaces
interface Workable
{
public function work(): void;
}
interface Feedable
{
public function eat(): void;
}
interface Sleepable
{
public function sleep(): void;
}
class Human implements Workable, Feedable, Sleepable
{
public function work(): void { /* ... */ }
public function eat(): void { /* ... */ }
public function sleep(): void { /* ... */ }
}
class Robot implements Workable
{
public function work(): void { /* ... */ }
// No eat() or sleep() needed
}
5. Dependency Inversion Principle (DIP)
Depend on abstractions, not concretions.
// ❌ Depending on concrete implementation
class OrderService
{
private MySQLOrderRepository $repository;
public function __construct()
{
$this->repository = new MySQLOrderRepository();
}
}
// ✅ Depending on abstraction
interface OrderRepository
{
public function find(int $id): ?Order;
public function save(Order $order): void;
}
class OrderService
{
public function __construct(
private OrderRepository $repository
) {}
public function getOrder(int $id): ?Order
{
return $this->repository->find($id);
}
}
// Bind in service provider
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(
OrderRepository::class,
MySQLOrderRepository::class
);
}
}
Laravel Design Patterns
1. Repository Pattern
Separate data access logic from business logic.
// Repository interface
namespace App\Repositories\Contracts;
interface UserRepository
{
public function find(int $id): ?User;
public function all(): Collection;
public function create(array $data): User;
public function update(int $id, array $data): bool;
public function delete(int $id): bool;
}
// Implementation
namespace App\Repositories;
use App\Models\User;
use App\Repositories\Contracts\UserRepository;
class EloquentUserRepository implements UserRepository
{
public function find(int $id): ?User
{
return User::find($id);
}
public function all(): Collection
{
return User::all();
}
public function create(array $data): User
{
return User::create($data);
}
public function update(int $id, array $data): bool
{
return User::where('id', $id)->update($data);
}
public function delete(int $id): bool
{
return User::destroy($id);
}
}
2. Service Layer Pattern
Encapsulate business logic in service classes.
namespace App\Services;
use App\Models\Order;
use App\Repositories\Contracts\OrderRepository;
use App\Notifications\OrderPlaced;
class OrderService
{
public function __construct(
private OrderRepository $orderRepository,
private PaymentService $paymentService,
private InventoryService $inventoryService
) {}
public function placeOrder(array $data): Order
{
DB::transaction(function () use ($data) {
// Create order
$order = $this->orderRepository->create($data);
// Process payment
$this->paymentService->charge($order->total, $data['payment_method']);
// Update inventory
foreach ($order->items as $item) {
$this->inventoryService->decrementStock($item->product_id, $item->quantity);
}
// Send notification
$order->user->notify(new OrderPlaced($order));
return $order;
});
}
}
3. Action Pattern
Single-purpose classes for specific actions.
namespace App\Actions;
class CreateUserAction
{
public function execute(array $data): User
{
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
]);
$user->assignRole('user');
event(new UserRegistered($user));
return $user;
}
}
// Usage in controller
class UserController extends Controller
{
public function store(StoreUserRequest $request, CreateUserAction $action)
{
$user = $action->execute($request->validated());
return redirect()->route('users.show', $user);
}
}
4. Query Object Pattern
Encapsulate complex queries.
namespace App\Queries;
class PopularPostsQuery
{
private $query;
public function __construct()
{
$this->query = Post::query();
}
public function published(): self
{
$this->query->where('status', 'published');
return $this;
}
public function withMinViews(int $views): self
{
$this->query->where('views', '>=', $views);
return $this;
}
public function inCategory(int $categoryId): self
{
$this->query->where('category_id', $categoryId);
return $this;
}
public function get(): Collection
{
return $this->query
->with('author', 'category')
->orderBy('views', 'desc')
->get();
}
}
// Usage
$popularPosts = (new PopularPostsQuery())
->published()
->withMinViews(1000)
->inCategory(5)
->get();
Code Organization Best Practices
1. Directory Structure
Organize code logically:
app/
├── Actions/ # Single-purpose action classes
├── Enums/ # PHP enums
├── Events/ # Event classes
├── Exceptions/ # Custom exceptions
├── Http/
│ ├── Controllers/ # HTTP controllers
│ ├── Middleware/ # HTTP middleware
│ └── Requests/ # Form requests
├── Jobs/ # Queue jobs
├── Listeners/ # Event listeners
├── Models/ # Eloquent models
├── Notifications/ # Notification classes
├── Policies/ # Authorization policies
├── Providers/ # Service providers
├── Queries/ # Query objects
├── Repositories/ # Repository implementations
│ └── Contracts/ # Repository interfaces
├── Services/ # Business logic services
└── View/
└── Components/ # Blade components
2. Naming Conventions
Follow consistent naming:
// ✅ Controllers - plural, descriptive
class PostsController extends Controller {}
class UserProfileController extends Controller {}
// ✅ Models - singular, PascalCase
class Post extends Model {}
class OrderItem extends Model {}
// ✅ Form Requests - action + model name + Request
class StorePostRequest extends FormRequest {}
class UpdateUserRequest extends FormRequest {}
// ✅ Jobs - descriptive action
class SendWelcomeEmail implements ShouldQueue {}
class ProcessOrderPayment implements ShouldQueue {}
// ✅ Events - past tense
class OrderPlaced {}
class UserRegistered {}
// ✅ Listeners - action + event name
class SendOrderConfirmation {}
class NotifyAdminOfNewUser {}
// ✅ Services - noun + Service
class PaymentService {}
class NotificationService {}
// ✅ Repositories - model + Repository
interface UserRepository {}
class EloquentUserRepository implements UserRepository {}
3. Method Naming
Use descriptive, intention-revealing names:
// ❌ Poor naming
public function getData() {}
public function doStuff($id) {}
public function check($user) {}
// ✅ Clear naming
public function getActiveUsers(): Collection {}
public function sendWelcomeEmail(User $user): void {}
public function isEligibleForDiscount(User $user): bool {}
public function calculateOrderTotal(Order $order): float {}
Clean Controller Practices
1. Keep Controllers Thin
// ❌ Fat controller
class PostController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|max:255',
'content' => 'required',
]);
$post = Post::create($validated);
// Business logic in controller
if ($post->title === 'Special') {
$post->featured = true;
}
// More business logic
Cache::forget('posts');
Cache::tags(['posts'])->flush();
// Email logic
Mail::to($post->author)->send(new PostPublished($post));
return redirect()->route('posts.show', $post);
}
}
// ✅ Thin controller
class PostController extends Controller
{
public function __construct(
private PostService $postService
) {}
public function store(StorePostRequest $request)
{
$post = $this->postService->createPost(
$request->validated()
);
return redirect()
->route('posts.show', $post)
->with('success', 'Post created successfully');
}
}
2. Use Form Requests
Move validation to dedicated classes:
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|max:255|unique:posts',
'content' => 'required|min:100',
'category_id' => 'required|exists:categories,id',
'tags' => 'array',
'tags.*' => 'exists:tags,id',
'published_at' => 'nullable|date|after:now',
];
}
public function messages(): array
{
return [
'title.unique' => 'A post with this title already exists.',
'content.min' => 'Post content must be at least 100 characters.',
];
}
protected function prepareForValidation(): void
{
$this->merge([
'slug' => Str::slug($this->title),
]);
}
}
3. Use Resource Controllers
Follow RESTful conventions:
// ✅ Standard resource controller
class PostController extends Controller
{
public function index() {} // GET /posts
public function create() {} // GET /posts/create
public function store() {} // POST /posts
public function show() {} // GET /posts/{post}
public function edit() {} // GET /posts/{post}/edit
public function update() {} // PUT/PATCH /posts/{post}
public function destroy() {} // DELETE /posts/{post}
}
// Route definition
Route::resource('posts', PostController::class);
Eloquent Best Practices
1. Use Accessors and Mutators
class User extends Model
{
// Accessor - get formatted attribute
protected function name(): Attribute
{
return Attribute::make(
get: fn ($value) => ucwords($value),
);
}
// Mutator - set formatted value
protected function password(): Attribute
{
return Attribute::make(
set: fn ($value) => Hash::make($value),
);
}
// Cast attribute
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'is_admin' => 'boolean',
'settings' => 'array',
];
}
}
2. Use Scopes for Reusable Queries
class Post extends Model
{
// Local scope
public function scopePublished($query)
{
return $query->where('status', 'published');
}
public function scopePopular($query, $threshold = 100)
{
return $query->where('views', '>', $threshold);
}
public function scopeByAuthor($query, User $author)
{
return $query->where('user_id', $author->id);
}
}
// Usage
$posts = Post::published()
->popular(500)
->byAuthor($user)
->latest()
->get();
3. Define Relationships Properly
class Post extends Model
{
// One-to-many
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
// Belongs to
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
// Many-to-many
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class)
->withTimestamps()
->withPivot('order');
}
// Polymorphic
public function images(): MorphMany
{
return $this->morphMany(Image::class, 'imageable');
}
}
DRY Principle (Don't Repeat Yourself)
1. Extract Reusable Logic
// ❌ Repeated logic
public function approvePost(Post $post)
{
$post->status = 'approved';
$post->approved_at = now();
$post->approved_by = auth()->id();
$post->save();
}
public function approveComment(Comment $comment)
{
$comment->status = 'approved';
$comment->approved_at = now();
$comment->approved_by = auth()->id();
$comment->save();
}
// ✅ Trait for reusable logic
trait Approvable
{
public function approve(): void
{
$this->update([
'status' => 'approved',
'approved_at' => now(),
'approved_by' => auth()->id(),
]);
}
public function scopeApproved($query)
{
return $query->where('status', 'approved');
}
}
class Post extends Model
{
use Approvable;
}
class Comment extends Model
{
use Approvable;
}
2. Use Helper Functions
// Create app/Helpers/helpers.php
if (!function_exists('format_currency')) {
function format_currency($amount, $currency = 'USD'): string
{
return number_format($amount, 2) . ' ' . $currency;
}
}
if (!function_exists('active_route')) {
function active_route(string $route, string $class = 'active'): string
{
return request()->routeIs($route) ? $class : '';
}
}
// Load in composer.json
"autoload": {
"files": [
"app/Helpers/helpers.php"
]
}
Error Handling
1. Use Custom Exceptions
namespace App\Exceptions;
use Exception;
class PaymentFailedException extends Exception
{
public static function invalidCard(): self
{
return new self('The provided card is invalid.');
}
public static function insufficientFunds(): self
{
return new self('Insufficient funds for this transaction.');
}
}
// Usage
throw PaymentFailedException::invalidCard();
2. Global Exception Handler
// In bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
$exceptions->renderable(function (PaymentFailedException $e, $request) {
return response()->json([
'error' => $e->getMessage(),
], 402);
});
})
Testing for Maintainability
namespace Tests\Feature;
use Tests\TestCase;
class PostControllerTest extends TestCase
{
/** @test */
public function authenticated_user_can_create_post()
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->post('/posts', [
'title' => 'Test Post',
'content' => 'Test content',
]);
$response->assertRedirect();
$this->assertDatabaseHas('posts', [
'title' => 'Test Post',
'user_id' => $user->id,
]);
}
}
Conclusion
Writing clean, maintainable Laravel code requires discipline, knowledge of design patterns, and commitment to best practices. By following SOLID principles, organizing code logically, using appropriate design patterns, and maintaining consistency, you create applications that are easier to understand, test, and scale.
Key takeaways:
- Follow SOLID principles consistently
- Use repository and service layer patterns
- Keep controllers thin and focused
- Implement proper naming conventions
- Extract reusable logic into traits and helpers
- Write comprehensive tests
- Use type hints and return types
Need help refactoring your Laravel codebase? NeedLaravelSite specializes in code quality improvement and architecture consulting. Contact us for expert Laravel development services.
Related Resources: