Laravel Best Practices

By Amas
Laravel Best Practices

Laravel is a powerful framework designed to simplify building modern web applications. Like any framework, it has best practices embedded in its core. By following these guidelines, you can write cleaner code, minimize technical debt, enhance team collaboration, and ensure your codebase aligns with the "Laravel way" of doing things.

In this article, we’ll explore these essential Laravel best practices, from structuring your code to optimizing database operations, ensuring your projects stay efficient and developer-friendly.

Whether you’re a seasoned Laravel developer or just starting, these practices will help you level up your development skills and deliver high-quality applications.

So let's dive in. 👇

Fat Models, Skinny Controllers

Shift database logic to Eloquent models to maintain cleaner controllers and reusable code

Bad example:

public function index()
{
    $clients = Client::verified()
        ->with(['orders' => function ($query) {
            $query->where('created_at', '>', now()->subDays(7));
        }])
        ->get();

    return view('index', compact('clients'));
}

Good example:

public function index(Client $client)
{
    return view('index', ['clients' => $client->getVerifiedWithRecentOrders()]);
}

class Client extends Model
{
    public function getVerifiedWithRecentOrders(): Collection
    {
        return $this->verified()
            ->with(['orders' => fn($query) => $query->recent()])
            ->get();
    }

    public function scopeVerified($query)
    {
        return $query->where('is_verified', true);
    }
}

class Order extends Model
{
    public function scopeRecent($query)
    {
        return $query->where('created_at', '>', now()->subDays(7));
    }
}

 

Single Responsibility Principle

A class should have only one responsibility. This means that a class should focus on a single piece of functionality. Violating this principle can make your code harder to read, test, and maintain because it mixes concerns that should be separated.

By adhering to the Single Responsibility Principle, you create code that is easier to understand and refactor. Each class or service has a clear purpose, making the overall system more modular and flexible.

Bad example:

public function update(Request $request): string
{
    $validated = $request->validate([
        'name' => 'required|max:255',
        'tasks' => 'required|array:due_date,status'
    ]);

    foreach ($request->tasks as $task) {
        $formattedDate = $this->carbon->parse($task['due_date'])->toDateTimeString();
        $this->logger->info('Task updated: ' . $formattedDate . ' - ' . $task['status']);
    }

    $this->project->updateTasks($request->validated());

    return redirect()->route('projects.index');
}

Good example:

public function update(UpdateProjectRequest $request): string
{
    $this->taskLogger->logTasks($request->tasks);
    $this->projectService->updateTasks($request->validated());

    return redirect()->route('projects.index');
}

class TaskLogger
{
    public function logTasks(array $tasks): void
    {
        // Logic to log tasks
    }
}

class ProjectService
{
    public function updateTasks(array $data): void
    {
        // Logic to update project tasks
    }
}


Methods Should Do Just One Thing

A function should have a single purpose and execute it well. When a method does more than one thing, it becomes harder to understand, test, and maintain. Splitting responsibilities into smaller, focused methods makes your code more readable and easier to debug.

Bad example:

public function getFullNameAttribute(): string
{
    if (auth()->user() && auth()->user()->hasRole('admin') && auth()->user()->isVerified()) {
        return 'Admin ' . $this->first_name . ' ' . $this->last_name;
    } else {
        return $this->first_name[0] . '. ' . $this->last_name;
    }
}

Good Example:

public function getFullNameAttribute(): string
{
    return $this->isVerifiedAdmin() ? $this->formatFullName() : $this->formatShortName();
}

private function isVerifiedAdmin(): bool
{
    $user = auth()->user();
    return $user && $user->hasRole('admin') && $user->isVerified();
}

private function formatFullName(): string
{
    return 'Admin ' . $this->first_name . ' ' . $this->last_name;
}

private function formatShortName(): string
{
    return strtoupper($this->first_name[0]) . '. ' . ucfirst($this->last_name);
}


Keep Business Logic in Service Classes

Controllers should only handle HTTP requests and responses, delegating complex logic to service classes. This keeps code clean, reusable, and easier to test.

Bad example:

public function store(Request $request)
{
    if ($request->hasFile('image')) {
        $image = $request->file('image');
        $image->storeAs('temp', $image->getClientOriginalName(), 'public');
    }
    
    // Other unrelated logic...
}

Good example:

public function store(Request $request, ArticleService $articleService)
{
    $articleService->uploadImage($request->file('image'));

    // Other unrelated logic...
}

class ArticleService
{
    public function uploadImage(?UploadedFile $image): void
    {
        if ($image) {
            $image->storeAs('uploads/temp', uniqid() . '_' . $image->getClientOriginalName(), 'public');
        }
    }
}

 

Avoid Business Logic in Routes

Routes should only handle HTTP requests, not business logic. This keeps your code clean and maintainable.

Bad example

// Business logic in the route
Route::post('/article', function (Request $request) {
    $article = new Article;
    $article->title = $request->title;
    $article->content = $request->content;
    $article->save();
});

Good example

// Route delegates logic to the controller
Route::post('/article', [ArticleController::class, 'store']);

// In ArticleController
public function store(Request $request)
{
    // logic to create article
}


Use Relationships for Cleaner Code

Use Eloquent relationships to simplify and clarify how related models interact. This avoids repetitive assignments and makes the code easier to maintain and less error-prone.

Bad example

$article = new Article;
$article->title = $request->input('title');
$article->content = $request->input('content');
$article->verified = $request->boolean('verified');
$article->category_id = $category->id;
$article->save();

Good example

$category->articles()->create($request->safe()->only(['title', 'content', 'verified']));

 

Use Database Transactions for Atomic Business Operations

Transactions ensure all database operations succeed or fail as a group, maintaining data integrity.

Bad example

public function placeOrder(Request $request)
{
    $order = new Order;
    $order->user_id = $request->user_id;
    $order->save();

    $payment = new Payment;
    $payment->order_id = $order->id;
    $payment->save();
}

Good example

use DB;

public function placeOrder(Request $request)
{
    DB::beginTransaction();

    try {
        $order = Order::create($request->validated());
        $payment = Payment::create(['order_id' => $order->id]);

        DB::commit();
    } catch (\Exception $e) {
        DB::rollBack();
        throw $e;
    }
}



Avoid Queries in Blade: Use Eager Loading

Executing queries inside Blade templates leads to inefficient database calls, especially with loops. Eager loading fetches related data in a single query, improving performance and avoiding the N + 1 query problem.

Bad example

@foreach (User::all() as $user)
    {{ $user->profile->name }}
@endforeach

If you have 100 users, this triggers 101 queries: one for the users and one for each user's profile.

Good example

// in a service class or model passed back to controller which shares that with the blade file
$users = User::with('profile')->get();

// in blade file
@foreach ($users as $user)
    {{ $user->profile->name }}
@endforeach

This triggers only 2 queries: one for users and one for their profiles.


Chunk Data for Performance

For tasks that involve large datasets, processing in chunks reduces memory usage and improves performance by limiting the amount of data held in memory at once.

Bad example

$users = User::all();

foreach ($users as $user) {
    // Process each user
}

Good example

User::chunk(500, function ($users) {
    foreach ($users as $user) {
        // Process each user
    }
});


Use Constants Instead of Hardcoded Values

Using constants will help you locate the places where this value is used in case you wanted to change it, refactor and will help you with debugging. 

Bad example

public function isAdmin(User $user): bool
{
    return $user->type === 'admin';
}

Good example

public function isAdmin(User $user)
{
    return $user->type === UserType::ADMIN;
}


Translate Strings

You'll thank yourself in the future as your app grows if you considered translating strings from the start. All you need it to pass strings through the __() function 

Bad example

return back()->with('message', 'Your article has been added!');

Good example

return back()->with('message', __('Your article has been added!'));  // notice the call to __()


Inject Dependencies

Creating instances with new tightly couples your classes and makes them harder to test or modify. Using the IoC container allows for easier dependency injection and better testability.

Bad example

public function store(Request $request)
{
    $user = new User;
    $user->create($request->validated());
}

Good example

public function __construct(protected UserService $userService) {}

public function store(Request $request)
{
    $this->userService->create($request->validated());
}


Avoid Directly Using .env in Code

Accessing data directly from the .env file throughout your application can make your code harder to maintain and test. Instead, store values in configuration files and retrieve them using config().

Bad example

$apiKey = env('API_KEY');

Good example

// config/services.php
'api_key' => env('API_KEY'),

// Retrieve the value
$apiKey = config('services.api_key');


Store Dates as Objects, Not Strings

Storing dates as strings can lead to inconsistent formats and parsing errors. It's better to store them as Carbon instances, which provide robust date handling. Use accessors and mutators to format dates only when needed in the display layer.

Bad example

{{ Carbon::createFromFormat('Y-d-m H-i', $object->ordered_at)->toDateString() }}
{{ Carbon::createFromFormat('Y-d-m H-i', $object->ordered_at)->format('m-d') }}

Good example

// In Model
protected $casts = [
    'ordered_at' => 'datetime',
];

// In Blade View
{{ $object->ordered_at->toDateString() }}
{{ $object->ordered_at->format('m-d') }}


Keep Code Documentation Minimal and Meaningful

Excessive documentation often clutters the code and makes it harder to maintain. Instead, rely on clear, descriptive names for variables, functions, and classes. Use comments only when absolutely necessary to explain complex logic.

Bad example

/**
 * The function checks if the given string has any white spaces
 *
 * @param string $string String received from frontend which might contain
 *                       space characters. Returns True if the string
 *                       is valid.
 *
 * @return bool
 *
 * @license GPL
 */

public function checkString($string)
{
}

Good example

public function hasWhiteSpaces(string $string): bool
{
}

 

Align on Coding Standards with Your Team

Consistent code improves readability and maintainability, making collaboration easier.

Use can Laravel Pint to automatically format and enforce coding standards. It integrates with your development workflow, so you can run it before each commit using Git Hooks.

composer require --dev laravel/pint
vendor/bin/pint


Test, Test and Test

And finally, one of the most important things you can do to ensure the reliability, maintainability, and scalability of your code is to write automated tests.

You don't need 100% coverage of your functionality (I'd argue that's counter productive), but at least you need to make sure that all you GET routes are covered, and the more you can add on top, the better. 

Here’s why:

  1. Catch Bugs Early: Tests help identify issues before they make it to production. Catching bugs during development is far cheaper and easier than fixing them post-deployment.

  2. Code Confidence: With proper test coverage, you can make changes to the codebase with confidence. Tests ensure that new changes don't break existing functionality.

  3. Documentation: Well-written tests act as living documentation for your code. They describe how the system is expected to behave and can be used to understand the code's intent.

  4. Refactoring Made Safe: When refactoring or improving existing code, tests provide a safety net that ensures no functionality is lost during changes.

  5. Improved Design: Writing tests often encourages better software design. To write testable code, you typically end up with smaller, more focused methods and classes that are easier to maintain.

  6. Collaboration: Tests make it easier for teams to work together on large codebases. They define clear expectations for behavior, reducing misunderstandings and improving collaboration.

  7. Continuous Integration: Tests are essential for implementing continuous integration and delivery workflows. Automated tests can run on every code push, ensuring that only stable code is deployed.

  8. Long-Term Maintenance: In large projects, tests help maintain stability over time, especially when the team changes. New developers can rely on tests to understand the behavior of the codebase and ensure future changes don't break functionality.

Keep pushing, keep building! 🤘

Share this post.
Liked that? Subscribe to our newsletter for more
Ship fast & don't reinvent the wheel

Build your SaaS using SaaSykit

SaaSykit is a SaaS boilerplate that comes packed with all components required to run a modern SaaS software.

Don't miss this

You might also like