Agentic hosting is here. Connect to our MCP now ->

Blog

The application coordinator pattern for modern Laravel architecture

A systematic approach to organizing complex Laravel workflows with clear boundaries, consistent orchestration, and railway-oriented programming.

ยทby Steve McDougall

Laravel has evolved significantly over the years. What began as a relatively simple MVC framework has grown into a powerful platform for building complex applications. With that growth, however, many teams run into a new challenge. There is far less agreement today on how larger Laravel applications should actually be structured.

Most developers have seen the same discussions play out. Should business logic live in service classes, action classes, or repositories? What is the right way to apply Domain Driven Design in a Laravel project? Over time the Laravel ecosystem has borrowed ideas from many other platforms such as Java, C#, and functional programming. While those ideas are useful, they often leave teams with multiple competing patterns and no clear structure for organizing complex workflows.

That's why I've spent the last year designing what I call the Application Coordinator Pattern. It's not just another architectural flavor of the month. It's a systematic approach that finally gives Laravel developers a clear, consistent way to build complex applications without drowning in abstractions or losing the framework's elegance.

The problem this pattern tries to solve

Many Laravel applications start out clean and easy to understand, but as the product grows, the application logic becomes more complicated, and the boundaries between responsibilities start to blur.

Controllers that once handled simple requests begin to accumulate more work. Transactions are introduced to protect data integrity. Events are dispatched when important changes occur. Cache invalidation appears to keep responses fast.

Over time, it becomes common to see controller methods that manage database operations, error handling, orchestration, and HTTP responses all at once.

At that point teams usually refactor. Business logic moves into service classes, action classes, or other abstractions intended to keep controllers thin. While this improves the structure initially, it often raises a new set of questions:

  • Where should transactions live?
  • Which layer is responsible for dispatching events?
  • Should services call other services?
  • How should the same workflow be reused from an API endpoint, a console command, or a queued job?

Different teams solve these problems in different ways. Some rely on larger controllers, others introduce service layers, and some attempt a full Domain Driven Design approach. Each option can work, but they often introduce their own trade-offs. Controllers become difficult to test, service layers start to orchestrate other services, and full DDD structures can feel heavy for many Laravel projects.

What is often missing is a clear place where orchestration belongs. Without that boundary, coordination logic spreads across controllers, services, and other layers, which makes the application harder to reason about as it grows.

The insight: your application needs a brain

Here is the realization that led to this approach: orchestration is its own concern, and it needs a single, consistent home.

Think about the human body. Your brain doesn't personally contract every muscle. It sends signals through your nervous system to specialised organs and muscles that do the actual work. There's a clear separation:

  • Brain -> orchestration, coordination, decision-making.
  • Nervous system -> routing signals to the right place
  • Organs/Muscles -> specialised work.
  • Hormones -> asynchronous side effects.

This metaphor maps beautifully to application architecture:

  • Application Coordinator -> the brain, orchestrating everything.
  • Command Bus -> the nervous system, routing commands.
  • Handlers -> the muscles, doing the actual work.
  • Domain Events -> hormones, broadcasting side effects.
  • Result Objects -> sensory feedback, explicit success/failure.

The Application Coordinator Pattern takes this metaphor seriously and builds an architecture around it.

The pattern: simple rules, clear responsibilities

Let's look at what this pattern looks like in practice. It relies on a small set of components, each with a clear responsibility.

Commands: pure intent

Commands are immutable DTOs that express intent and nothing more.

final readonly class RegisterUserCommand
{
    public function __construct() {
        public string $email,
        public string $name,
        public string $password,
    }
}

Commands contain no logic and no behavior. They simply describe what the application should do. In that sense, commands behave more like messages than traditional objects.

Handlers: focused business logic

Each command is processed by a single handler that performs the business logic.

final readonly class RegisterUserHandler
{
    public function handle(RegisterUserCommand $command): Result
    {
        if (User::query()->where('email', $command->email)->exists()) {
            return Result::error(
	            new InvalidArgumentException('Email already exists'),
			);
        }
 
        $user = User::query()->create([
            'name' => $command->name,
            'email' => $command->email,
            'password' => Hash::make($command->password),
        ]);
 
        $user->recordEvent(new Registered($user));
 
        return Result::ok($user);
    }
}

Notice what the handler does not do:

  • Start database transactions
  • Dispatch events directly
  • Handle HTTP responses
  • Use exceptions as normal control flow

The handler focuses only on the domain work and returns a Result object. This follows the idea behind railway oriented programming, where expected failures are represented explicitly instead of relying on exceptions.

Application coordinator: the orchestration layer

This is where everything comes together. The Application Coordinator acts as the central place where workflows are orchestrated.

final class ApplicationCoordinator
{
    public function __construct(private readonly CommandBus $bus) {}
 
    public function run(object $command): Result
    {
        return DB::transaction(function () use ($command) {
            $result = $this->bus->dispatch($command);
 
            if ($result instanceof Err) return $result;
 
            $value = $result->unwrap();
            if (method_exists($value, 'releaseEvents')) {
                DB::afterCommit(fn() => collect($value->releaseEvents())
                    ->each(fn($event) => Event::dispatch($event)));
            }
 
            return $result;
        });
    }
}

This single method ensures that every command in the application follows the same process:

  • It runs inside a database transaction
  • It is dispatched to the appropriate handler
  • Any recorded events are emitted after the transaction commits
  • The result is returned in a consistent Result type

With this structure in place, each workflow goes through the same orchestration layer instead of handling these concerns in controllers or services.

Domain coordinators: the API your controllers use

Controllers do not need to call the Application Coordinator directly. Instead, a small domain layer can expose the workflows the application supports.

Enter Domain Coordinators:

final class UserCoordinator
{
    public function __construct(
        private readonly ApplicationCoordinator $app
    ) {}
 
    public function register(string $email, string $name, string $password): Result
    {
        return $this->app->run(
            new RegisterUserCommand($email, $name, $password)
        );
    }
 
    public function updateProfile(int $userId, array $data): Result
    {
        return $this->app->run(
            new UpdateUserProfileCommand($userId, $data)
        );
    }
}

Domain Coordinators act as the application facing API for each domain area. They provide a clear and typed interface that controllers, console commands, or jobs can call.

With this structure in place, the controller becomes very small:

public function store(RegisterRequest $request, UserCoordinator $users)
{
    $result = $users->register(
        email: $request->input('email'),
        name: $request->input('name'),
        password: $request->input('password')
    );
 
    return match (true) {
        $result instanceof Ok  => response()->json($result->unwrap(), 201),
        $result instanceof Err => response()->json(['error' => $result->unwrapErr()], 422),
    };
}

At this point the controller only deals with HTTP concerns. Business logic, transactions, and orchestration remain in the application layer.

Why this approach works well

After building several production applications with this pattern, a few practical benefits become clear.

1. Consistency across workflows

Every single workflow in your application follows the same path: Controller -> Domain Coordinator -> Application Coordinator -> Command Bus -> Handler -> Result.

No more wondering "Where does this transaction go?" or "Who dispatches this event?". The answer is always the same: the Application Coordinator handles it.

2. Testing becomes obvious

Want to test business logic? Test the handler. It's a pure function that takes a command and returns a result.

Want to test the full workflow including transactions and events? Test through the Application Coordinator.

Want to test HTTP concerns? Test the controller separately.

The boundaries are crystal clear.

3. Reusability without duplication

Consider a workflow such as registering a user. The same operation might be triggered from several places:

  • A web endpoint
  • An API endpoint
  • A console command
  • A queued job
  • A test factory

With this structure, the command and handler are written once. The orchestration remains the same regardless of where the request originates.

4. Railway-oriented programming in PHP

Using result objects changes how expected failures are handled. Instead of relying on exceptions for normal control flow:

try {
	$user = $userService->register($data);
	return response()->json($user, 201);
} catch (ValidationException $e) {
	return response()->json(['error' => $e->getMessage], 422);
} catch (DatabaseException $e) {
	return response()->json(['error' => 'Database error'], 500);
}

The result can be handled explicitly:

return match (true) {
	$result->isOk() => response($result->unwrap(), 201),
	$result->isErr() => response(['error' => $result->unwrapErr()], 422),
};

Expected failures aren't exceptions. They're first-class values you can compose, transform, and handle explicitly.

5. Events Get First-Class Treatment

Domain events are collected during command execution and dispatched only after the transaction commits. This ensures that events are not emitted for operations that ultimately fail and keeps event handling consistent across the application.

What about other architectural patterns?

You might be wondering how this compares to patterns that already exist. Is this just CQRS? Is it a form of Domain Driven Design? What about the repository pattern?

The goal here is not to introduce another rigid architectural rule set. The pattern borrows useful ideas from existing approaches but keeps the structure lightweight.

  • CQRS? Commands in this approach are inspired by CQRS, but the pattern does not enforce strict read and write separation or require event sourcing. Many applications simply do not need that level of complexity.
  • DDD? Concepts such as domain boundaries and coordinators help organize larger applications, but the approach does not require the full set of Domain Driven Design patterns such as aggregates, value objects, and extensive domain layers.
  • Repository pattern? Laravel already provides a powerful ORM with Eloquent. In many cases wrapping it in repositories adds little value. Using Eloquent directly inside handlers keeps the implementation straightforward.

The idea is to take the useful concepts from these patterns while avoiding unnecessary complexity. For many Laravel teams, that balance provides structure without excessive ceremony.

The future of Laravel architecture

I genuinely believe this is where Laravel architecture needs to go. Not because it's theoretically pure or academically interesting, but because it solves real problems real developers face every day.

We need a standard way to:

  • Orchestrate complex workflows.
  • Handle transactions consistently.
  • Emit events reliably.
  • Return explicit success/failure.
  • Keep controllers thin.
  • Test business logic in isolation.
  • Reuse workflows across contexts.

The Application Coordinator Pattern gives us all of that without fighting the framework or drowning in abstractions.

Start small, scale naturally

The beauty of this pattern is you can start small. Pick one controller that's gotten messy. Extract a Command and Handler. Wire up a Domain Coordinator. See how it feels.

You don't need to rewrite your entire application. This pattern scales from a single workflow to a hundred. The complexity grows linearly with your business logic, not exponentially with your architecture.

Final thoughts

Architectural patterns are useful only if they help teams build and maintain real software. The goal of this approach is not perfection, but clarity.

Applications should remain understandable as they grow. Workflows should be reusable across different contexts. New developers should be able to understand how the system is structured without learning a large set of conventions.

Giving orchestration a clear place in the architecture moves Laravel applications closer to that goal.

Deep dive into the cloud!

Stake your claim on the Interwebz today with Sevalla's platform!
Deploy your application, database, or static site in minutes.

Get started