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
orhandle
), 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
Pattern | When to Use | Pros | Cons |
---|---|---|---|
Controllers | Small/simple apps | Quick, simple | Fat controllers |
Service Class | Medium/large apps | Testable, reusable | More structure |
Action Class | Reusable tasks, clarity | Focused, testable, reusable | Many small files |
DDD | Large/complex apps | Scalable, maintainable | Learning curve |
Repository | Abstraction, DDD, testing | Decouples data access, testable | Boilerplate, 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.