Introduction

As Laravel applications grow, controllers usually become the first place where architectural pain starts to show up. A single controller method begins with request validation, then jumps into business rules, talks to models, dispatches jobs, and finally decides which response or redirect should be returned.

That works for small flows, but after some time it becomes hard to understand what belongs to the HTTP layer, what belongs to the business layer, and what is just response formatting.

This is where the ADR pattern can help a lot. ADR means Action / Domain / Responder. The idea is the same: keep the HTTP entrypoint focused, keep the business logic in the domain, and keep the response decision in a dedicated class.

In this article, let's understand what the ADR pattern is, why it fits really well in Laravel, and how to apply it with a simple example.

What is the ADR Pattern?

The ADR pattern splits a request flow into three very clear responsibilities.

The Action is the entry point for the HTTP request. In Laravel, this is usually an invokable controller. Its responsibility is small: receive the request, collect the input, call the domain, and pass the domain result to the responder.

The Domain is where the business logic lives. This is the part that knows your rules, invariants, and decisions. It should not care if the request came from a web route, an API, or a console command.

The Responder is the class that takes the domain result and resolves it into the final HTTP response. That could be a redirect, a JSON response, a view, or even an exception.

If we reduce it to one sentence, the flow looks like this:

  • Action receives the request.
  • Domain decides what should happen.
  • Responder decides how to express that result to the client.

That separation may look small at first, but it removes a lot of accidental complexity from our controllers.

Why to Use ADR in Laravel

Laravel already gives us a good foundation for this pattern.

We have routes and invokable controllers for the Action, services or actions for the Domain, and response classes, redirects, JSON responses, or view models for the Responder.

The biggest benefit of using ADR is not that it creates more classes. The real benefit is that it makes each class easier to reason about.

With ADR:

  • the Action stays thin and framework-facing
  • the Domain becomes easier to reuse and test
  • the Responder keeps response logic out of your business rules
  • the whole request flow becomes easier to evolve without mixing concerns

This is especially useful when a single business flow can be consumed by different entry points. For example, the same domain logic might be used by a web controller, an API endpoint, and a queued job. If your controller is carrying all the business logic, reuse becomes painful.

With ADR, the controller stops being the center of the world, which is usually a very good thing.

A Quick Before and After

Let's start with a tiny example of what many applications end up doing:

final class PublishArticleController extends Controller
{
    public function __invoke(Request $request, Article $article): RedirectResponse
    {
        abort_unless($request->user()->can('publish', $article), 403);

        if ($article->status === 'published') {
            return back()->withErrors([
                'article' => 'This article is already published.',
            ]);
        }

        $article->update([
            'status' => 'published',
            'published_at' => now(),
        ]);

        return redirect()
            ->route('articles.show', $article)
            ->with('success', 'Article published successfully.');
    }
}

This is not terrible. But the HTTP layer is making authorization decisions, checking domain state, updating the model, and deciding the response all in one place.

Now let's split that flow using ADR.

Implementing ADR in Laravel

For this example, imagine that we want to publish an article from an admin panel. The rules will be simple:

  • only authorized users can publish an article
  • an article that is already published should not be published again
  • if the publish_at date is in the future, the article should be scheduled instead

Let's start with the Action.

The Action

The Action should be very small. It receives the request, calls the domain, and delegates the final response to the responder.

use App\Domain\Articles\Actions\PublishArticle;
use App\Domain\Articles\Responders\PublishArticleResponder;
use App\Models\Article;
use Carbon\CarbonImmutable;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

final class PublishArticleAction
{
    public function __invoke(
        Request $request,
        Article $article,
        PublishArticle $publishArticle,
        PublishArticleResponder $responder,
    ): RedirectResponse {
        $publishAt = CarbonImmutable::parse(
            $request->string('publish_at')->toString()
        );

        $result = $publishArticle->handle(
            article: $article,
            actor: $request->user(),
            publishAt: $publishAt,
        );

        return $responder->toResponse($result);
    }
}

Notice how the Action does not contain business rules. It is just coordinating the flow. That is exactly the point.

And your route can stay very simple too:

Route::post('/articles/{article}/publish', PublishArticleAction::class)
    ->name('articles.publish');

Now let's move to the part that really matters: the Domain.

The Domain

The Domain should return a result that explains what happened. I like doing this with a small result object plus an enum representing the outcome.

enum ArticleStatus: string
{
    case Draft = 'draft';
    case Scheduled = 'scheduled';
    case Published = 'published';
}

enum PublishArticleStatus: string
{
    case Published = 'published';
    case Scheduled = 'scheduled';
    case Forbidden = 'forbidden';
    case AlreadyPublished = 'already_published';
}

final readonly class PublishArticleResult
{
    public function __construct(
        public PublishArticleStatus $status,
        public Article $article,
    ) {}
}

Now the actual domain action:

use App\Models\Article;
use App\Models\User;
use App\Support\ArticleStatus;
use Carbon\CarbonImmutable;

final class PublishArticle
{
    public function handle(
        Article $article,
        User $actor,
        CarbonImmutable $publishAt,
    ): PublishArticleResult {
        if (! $actor->can('publish', $article)) {
            return new PublishArticleResult(
                status: PublishArticleStatus::Forbidden,
                article: $article,
            );
        }

        if ($article->status === ArticleStatus::Published) {
            return new PublishArticleResult(
                status: PublishArticleStatus::AlreadyPublished,
                article: $article,
            );
        }

        if ($publishAt->isFuture()) {
            $article->update([
                'status' => ArticleStatus::Scheduled,
                'published_at' => $publishAt,
            ]);

            return new PublishArticleResult(
                status: PublishArticleStatus::Scheduled,
                article: $article->refresh(),
            );
        }

        $article->update([
            'status' => ArticleStatus::Published,
            'published_at' => $publishAt,
        ]);

        return new PublishArticleResult(
            status: PublishArticleStatus::Published,
            article: $article->refresh(),
        );
    }
}

This is where the important decisions are happening.

The Domain does not know if this result will become a redirect, a JSON payload, or something else. It only knows the business outcome.

That is a very healthy boundary.

The Responder

Now we need something that transforms the domain result into an HTTP response. That's the job of the Responder.

use Illuminate\Http\RedirectResponse;

final class PublishArticleResponder
{
    public function toResponse(PublishArticleResult $result): RedirectResponse
    {
        return match ($result->status) {
            PublishArticleStatus::Published => redirect()
                ->route('articles.show', $result->article)
                ->with('success', 'Article published successfully.'),

            PublishArticleStatus::Scheduled => redirect()
                ->route('articles.show', $result->article)
                ->with('success', 'Article scheduled successfully.'),

            PublishArticleStatus::AlreadyPublished => back()->withErrors([
                'article' => 'This article is already published.',
            ]),

            PublishArticleStatus::Forbidden => abort(403),
        };
    }
}

The nice thing here is that the response logic is also explicit and centralized.

If tomorrow you decide that Forbidden should redirect to a custom page instead of returning a 403, you change the Responder, not the Domain.

And if you later expose the same domain flow through an API, you can create another responder, for example PublishArticleApiResponder, without touching the business logic.

Why This Separation Helps So Much

At first, ADR can feel like "just one more class", but in real applications the benefits show up very quickly.

The Action stays small and easy to scan.

The Domain becomes the real source of truth for the use case.

The Responder becomes the place where transport concerns live.

That means each layer changes for different reasons:

  • change the request flow: update the Action
  • change the business rule: update the Domain
  • change the HTTP output: update the Responder

That is the kind of separation that makes maintenance much easier after six months, not only five minutes after writing the code.

A Few Practical Rules for Using ADR

If you want to start using ADR in Laravel, I would recommend a few simple rules.

First, keep your Action really thin. If it starts growing branches and conditions, you are probably leaking domain logic back into the controller layer.

Second, make your Domain return an explicit result. Booleans are usually too poor for non-trivial flows. A result object with a status enum is much easier to understand.

Third, keep the Responder focused on transport concerns. It should resolve a domain result into an HTTP response, not re-evaluate business rules.

Fourth, don't force ADR everywhere. If a route just returns a simple page or updates one field with no real business rules, a full ADR split might be unnecessary. Use it where the flow actually has enough complexity to justify the separation.

Testing the Flow

Another nice advantage of ADR is how naturally it improves testing.

You can test the Domain in isolation by asserting that a given input produces the expected PublishArticleStatus.

For example, a simple Pest test for the domain action could look like this:

use App\Domain\Articles\Actions\PublishArticle;
use App\Domain\Articles\Results\PublishArticleStatus;
use App\Models\Article;
use App\Models\User;
use App\Support\ArticleStatus;
use Carbon\CarbonImmutable;

it('schedules the article when publish_at is in the future', function () {
    $author = User::factory()->create();

    $article = Article::factory()->create([
        'status' => ArticleStatus::Draft,
    ]);

    $result = app(PublishArticle::class)->handle(
        article: $article,
        actor: $author,
        publishAt: CarbonImmutable::now()->addDay(),
    );

    expect($result->status)->toBe(PublishArticleStatus::Scheduled)
        ->and($result->article->status)->toBe(ArticleStatus::Scheduled);
});

You can test the Responder separately if you want to validate redirects, errors, or JSON payloads for each result.

In many cases, though, I like testing the HTTP behavior with a Feature Test, because it validates the full collaboration between the Action, the Domain, and the Responder.

use App\Models\Article;
use App\Models\User;
use App\Support\ArticleStatus;
use Carbon\CarbonImmutable;

it('publishes an article through the adr endpoint', function () {
    $user = User::factory()->create();

    $article = Article::factory()->create([
        'status' => ArticleStatus::Draft,
    ]);

    $this->actingAs($user)
        ->post(route('articles.publish', $article), [
            'publish_at' => CarbonImmutable::now()->toDateTimeString(),
        ])
        ->assertRedirectToRoute('articles.show', $article)
        ->assertSessionHas('success', 'Article published successfully.');

    expect($article->fresh()->status)->toBe(ArticleStatus::Published);
});

If you want even more confidence, you can also add a small test for the edge cases, like an already published article or a forbidden actor. The nice thing is that each test can focus on one layer or one outcome, instead of forcing you to exercise all the branches through one huge controller test.

That layered approach usually leads to cleaner and more focused tests than trying to validate every possible branch through one big controller test.

Conclusion

The ADR pattern is a really nice fit for Laravel when you want to keep your request flows clean and your business rules easy to evolve.

By splitting the flow into Action, Domain, and Responder, you stop overloading your controllers and start giving each layer a very clear responsibility.

The best part is that this is not a complicated pattern to adopt. You can start small, pick one use case with a bit more business logic, and apply the structure there. After that, the benefits become very easy to feel in day-to-day development.

I hope that you liked this article and if you do, don't forget to share this article with your friends!!! See ya!