Matthew Hodge
Full Stack Developer

There's no one size fits all solution when it comes to structuring your Laravel applications. The right pattern depends on many factors such as team size, project complexity, long-term maintenance, and even your team's familiarity with certain approaches. What works for a solo developer on a small project might not scale for a large team building a business-critical application.

As Laravel developers, most of us start by putting business logic directly in controllers. It's quick, simple, and gets the job done, until your app grows and “fat controllers” start to slow you down. At that point, it's time to reach for better patterns.

In this post, I'll walk through five common approaches to structuring business logic in Laravel: Controllers, Service Classes, Action Classes, Domain-Driven Design (DDD), and the Repository Pattern. I'll share when to use each, their pros and cons, and practical examples.


1. Controllers (CRUD)

The classic approach: keep everything in the controller.

When to Use:

  • Small projects
  • Prototypes
  • Quick-and-dirty features

Pros:

  • Fast to write
  • Easy to follow for beginners

Cons:

  • Controllers get bloated (“fat controllers”)
  • Hard to test
  • Logic is not reusable

Example:

// app/Http/Controllers/PostController.php

public function store(Request $request)
{
    $validated = $request->validate([
        'title' => 'required|string',
        'body' => 'required|string',
    ]);
    $post = Post::create($validated);
    // Maybe some more business logic here...
    return redirect()->route('posts.index');
}

2. Service Classes

As your app grows, business logic in controllers becomes hard to manage. Service classes move this logic into dedicated classes, keeping controllers slim.

When to Use:

  • Medium to large projects
  • When logic is reused in multiple places
  • For improved testability

Pros:

  • Keeps controllers clean
  • Logic is reusable
  • Easier to test

Cons:

  • More files/structure
  • Can become “anemic” (just wrappers) if not careful

Example:

// app/Http/Controllers/PostController.php
public function construct(PostService $service){
    $this->service = $service;
}

public function store(Request $request)
{
    $post = $this->service->create($request->validated());
    return redirect()->route('posts.index');
}

// app/Services/PostService.php
namespace App\Services;

class PostService
{
    public function create(array $data)
    {
        // Business logic for creating a post
        return Post::create($data);
    }
}

3. Action Classes (Action Pattern)

Action classes encapsulate a single, focused task—like “CreatePost” or “SendInvoice.” They're great for isolating business logic, making it reusable and testable.

When to Use:

  • When you want to encapsulate and reuse specific business operations
  • For clear, single-responsibility units of work

Pros:

  • Highly focused and reusable
  • Easy to test
  • Encourages single responsibility

Cons:

  • Can result in many small files
  • May overlap with service classes if not careful

Example:

// app/Http/Controllers/PostController.php
public function store(Request $request, CreatePost $action)
{
    $post = $action->execute($request->validated());
    return redirect()->route('posts.index');
}

// app/Actions/CreatePost.php
namespace App\Actions;

use App\Models\Post;

class CreatePost
{
    public function execute(array $data)
    {
        // Business logic for creating a post
        return Post::create($data);
    }
}

Service Classes vs Action Classes: What's the Difference?

At first glance, Service Classes and Action Classes in Laravel look very similar: both move business logic out of controllers and into dedicated classes, both make your code more testable and reusable, and both help keep your controllers slim.

But there are some key differences in philosophy and usage:

Service Classes

  • Purpose: Group related business logic that spans multiple actions or concerns. For example, a PostService might handle creating, updating, and deleting posts, as well as more complex post-related operations.
  • Structure: Typically contains multiple public methods, each representing a different operation.
  • Example Use: When you have a set of related operations that share dependencies or need to coordinate logic.
class PostService
{
    public function create(array $data) { /* ... */ }
    public function update(Post $post, array $data) { /* ... */ }
    public function delete(Post $post) { /* ... */ }
}

Pros:

  • Centralizes related logic
  • Good for operations that are conceptually grouped
  • Can manage dependencies via constructor injection

Cons:

  • Can become bloated with too much logic
  • Less explicit about single responsibilities

Action Classes

  • Purpose: Encapsulate a single, focused operation or “action” (e.g., CreatePost, SendWelcomeEmail). Each class does one thing and does it well.
  • Structure: Usually one public method (often execute or handle), representing the action.
  • Example Use: When you want to isolate a specific task, especially if it will be reused in multiple places (controllers, jobs, events, etc.).
class CreatePost
{
    public function execute(array $data) { /* ... */ }
}

Pros:

  • Highly focused and easy to test
  • Encourages single responsibility principle
  • Easy to compose and reuse in different parts of your app (controllers, jobs, commands, etc.)

Cons:

  • Can result in many small files
  • If not organized, may lead to scattered logic

When to Use Each?

  • Use a Service Class when you have a set of related operations that share dependencies or need to coordinate logic. For example, a UserService that manages user registration, profile updates, and password resets.
  • Use an Action Class when you want to isolate a single operation, especially if it will be reused in multiple places. For example, a CreateInvoice action that you call from a controller, a scheduled job, or an event listener.

Tip:
You can also combine both! A Service Class might orchestrate several Actions, or Actions might use a Service internally for shared logic.

Practical Example

Suppose your app lets users create posts, and you want to send a notification after a post is created.

With Service Class:

  • PostService@create handles both saving the post and sending the notification.

With Action Class:

  • CreatePost@execute handles saving the post.
  • SendPostNotification@execute handles the notification.
  • The controller (or a service) coordinates the two actions.

4. Domain-Driven Design (DDD)

DDD is an advanced approach that organizes code by domain concepts rather than technical type. It's ideal for large, complex, or long-lived projects.

When to Use:

  • Large, complex, or business-critical projects
  • When working with multiple teams
  • When you need clear domain boundaries

Pros:

  • Highly maintainable and scalable
  • Clear boundaries and business language
  • Aligns code with real-world concepts

Cons:

  • Steep learning curve
  • More boilerplate
  • Overkill for small apps

Example Structure:

app/
  Domain/
    Blog/
      Actions/
      ValueObjects/
      Aggregates/
      Repositories/
    User/
  Application/
  Infrastructure/
  Presentation/

You might have a CreatePost action inside Domain/Blog/Actions, and aggregates or value objects representing your business rules.


5. The Repository Pattern

The Repository pattern provides a layer between your business logic and data access. Instead of interacting directly with Eloquent models, your controller, services or actions talk to repositories, making your code more flexible and testable.

When to Use:

  • When you want to decouple your business logic from Eloquent or your database.
  • In large or complex apps, or when following DDD.

Pros:

  • Makes swapping data sources easier (e.g., database, API, cache).
  • Improves testability (you can mock repositories).
  • Enforces a contract for data access.

Cons:

  • Adds extra boilerplate in simple apps.
  • Can be overkill for small projects.

Example:

// app/Repositories/PostRepository.php
interface PostRepository
{
    public function all();
    public function find($id);
    public function create(array $data);
}

// app/Repositories/EloquentPostRepository.php
class EloquentPostRepository implements PostRepository
{
    public function all() { return Post::all(); }
    public function find($id) { return Post::find($id); }
    public function create(array $data) { return Post::create($data); }
}

You can then inject and use PostRepository in your controller, services or actions instead of Eloquent directly.

Binding the Repository in a Service Provider: To make Laravel resolve PostRepository to your EloquentPostRepository, add the following to your AppServiceProvider or a dedicated service provider:

// app/Providers/AppServiceProvider.php

use App\Repositories\PostRepository;
use App\Repositories\EloquentPostRepository;

public function register()
{
    $this->app->bind(PostRepository::class, EloquentPostRepository::class);
}

Using the repository in your controller for example might look something like this:

// app/Http/Controllers/PostController.php
class PostController extends Controller
{
    protected $postRepository;

    public function __construct(protected PostRepository $postRepository)
    {
        $this->postRepository = $postRepository;
    }

    public function index()
    {
        $posts = $this->postRepository->all();
        return view('posts.index', compact('posts'));
    }
}

Comparison Table

PatternWhen to UseProsCons
ControllersSmall/simple appsQuick, simpleFat controllers
Service ClassMedium/large appsTestable, reusableMore structure
Action ClassReusable tasks, clarityFocused, testable, reusableMany small files
DDDLarge/complex appsScalable, maintainableLearning curve
RepositoryAbstraction, DDD, testingDecouples data access, testableBoilerplate, overkill for small apps

Conclusion

There's no one-size-fits-all answer. Start simple, controllers are fine for small apps. As your app grows, refactor business logic into service classes or action classes. For truly complex domains, consider DDD. The key is to recognize when it's time to evolve your architecture.


Reference: PHP 8.5 Pipe Operator

⚠️ The pipe operator (|>) is only available in PHP 8.5 and above. Attempting to use it in earlier versions will cause a syntax error.

Introduction

PHP 8.5 introduces the long-awaited pipe operator (|>), making it easier to chain function calls and write cleaner, more readable code.

How Does It Work?

  • The ... (spread operator) is the placeholder for the value passed from the previous step.
  • Each function in the chain receives the output of the previous function as its input.
  • You can combine as many actions as you like, making your transformations clear and concise.

Let's see how you might manipulate an array of fruits in PHP, first using traditional code, then with the new pipe operator.

Without the Pipe Operator

Here's how you might perform a series of transformations on an array in classic PHP:

$fruits = ["apple", "banana", "cherry"];
$fruits = array_map(fn($f) => strtoupper($f), $fruits);
$fruits = array_filter($fruits, fn($f) => str_starts_with($f, 'A'));
$fruits = array_values($fruits);

print_r($fruits); // ["APPLE"]

With the Pipe Operator

With PHP 8.5's pipe operator, you can chain these operations more elegantly:

$fruits = ["apple", "banana", "cherry"]
    |> array_map(fn($f) => strtoupper($f), ...)
    |> array_filter(..., fn($f) => str_starts_with($f, 'A'))
    |> array_values(...);

print_r($fruits); // ["APPLE"]

Each function receives the result of the previous one, making your code more readable and expressive.

Limitations & Gotchas

  • Only One Required Parameter: All callables in the pipe chain must accept the piped value as their first (and only required) parameter. You cannot change the position.
  • Callable Flexibility: You can use user functions, built-in functions, static methods, anonymous functions, arrow functions, objects with __invoke, and first-class callables.
  • Type Coercion: The pipe operator follows PHP’s normal type coercion rules. If strict_types is enabled, type mismatches will throw errors.
  • Void Return Types: Functions with void return types can be piped, but the value becomes null for the rest of the chain. Typically, use void functions last.
  • By-Reference Limitation: Functions requiring by-reference parameters (e.g., array_pop) cannot be used in a pipe chain, except for a few special cases like array_multisort and extract.

Final Thoughts

The PHP 8.5 pipe operator is a game-changer for writing expressive, readable code. Whether you’re transforming arrays, objects, or other data, it’s a powerful new tool in your PHP arsenal.


Using Scoped Relationships in Laravel Eloquent

When working with Laravel Eloquent, you’ll often need to filter or extend your relationships to suit your app’s needs. Scoped relationships let you add custom constraints to your relationships, making your models more expressive and your queries more reusable.

Let’s walk through some concrete examples to help you understand and use scoped relationships in your own projects!

What is a Scoped Relationship?

A scoped relationship is simply a relationship method on your model that applies extra query constraints. For example, you might want to easily fetch only the “featured” posts for a user, not just all posts.

Example: User and Posts

Let’s say you have a User model and a Post model. A user can have many posts:

// app/Models/User.php

public function posts(): HasMany
{
    return $this->hasMany(Post::class)->latest();
}

Now, let’s say you want a quick way to get just the featured posts for a user. You can add a new relationship method that scopes the query:

public function featuredPosts(): HasMany
{
    return $this->posts()->where('featured', true);
}

Now you can do:

$featuredPosts = $user->featuredPosts;

Creating Models via Scoped Relationships

But what if you want to create a new featured post using this relationship? By default, the featured attribute won’t be set automatically:

$post = $user->featuredPosts()->create(['title' => 'My Post']);
// $post->featured is NOT true

Introducing withAttributes

Laravel has a withAttributes function which lets you specify default attributes for models created via the relationship:

public function featuredPosts(): HasMany
{
    return $this->posts()->withAttributes(['featured' => true]);
}

Now, when you create a post via this relationship:

$post = $user->featuredPosts()->create(['title' => 'Featured Post']);
// $post->featured is TRUE!

Customizing Query vs. Creation

By default, withAttributes also adds a where clause to the query. If you only want the default attribute on creation, not as a query constraint, pass asConditions: false:

public function featuredPosts(): HasMany
{
    return $this->posts()->withAttributes(['featured' => true], asConditions: false);
}

When Should You Use Scoped Relationships?

  • When you want to DRY up your code and avoid repeating common query constraints.
  • When you want to make your model API more expressive (e.g., $user->publishedPosts, $user->archivedPosts).
  • When you want to provide sensible defaults for related model creation.

Real-World Use Case

Suppose you’re building a blog platform. You want to let users quickly fetch or create “draft” or “published” posts:

public function draftPosts(): HasMany
{
    return $this->posts()->withAttributes(['status' => 'draft']);
}

public function publishedPosts(): HasMany
{
    return $this->posts()->withAttributes(['status' => 'published']);
}

Now you can easily fetch or create posts in either state:

$draft = $user->draftPosts()->create(['title' => 'My Draft']);
$published = $user->publishedPosts()->create(['title' => 'My Article']);

Conclusion

Scoped relationships are a powerful way to make your Eloquent models more expressive and your code more maintainable. Try adding them to your own models to simplify your queries and model creation!


PHP: Yield vs. Traditional Loop

When working with large datasets, such as calculating prime numbers, efficiency is key. In PHP, the yield keyword offers a powerful way to optimize both performance and memory usage. Let’s explore how it compares to traditional loops.

The Problem: Calculating Prime Numbers

Prime numbers are fundamental in mathematics and computing. However, generating a large list of primes can be resource-intensive. Let’s compare two approaches in PHP:

1. Traditional Loop (Without yield)

function getPrimesWithoutYield($limit) {
    $primes = [];
    for ($i = 2; $i < $limit; $i++) {
        $isPrime = true;
        for ($j = 2; $j <= sqrt($i); $j++) {
            if ($i % $j == 0) {
                $isPrime = false;
                break;
            }
        }
        if ($isPrime) {
            $primes[] = $i;
        }
    }
    return $primes;
}

2. Using yield

function getPrimesWithYield($limit) {
    for ($i = 2; $i < $limit; $i++) {
        $isPrime = true;
        for ($j = 2; $j <= sqrt($i); $j++) {
            if ($i % $j == 0) {
                $isPrime = false;
                break;
            }
        }
        if ($isPrime) {
            yield $i;
        }
    }
}

Performance Comparison

Testing both methods to calculate primes up to 10,000,000. Here are the results:

  • Without yield:
    Time: ~25 seconds, Memory: ~42 MB
  • With yield:
    Time: ~23 seconds, Memory: ~0 MB

Why Use yield?

  1. Memory Efficiency: yield generates values on-the-fly, avoiding the need to store the entire list in memory.
  2. Lazy Evaluation: Values are computed only when needed, reducing unnecessary computation.
  3. Scalability: Ideal for processing large datasets without running into memory limits.

Conclusion

For tasks like calculating primes, yield is a game-changer. It offers significant memory savings and maintains comparable speed, making it the better choice for large-scale computations.


Let's build a simple dashboard shell using Tailwind CSS's command-line approach. This method is perfect for quick prototyping and smaller projects where you don't need a full build system.

Step 1: Set Up Your Project

First, create a new project folder and initialize it:

mkdir tailwind-dashboard
cd tailwind-dashboard
npm init -y

Step 2: Install Tailwind CSS

Install Tailwind CSS as a development dependency:

npm install tailwindcss @tailwindcss/cli

Step 3: Create CSS Input File

Create a source CSS file that includes Tailwind:

// filepath: input.css
@import "tailwindcss";

Step 4: Create HTML File

Create an index.html file with our dashboard structure:

// filepath: index.html
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Tailwind Dashboard</title>
  <link rel="stylesheet" href="./output.css">
</head>

<body class="bg-gray-100">
  <div class="min-h-screen flex flex-col">
    <header class="bg-header shadow-md">
      <nav class="bg-gray-800">
        <div class="mx-auto max-w-7xl px-2 sm:px-6 lg:px-8">
          <div class="relative flex h-16 items-center justify-between">

            <div class="flex flex-1 items-center justify-center sm:items-stretch sm:justify-start">
              <div class="flex shrink-0 items-center">
                <img class="h-8 w-auto" src="https://picsum.photos/100" alt="Your Company">
              </div>
              <div class="hidden sm:ml-6 sm:block">
                <div class="flex space-x-4">
                  <a href="#" class="rounded-md bg-gray-900 px-3 py-2 text-sm font-medium text-white"
                    aria-current="page">FIRST</a>
                  <a href="#"
                    class="rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white">SECOND</a>
                  <a href="#"
                    class="rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white">THIRD</a>
                  <a href="#"
                    class="rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white">FOURTH</a>
                </div>
              </div>
            </div>
            <div class="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0">
              <button type="button"
                class="relative rounded-full bg-gray-800 p-1 text-gray-400 hover:text-white focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800 focus:outline-hidden">
                <svg class="size-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
                  <path stroke-linecap="round" stroke-linejoin="round"
                    d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
                </svg>
              </button>


              <div class="relative ml-3">
                <div>
                  <button type="button"
                    class="relative flex rounded-full bg-gray-800 text-sm focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800 focus:outline-hidden"
                    id="user-menu-button" aria-expanded="false" aria-haspopup="true">
                    <img class="size-8 rounded-full" src="https://picsum.photos/256/256" alt="">
                  </button>
                </div>
              </div>
            </div>
          </div>
        </div>

        <div class="sm:hidden" id="mobile-menu">
          <div class="space-y-1 px-2 pt-2 pb-3">
            <a href="#" class="block rounded-md bg-gray-900 px-3 py-2 text-base font-medium text-white"
              aria-current="page">FIRST</a>
            <a href="#"
              class="block rounded-md px-3 py-2 text-base font-medium text-gray-300 hover:bg-gray-700 hover:text-white">SECOND</a>
            <a href="#"
              class="block rounded-md px-3 py-2 text-base font-medium text-gray-300 hover:bg-gray-700 hover:text-white">THIRD</a>
            <a href="#"
              class="block rounded-md px-3 py-2 text-base font-medium text-gray-300 hover:bg-gray-700 hover:text-white">FOURTH</a>
          </div>
        </div>
      </nav>
    </header>

    <div class="flex flex-1 p-4">
      <aside id="sidebar" class="bg-sidebar w-64 fixed md:static">
        SIDEBAR
      </aside>

      <main class="flex-1">
        MAIN
      </main>
    </div>

    <footer class="bg-white py-4 px-6 shadow-inner">
      FOOTER
    </footer>
  </div>
</body>

</html>

Step 5: Build CSS

Now, let's use the Tailwind CLI to build our CSS file:

npx @tailwindcss/cli -i input.css -o output.css --watch

This command:

Takes our input.css with the Tailwind directives Processes it and outputs to output.css The --watch flag keeps the process running and rebuilds when files change

Step 6: View Your Dashboard

Open index.html in your browser to see your dashboard. As you modify your HTML, the CSS will automatically update thanks to the --watch flag.

Extending the Dashboard

Note that you have the basic shell, enhance it for your use case

Production Build

When you're ready for production, generate a minified CSS file:

npx tailwindcss -i input.css -o output.css --minify

This creates a smaller CSS file optimized for production.

Conclusion

You've successfully created a simple dashboard shell using Tailwind CSS's CLI approach. This method is perfect for prototyping and smaller projects where you don't need a complex build system.

The Tailwind CLI provides a streamlined workflow that makes it easy to create beautiful, responsive interfaces without the complexity of larger build systems.

For more information, check out the Tailwind CSS documentation.