Restructuring a Laravel application using Domains, Actions and more

Jun 8, 2022

In a recent article, Povilas Korop of Laravel Daily wrote about restructuring a Laravel Controller to be simpler and have a better structure.

In this article, I will give my take on the same subject. I often feel that in these types of articles, much is left out in order not to be too verbose, to keep the complexity down or because it feels over engineering to follow through with the examples. I will give a full structural suggestion based on my experience and if it feels like overkill or over engineering for such simple functionality, I want to stress that simple functionality often leads to a larger application and you might as well get the structure right (for a given value of "right") from the beginning.



The requirements

We are going to build a very small part of an application with the same functionality as in Povilas' article with a few extra parts thrown in where they lead to interesting considerations.

That means, we will have an application with an endpoint to create a new user. This endpoint should:

1. Validate the input

2. Create the user

3. Upload and attach a user avatar

4. Log in the newly created user

5. Generate a voucher

6. Send an email to the new user with the voucher

7. Notify administrators about the new user creation

To illustrate a point, I will add

8. When the user is created, it should generate an API key for the user - a simple string value saved on the user object

Starting point

Let's assume, we have an existing controller with the (almost) same code as in Povilas' example:

public function store(Request $request) 
{ 
  // 1. Validation 
  $request->validate([ 
    'name' => ['required', 'string', 'max:255'], 
    'email' => [
      'required', 
      'string', 
      'email', 
      'max:255', 
      'unique:users',
    ], 
    'password' => [
      'required', 
      'confirmed', 
      Rules\Password::defaults()
    ],
  ]);
      
  // 2. Create user
  $user = User::create([
    'name' => $request->name,
    'email' => $request->email,
    'password' => Hash::make($request->password),
    // Generate the API key
    'api_key' => Hash::make(Str::random()), 
  ]);

  // 3. Upload the avatar file and update the user
  if ($request->hasFile('avatar')) {
    $avatar = $request->file('avatar')->store('avatars');
    $user->update(['avatar' => $avatar]);
  }

  // 4. Login
  Auth::login($user);

  // 5. Generate a personal voucher
  $voucher = Voucher::create([
    'code' => Str::random(8),
    'discount_percent' => 10,
    'user_id' => $user->id
  ]);

  // 6. Send that voucher with a welcome email
  $user->notify(
    new NewUserWelcomeNotification($voucher->code)
  );

  // 7. Notify administrators about the new user
  foreach (config('app.admin_emails') as $adminEmail) {
    Notification::route('mail', $adminEmail)
      ->notify(new NewUserAdminNotification($user));
  }

  return redirect()->route('dashboard');
}

This has all the elements of our requirements, it is rather clear and understandable. However, it also has a lot of logic and it does not foster reusability or code decoupling.

So let's talk about the structure of the code.

Controller responsibility

I completely and wholeheartedly agree with Povilas Korop's statement:

Controller methods need to do three things:

  1. Accept the parameters from routes or other inputs
  2. Call some logic classes/methods, passing those parameters
  3. Return the result: view, redirect, JSON return, etc.

So, controllers are calling the methods, not implementing the logic inside the controller itself.

Domain structure

I like to organising my code into problem domains. Besides keeping code together that logically belongs together, it also allows for splitting certain parts out into separate packages that can be included in multiple projects and it can drive the code structure design and make it easier to answer the age-old question "Where do I put this method?". It can also make it more cumbersome to write new code as this question will have to be answered for every bit of code... but I feel it makes the code better in the long run to have these considerations and/or debates from the beginning.

When doing the domain splitting, I look at the parts and ask "what depends on what?"

In our example, we have at least two parts: User and Voucher. They are connected, but not so tightly that they need to be in the same domain. Some domains have multiple concepts - a hypothetical "Questionnaire" domain could have both Questionnaires, Questions and Replies where it would make sense to have them grouped together. In general, I try not to make my domains too small, but on the other hand, all concepts in a domain should have a clear connection to each others.

Next, I look at how the the dependencies are between the domains:

  • Vouchers depends on Users - we cannot really (in our example) have a Voucher that is not connected to a User. If we were to remove the User domain, the concept of our Voucher would no longer make sense.
  • Users, on the other hand, do not require Vouchers. If we were to remove the Voucher functionality, Users would still make sense and they would just not have a voucher.

So we will have two domains, Voucher and User, and Voucher will depend on User, but not the other way around. In practice, this means that the Voucher domain's classes and functions can use User classes, objects and interfaces, whereas classes and functions in the User domain cannot use Voucher classes, objects or interfaces.

Where to place the domains

There is no hard rule as to where the domains should live. In a Laravel application, I like to create a new 'domain' root folder so we have both ./app and ./domain.

So to sum up, we have the following structure:

./app
  ./app/Http
  ./app/Console
  ...
./bootstrap
./config
./database
./domain
  ./domain/User
  ./domain/Voucher
... 

For this to work, we naturally need to configure our composer.json to map the new folder to a PSR-4 namespace

"autoload": {
    "psr-4": {
        "App\\": "app",
        "Domain\\": "domain", // Add Domain namespace mapping
        "Database\\Factories\\": "database/factories/",
        "Database\\Seeders\\": "database/seeders/"
    }
},

To clarify the domain vs. app folders, everything that is application-specific - meaning it is aware of the HTTP or CLI context, user authentication mechanisms (cookie, HTTP Auth, API key, OAuth, etc.), request or input parameters etc. will live in the application. Everything that performs an action, modifies or fetches data, defines the authorization etc. will live in the domain namespaces.



1. Validation

The Controller is responsible for the input data being validated, but it is not required to perform this validation itself. Instead, it can delegate that to a relevant class. Laravel has - as Povilas also mentions in his article - a great framework for this in the FormRequest class, so let's do as Povilas and make the validation this way - although I call it CreateUserRequest rather than StoreUserRequest. It just feels more natural to me:

namespace App\Http\Requests;

class CreateUserRequest extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
        ];
    }
}

and use this in our Controller:

use App\Http\Requests\CreateUserRequest;

class RegisteredUserController extends Controller
{
    public function store(CreateUserRequest $request)
    {
        // 2. Create user
        $user = User::create([...]) // ...
    }
}

With the validation moved elsewhere, we can move on to the next steps.

2. Create the User

For creating a User, we need a way to move the logic away from the Controller and into a more suitable place.

I am not a fan of Service classes - the Single Responsibility Principle gets very fuzzy if we were to define the responsibility of e.g. a UserService as "perform actions on or for Users". It is very easy to put too much logic into such a UserService and with a couple of private helper methods for each public service method, the Service class can quickly grow out of control.

Instead, I prefer to use Actions. It's a rather newly named concept in Laravel, but really it is very similar to Jobs; A class with a single purpose: To perform one action. The difference for me is that I think of Jobs as being queued - that is, they are run in a separate process and any return value is of little use to the caller. Of course, you don't have to use Jobs like that. You can call them synchronously and return a value that is used by the caller, but I find that it clogs up my mental capacity to distinguish between queued Jobs and synchronous Jobs. Hence, I always queue Jobs and run Actions synchronously.

There are packages that attempt to streamline Actions, but I rarely feel they are necessary. An Action is simply a class with a constructor taking arguments through dependency injection from the Laravel Service Container, and a public method to perform the action. This method can be called whatever you like - handle, perform, do or my own favourite: execute - and take whatever arguments you like. In that respect it is very much like a Job, except that a Job takes the arguments in the constructor and uses dependency injection in the handle() method. The main benefit from this reversal is that an Action can itself be dependency injected.

So a CreateNewUser Action could look like this:

namespace UseCases\Actions;

use Domain\User\Models\User;

class CreateNewUser
{
    public function execute(): User
    {
        // Execute the action and return the created user
    }
}

Wait! UseCases?

Oh, right. We have the Application layer for receiving input and calling logic in the Domain, but it is useful to have a layer in between that ties the Application and Domain together. I call this layer the UseCase layer. This layer will hold the Actions, the queued Jobs and the Event Listeners.

Action arguments

Besides the body of the execute() method in the CreateNewUser Action, we also need to decide on the arguments, we will pass to the method. We could pass the CreateUserRequest object - but even though the CreateUserRequest may be possible to instantiate from anywhere, it is very much an HTTP concept. This is also where the Domain separation saves us from making a mistake. Since the CreateUserRequest lives in the App namespace, we may not call it from the Domain. The Application layer may use the UseCases and Domain layers and the UseCases may use the Domain layer, but the Domain must never call anything from the Application or UseCase layer, and the UseCase layer must never call anything from the Application layer.

For this reason, I reach for Data Transfer Objects or DTOs. Simple objects that hold data in a structured way, usable anywhere. Again, you can go full Spatie and use their laravel-data package for this, or you can use a simple class. I usually go with the latter; a simple POPO in the Domain.

namespace Domain\User\DTO;

use Illuminate\Foundation\Http\UploadedFile;

class UserObject
{
    public function __construct(
        public readonly string $name,
        public readonly string $email,
        public readonly ?UploadedFile $avatar = null
    )
    {}
}

Where to instantiate the DTO is the next question to answer. Is it the Controllers responsibility? It could be, but since we already have an object that handles the request and knows the input data structure, let's reuse CreateUserRequest to give us the DTO in a public method that we call data():

namespace App\Http\Requests;

class CreateUserRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
        ];
    }

    public function data(): UserObject
    {
        return new UserObject(
          $this->name, 
          $this->email,
          $this->file('avatar');
        );
    } 
}

Now, we are ready to go back to the Controller

use App\Http\Requests\CreateUserRequest;
use UseCases\User\CreateNewUser;

class RegisteredUserController extends Controller
{
    public function store(
      CreateUserRequest $request, 
      CreateNewUser $action
    )
    {
        $action->execute($request->data());
    }
}

And we can continue in the Action, implementing the actual user creation:

namespace UseCases\Actions;

use Domain\User\Models\User;
use Domain\User\DTO\UserObject;

class CreateNewUser
{
    public function execute(UserObject $data): User
    {
        return User::create([
          'name' => $data->name,
          'email' => $data->email,
        ]);
    }
}

That takes care of the user creation and we can continue to requirement 3.

3. Upload and attach a user avatar

We previously put the user avatar on the UserObject, so as far as the Controller is concerned, this is already done - however, we are not yet using it.

One way is to implement it directly in the CreateNewUser Action, but is uploading and attaching an avatar really so tightly connected to creating a new user that we want them to be inseparable? I don't think so. We could easily have another requirement to be able to upload and attach an avatar in the process of editing a user - or as a stand-alone process from a dedicated endpoint. So let's make sure not to put too much together.

Instead, we can create a new Action for storing and attaching a file to a User:

namespace UseCases\Actions;

use Domain\User\Models\User;
use Illuminate\Foundation\Http\UploadedFile;

class AddAvatarToUser
{
    public function execute(UploadedFile $file, User $user): bool
    {
        return $user->update(['avatar' => $file->store('avatars')]);
    }
}
Note that I am passing an UploadedFile directly instead of wrapping in a DTO - since there is only the file and the user, I find a DTO overkill. In the case of UserObject, I would imagine that there were plenty of other fields on a user that would be beneficial to have in the DTO.

Now, we just need to use this Action from our previous Action:

namespace UseCases\Actions;

use Domain\User\Models\User;
use Domain\User\DTO\UserObject;
use UseCases\Actions\AddAvatarToUser;

class CreateNewUser
{
    private AddAvatarToUser $addAvatarAction;

    public function __construct(
       AddAvatarToUser $avatarAction
    )
    {}

    public function execute(UserObject $data): User{
        $user = User::create([
          'name' => $data->name,
          'email' => $data->email,
        ]);
        
        $this->addAvatarAction->execute($data->avatar, $user);

        return $user;
    }
}

By using dependency injection, we can get the action to add the avatar to the User with very little extra code.

Note: We could go a step further and have AddAvatarToUser implement an interface that we in the Application's AppServiceProvider bind to the concrete class. This would allow us to keep the Actions unaware of each other and it is something I would normally do.


4. Log in the newly created user

Logging in the user is the next step. It is not something that I would consider a general part of creating a new user, it is interacting with the request architecture and there is already a perfectly fine Laravel method of abstraction in the Auth facade. Therefore, I am completely on board with Povilas that this should be in the Controller.

use App\Http\Requests\CreateUserRequest;
use UseCases\User\CreateNewUser;
use Illuminate\Support\Facades\Auth;

class RegisteredUserController extends Controller
{
    public function store(
      CreateUserRequest $request, 
      CreateNewUser $action
    )
    {
        $user = $action->execute($request->data());

        Auth::login($user);

        return redirect()->route('dashboard');
    }
}

5. Generate a Voucher

Is generating a Voucher always happening whenever we create a new user? That would be a question for the Product Owner or similar requirement responsible person, but in this case, I will assume that yes, creating a User should always generate a Voucher.

Secondly, we need to consider the Domain. The straightforward approach would be to add the Voucher generation inside the CreateNewUser Action, but the User Domain should not "know" about the Voucher Domain. Therefore we need to decouple the Voucher generation from the User creation.

A really good method is to use Events for this. It makes great sense to dispatch an Event whenever the CreateNewUser Action has created the user for anything and anyone to react to.

A word on Observers. We could use an Observer to watch the User created model event and generate the Voucher then. However, I do not recommend using them for decoupled logic, when an Event is possible. We will get back to Observers further down.

Adding an Event accepting the new User to CreateNewUser Action results in the following:

namespace UseCases\Actions;

use Domain\User\Models\User;
use Domain\User\DTO\UserObject;
use Domain\User\Events\UserCreated;
use UseCases\Actions\AddAvatarToUser;

class CreateNewUser
{
    public function __construct(
      private AddAvatarToUser $avatarAction
    )
    {}

    public function execute(UserObject $data): User
    {
        $user = User::create([
          'name' => $data->name,
          'email' => $data->email,
        ]);
        
        $this->addAvatarAction->execute($data->avatar, $user);

        event(new UserCreated($user));

        return $user;
    }
}

Generating the Voucher is then accomplished by a Listener:

namespace UseCases\Listeners;

use Domain\User\Events\UserCreated;

class GenerateVoucher
{
    public function handle(UserCreated $event): void
    {
        Voucher::create([
          'code' => Str::random(8),
          'discount_percent' => 10,
          'user_id' => $event->user->id,
        ]);
    }
}

And that concludes the 5th step.

6. Send an email to the new user with the voucher

Until now, it has been quite easy to place the logic in separate places, but now we get into the meat of the UseCase namespace and its separation from Domain, because not only do we want to send an email to the new user, we also want to include the voucher.

Since the UseCase namespace is separate from the Domain, we are not as constrained as we would be, if we wanted to place the Actions, Jobs and Listeners in the Domain. We can absolutely use multiple Domains in the same UseCase and with this new requirement, that is what we will do.

After the logic in the CreateNewUser Action is executed we need to get the Voucher so we can use it for sending the welcome email. If CreateNewUser did not always have to send the welcome email, we could make a "meta Action" that called CreateNewUser, then a GenerateVoucher Action and then a SendWelcomeEmail Action - but as we decide that CreateNewUser will always generate a Voucher and send the welcome email, we can add the logic to CreateNewUser.

First, we modify the GenerateVoucher Listener into a GenerateVoucher Action. Quite simple, as it is just moving the namespace, renaming the method (for consistency) and adjusting the arguments:

namespace UseCases\Actions;

use Domain\Voucher\Models\Voucher;
use Domain\User\Models\User;
use Illuminate\Support\Str;

class GenerateVoucher
{
    public function execute(User $user): Voucher
    {
        return Voucher::create([
          'code' => Str::random(8),
          'discount_percent' => 10,
          'user_id' => $user->id,
        ]);
    }
}

Then we create the new SendWelcomeEmail Action that takes a User and a Voucher:

namespace UseCases\Actions;

use Domain\User\Models\User;
use Domain\User\Models\Voucher;

class SendWelcomeEmail
{
    public function execute(User $user, Voucher $voucher): bool
    {
       return $user->notify(
         new NewUserWelcomeNotification($voucher->code)
       );
    }
}

Then we add the new Actions to CreateNewUser:

namespace UseCases\Actions;

use Domain\User\Models\User;
use Domain\User\DTO\UserObject;
use Domain\User\Events\UserCreated;
use UseCases\Actions\AddAvatarToUser;
use UseCases\Actions\GenerateVoucher;
use UseCases\Actions\SendWelcomeEmail;

class CreateNewUser
{
    public function __construct(
      private AddAvatarToUser $avatarAction,
      private GenerateVoucher $generateVoucherAction,
      private SendWelcomeEmail $sendWelcomeEmailAction,
    )
    {}

    public function execute(UserObject $data): User{
        $user = User::create([
          'name' => $data->name,
          'email' => $data->email,
        ]);
        
        $this->addAvatarAction->execute($data->avatar, $user);

        $voucher = $this->generateVoucherAction($user);
        $this->sendWelcomeEmailAction($user, $voucher);

        event(new UserCreated($user));

        return $user;
    }
}

We now have one place to look for the process of creating a new User. It can be called from anywhere and it delegates logic to other parts for other, specific parts.

Moving on.

7. Notify administrators about the new user creation

You may have noticed that I left the UserCreated Event in the CreateNewUser Action. That was intentional as I still think it is a good idea to dispatch the Event for easy reactions in future code. And with the requirement to notify administrators, we can already conclude that the future is now!

We have gone over the decisions and considerations for doing something that is decoupled logically, but still depends on something else, and that is the Listener. With the Event in place, adding new logic on top is as easy as creating a new Listener and configure it to listen on the Event in the application's EventServiceProvider.

namespace UseCases\Listeners;

use Domain\User\Events\UserCreated;
use Domain\User\Notifications\NewUserAdminNotification;

class NotifyAdministrators
{
    public function handle(UserCreated $event): void{
      foreach (config('app.admin_emails') as $adminEmail) {
        Notification::route('mail', $adminEmail)
          ->notify(
            new NewUserAdminNotification($event->user)
          );
      }
    }
}

I am assuming that NewUserAdminNotification is a queued Notification so as not to slow down the request execution time. Also, as Povilas notes, if there was more logic in notifying the administrators, it should be in a Job that would be dispatched from the Listener.

Now to the final step:

8. When the user is created, it should generate an API key for the user

I added this requirement because I want to make a point about Observers. Observers are great for hooking into Model Events, especially when you do not have access to modifying the Model (e.g. if it is provided by a package) or if it is in a Domain that is upstream in the dependency chain. However, as I have shown above, Actions and Events can sometimes be a better implementation.

Where I do use Observers is for logic that is essential to the data integrity. If everything blows up if some calculation is not done for a Model, an Observer may be the best location for this logic. In general, I try to weigh data integrity very high for this consideration and business requirements very low. Business requirements are better in Actions, Events and Listeners than in Observers.

In this example, I am assuming that the API key is considered an add-on to the User, but nevertheless, it has to be set on the User before it is saved in the database. Otherwise the application blows up and starts notifying developers and system administrators at two in the morning...

A simple Observer would then be created in the Domain (it is about data integrity, not business use cases) and set in the AppServiceProvider to observe the User.

namespace Domain\Api\Observers;

use Domain\User\Models\User;
use Illuminate\Support\Str;

class UserObserver
{
    public function creating(User $user): void
    {
        $user->api_key = Str::random(24);
    }
}

If this logic had been in the same Domain as the Model, I would probably still have used Model Events, but not an Observer - instead I would have used a booted() method on the Model:

protected static function booted()
{
    static::creating(function (User $user) {
        $user->api_key = Str::random(24);
    });
}

This is to avoid setting up an Observer in the same Domain namespace as the Model.



In Conclusion

Laravel gives you complete freedom to structure your project as you please. I find that a Domain Driven approach helps you keep your code clean and SOLID although much of the work is - of course - still up to you as the developer.

Happy coding.

by Jan Keller

Long time developer, architect and CTO with a real love for the backend. On this blog, I give out insight gathered through working with Vue, Nuxt, Laravel and more, when building and maintaining our SaaS.

Catch me on Twitter and Github

Mutation Testing - the missing link

Mutation testing can give your tests that extra confidence level not achieved simply by measuring code test coverage.

Jan Keller Dec 15, 2021

TDD without tests first approach

TDD is synonymous with writing tests first and implementations later. I want to challenge that approach as a catch-all do-all approach.

Jan Keller Dec 15, 2021

Lessons learned migrating to Laravel Vapor

When moving to Laravel Vapor, there are things to consider. In this article, I name the obstacles I have experienced in a recent project.

Jan Keller Jun 4, 2021

Tree hierarchies in Laravel

Three approaches to parent-child tree structures with relational databases.

Jan Keller Jan 1, 2021