Code Quality & Best Practices

Writing Clean and Maintainable Code in Laravel 12

Learn best practices for writing clean, maintainable Laravel 12 code. Master SOLID principles, design patterns, refactoring techniques, and coding standards for professional development.

Muhammad Waqas

Muhammad Waqas

CEO at CentoSquare

|
03-Nov-2025
11 min read
Writing Clean and Maintainable Code in Laravel 12

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:


Article Tags

Laravel clean code Laravel 12 best practices Laravel SOLID principles Laravel design patterns Laravel code quality Laravel repository pattern Laravel service layer Laravel refactoring Laravel coding standards Laravel architecture patterns Laravel dependency injection Laravel maintainable code

About the Author

Muhammad Waqas

Muhammad Waqas

CEO at CentoSquare

Founder & CEO at CentoSquare | Creator of NeedLaravelSite | Helping Businesses Grow with Cutting-Edge Web, Mobile & Marketing Solutions | Building Innovative Products