orest's avatar
Level 13

Form request vs Service

I'm trying to figure out what is an elegant way to organize the following workflow

  1. Submit form ( the form contains the title of the conversation, the body of the message and the name of the invited participant.
  2. Create a conversation
  3. associate the message with the conversation
  4. associate the participant with the conversation
  5. notify the participant that a new conversation is created

My current approach is the following:

  1. I validate all the input values in the form request and then i call persist() to create the conversation.
  2. Then i associate the participants with the converastion
  3. I associate the message with the conversation
  4. finally i fire an even that the conversation was created

ConversationController

 public function store(Request $request, CreateConversationRequest $conversationRequest)
    {
        $conversation = $conversationRequest->persist();
        $conversation->addParticipants(
            $request->input('participants'),
            $request->boolean('admin')
        );
        $conversation->addMessage(
            $request->input('message')
        );
        
        event(new NewConversationWasCreated($conversation));
   }
public function addMessage($message, $user = null)
    {
        $message = $this->messages()
            ->create([
                'body' => $message,
                'user_id' => $user ? $user->id : auth()->id(),
            ]);

        event(new NewMessageWasAddedToConversation($this, $message));

        return $message;
    }
public function addParticipants(array $usernames, $admin = false)
    {
        $participantIds = $this->getParticipantIds($usernames);

        foreach ($participantIds->toArray() as $participantId) {
            $participants[$participantId] = ['admin' => $admin];
        }

        $this->participants()->syncWithoutDetaching($participants);

        event(new NewParticipantsWereAdded($this, $participantIds));
    }

The Issue is

I think that the ConversationController as well as the Conversation model have too much logic.

  1. First Alternative

Move the logic from the controller in the persist method of the Form request ( For some reason it doesn't look like a good idea to put everything in the form request)

  1. Second Alternative

Create a CreateConverastionService

I was thinking that if i create a service to encapsulate all the steps for creating a conversation Then probably i would have to get rid of the form request which means that i should handle the validation of the inputs within the service.

class ConversationCointroller extends Controller
{
     public function store(Request $request)
     {
            (new CreateConversationService($request))->handle();
      }
{
class CreateConversationService
{
     protected $request;

     public function __construct(Request $request)
     { 
             $this->request = request;
      }
    
     public function handle()
     {
             $this->validate();
             $conversation = Conversation::create(
               [
                'user_id' => auth()->id()
                'title' => $this->request->input('title')
               ]
             $conversation->addParticipants(
             $this->request->input('participants'),
             $this->request->boolean('admin')
             );
            $conversation->addMessage(
                  $this->request->input('message')
              );
        
        event(new NewConversationWasCreated($conversation));
             
     }

{

With regards to the methods addParticipants and addMessage, maybe i could keep them but instead of implementing the logic inside the methods, i could call another service to handle all the logic for ***adding a participant and adding a message .

public function addParticipants(array $usernames, $admin = false)
{
    (new (AddParticipantToConversationService($this, $usernames, $admin))->handle();
}

public function addMessage($message, $user = null)
    {
        (new(AddMessageToConversationService($this, $message, $user))->handle();
    }

Which one do you think is the better approach ?

Any other suggestion is welcome of course.

0 likes
13 replies
martinbean's avatar
Level 80

@orest You could follow Laravel’s lead lately and wrap your “create conversation” logic into an “action” class. It’s essentially a class with a single method that does one thing and one thing only (think of it like a synchronous job or command).

You can type-hint this action class in your controller and then call its public method once you’ve validated your data:

class ConversationController extends Controller
{
    public function store(CreateConversationRequest $request, CreateConversationAction $action)
    {
        $conversation = $action->handle($request->validated());

        // Return response or redirect
    }
}

Your action class then has all the logic for creating models, attaching related models, and dispatching events:

use Illuminate\Support\Arr;

class CreateConversationAction
{
    public function handle(array $data)
    {
        $conversation = Conversation::create(Arr::except($data, [
            'participants',
            'admin',
            'message',
        ]));

        $conversation->addParticipants($data['participants'], $data['admin']);

        $conversation->addMessage($data['message']);

        NewConversationWasCreated::dispatch($conversation);

        return $conversation;
    }
}
2 likes
orest's avatar
Level 13
  1. Do Actions have a different purpose compared to Services and Jobs, because especially if a Job is synchronous then all the above seem like the same thing to me.

  2. What is your opinion about the logic within the addParticipants and addMessage methods. Is it ok to have the logic for creating and firing an event in those methods or it would be better to call an Action again ?

public function addParticipants(array $usernames, $admin = false)
{
    (new (AddParticipantToConversationAction($this, $usernames, $admin))->handle();
}

public function addMessage($message, $user = null)
    {
        (new(AddMessageToConversationAction($this, $message, $user))->handle();
    }

One downside with this approach though is that i might end up with too many Action classes

martinbean's avatar

@orest I have a love–hate relationship with commands/sync jobs/actions because, as you’ve rightly pointed out, they’re all the same thing in practice. Laravel introduced commands and handlers in Laravel 5.0, and by 5.1 they were renamed to “self-handling” jobs.

It’s up to you have far you abstract your actions logic. If you need to add participants and messages independently of creating a conversation, then it may make sense to have AddParticipantToConversation and AddMessageToConversation actions. You can then just type-hint them in the CreateConversation action class to re-use them:

class CreateConversation
{
    protected $addParticipants;
    protected $addMessage;

    public function __construct(
        AddParticipantsToConversation $addParticipants,
        AddMessageToConversation $addMessage
    ) {
        $this->addParticipants = $addParticipants;
        $this->addMessage = $addMessage;
    }

    public function handle(array $data)
    {
        $conversation = Conversation::create(Arr::except($data, [
            'participants',
            'admin',
            'message',
        ]));

        $this->addParticipants->handle(
            $conversation,
            $data['participants'],
            $data['admin']
        );

        $this->addMessage->handle($conversation, $data['message']);

        NewConversationWasCreated::dispatch($conversation);

        return $conversation;
    }
}

Again, the container will resolve the dependencies in the action’s constructor, if the container is resolving the action itself (i.e. by type-hinting it in a controller method).

1 like
orest's avatar
Level 13

@martinbean thanks for the answer.

I have another confusion regarding the event listeners. I know it is not related to the initial question, but it is a good example for me to get a better understanding.

My confusion is when to use events and event listeners.

For example, in the example above

  • I could just create a conversation and then fire an event ConversationWasCreated
  • And I could have one listener to add a message to the conversation
  • one listener to add a participant to the conversation
  • one listener to subscribe the participant to the conversation
  • and one listener to notify the participant that he is part of a new conversation

On the other hand I could do all the above steps without using any events. So for example in the CreateConverastionAction I could have

class CreateConversationAction
{
      public function handle()
       {
             $title = request('title');
             $message = request('message');
             $participant = request('participant');
             
             $conversation = Conversation::create($title);
             $conversation->addMessage($message);
             $participant = $conversation->addParticipant($participant);
             $conversation->subscribe($participant);
             $participant->notify(new YouHaveNewMessage());
       }
}

In practice it can be done in both ways but I want to understand which way makes more sense.

So for example, in the CreateConverastionAction should I include only the steps that are crucial, which are

  1. Conversation::create();
  2. $conversation->addMessage();
  3. $conversation->addParticipant();

and use event listeners for the other steps, which are important but not crucial when creating a conversation

  1. $conversation->subscribe($participant)
  2. $participant->notify(new YouHaveNewMessage());

Is this a good way to think of it ?

martinbean's avatar

@orest Yeah, exactly :)

I’d have the action create the required models based off of validated data. You can then optionally dispatch an event that can do any supplemental work such as notifying participants that don’t need to be done as part of the request itself.

1 like
orest's avatar
Level 13

@michaloravec

Thanks for the suggestion.

I have watched this series, that's where i got the method name persist

Form requests are an option of course but i think ( i am not sure ) that then it will have too many responsibilities.

  1. Authorize
  2. prepare for validation and Validate
  3. create conversation , add message, add participants
  4. fire event that conversation was created

Is it me ? or all the steps above are too many for a form request object ?

martinbean's avatar

Yeah, the form request should validate the data, but then the controller should actually process the request once you’ve deemed it valid.

1 like
orest's avatar
Level 13

@martinbean You don't agree in in general with the following approach ?

class ConversationController extends Controller
{
      public function store(CreateConversationRequest $request)
      {
                 $request->persist();
       }
}

To me it looks the same with using a an action class with the difference that you don't pass the validated data as a parameter.

class ConversationController extends Controller
{
      public function store(Request $request, CreateConversationAction $action)
       {
                 $action->handle($request->validated());
       }

}

My concern about the form request was more about the fact that it will have to create not only the conversation, but also add a message and a participant.

martinbean's avatar

Not really, because the logic’s not usable outside of that request class now.

It’s also intrinsically linked to a HTTP request, which makes testing the logic of creating a conversation more difficult.

orest's avatar
Level 13

Ok..i get your point, except for the testing part

How does it make it more difficult ?

In a feature test for example, you just want to test that when you hit that endpoint, there will be a conversation record in the database.

MichalOravec's avatar

You handle a form data, so conversation, add message and add participants can stay together in form object. Only event I would move to the controller.

martinbean's avatar

@orest Because testing the logic of creating a conversation shouldn’t come with the overhead of creating a valid HTTP request first.

It also doesn’t change the fact that you can’t use that logic outside the request either, i.e. an Artisan command or queued job.

Please or to participate in this conversation.