Updated June 2026. Tested on Laravel 13 and PHP 8.4.
The idea behind clean architecture is simple to say and hard to keep: your business logic should not depend on the framework, the database, or the web. The framework calls your logic, not the other way round. When you get this right, you can change the database, swap a third party service, or even move frameworks, without rewriting the rules that make your app what it is. And your tests run fast, because they do not boot a router or hit a database to check a result.
You do not need a heavyweight setup to get the benefits in Laravel. A little discipline goes a long way.
Push logic out of controllers
The first habit is to keep controllers thin. A controller should take the request, hand off to a piece of business logic, and return a response. That is all. The logic itself goes into a small single purpose class, often called an action.
class RegisterUser
{
public function __construct(private SendsWelcomeMessage $welcome) {}
public function handle(string $name, string $email, string $password): User
{
$user = User::create([
'name' => $name,
'email' => $email,
'password' => Hash::make($password),
]);
$this->welcome->send($user);
return $user;
}
}
The controller becomes a thin wrapper.
public function store(RegisterUserRequest $request, RegisterUser $action)
{
$user = $action->handle(
$request->string('name'),
$request->string('email'),
$request->string('password'),
);
return redirect()->route('dashboard');
}
Now the registration rules live in one class that you can test on its own, with no HTTP involved.
Depend on interfaces, not concrete classes
The second habit is to depend on an interface for anything external, like sending a message or talking to a third party. Notice RegisterUser asked for a SendsWelcomeMessage interface, not a specific mail or SMS class.
interface SendsWelcomeMessage
{
public function send(User $user): void;
}
You bind the real implementation in a service provider.
$this->app->bind(SendsWelcomeMessage::class, MailWelcomeMessage::class);
Because the action depends on the interface, you can switch from email to SMS by changing one binding, and your tests can pass in a fake that records the call instead of sending anything.
Test first, then build
Test driven development fits this naturally. You write the test for the behaviour before the code, watch it fail, then write just enough to make it pass. Here the test is fast because it exercises the action directly.
it('registers a user and sends a welcome', function () {
$welcome = new FakeWelcomeMessage();
$action = new RegisterUser($welcome);
$user = $action->handle('Jane', 'jane@example.com', 'secret');
expect($user->email)->toBe('jane@example.com');
expect($welcome->sentTo)->toContain('jane@example.com');
});
No browser, no real mail, no route. The test asserts the rule, the welcome message is sent, by checking a fake. That is the red, green, refactor cycle: a failing test, the smallest code to pass it, then tidy up.
How far to take it
You can go much further, with full ports and adapters and a repository layer hiding Eloquent behind an interface. For a large, long lived system that pays off. For most Laravel apps, the two habits above carry most of the value: thin controllers calling single purpose actions, and interfaces for anything that touches the outside world. Do that and your business rules stay yours, testable and independent, even as the framework around them changes. Questions welcome in the comments.
All comments ()
No comments yet
Be the first to leave a comment on this post.