One of the top Laravel questions I hear is "How to structure the project". If we narrow it down, the largest part of it sounds like "If the logic shouldn't be in Controllers, then where should we put it?"
The problem is there is no single correct answer to such questions. Laravel gives you the flexibility to choose the structure yourself, which is both a blessing and a curse. You won't find any recommendations in the official Laravel docs, so let's try to discuss various options, based on one specific example.
Notice: as there's no one way to structure the project, this article will be full of side-notes, "what if" and similar paragraphs. I advise you don't skip them, and read the article in full, to be aware of all the exceptions to the best practices.
Imagine you have a Controller method for registering users that does a lot of things:
1public function store(Request $request) 2{ 3 // 1. Validation 4 $request->validate([ 5 'name' => ['required', 'string', 'max:255'], 6 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], 7 'password' => ['required', 'confirmed', Rules\Password::defaults()], 8 ]); 9 10 // 2. Create user11 $user = User::create([12 'name' => $request->name,13 'email' => $request->email,14 'password' => Hash::make($request->password),15 ]);16 17 // 3. Upload the avatar file and update the user18 if ($request->hasFile('avatar')) {19 $avatar = $request->file('avatar')->store('avatars');20 $user->update(['avatar' => $avatar]);21 }22 23 // 4. Login24 Auth::login($user);25 26 // 5. Generate a personal voucher27 $voucher = Voucher::create([28 'code' => Str::random(8),29 'discount_percent' => 10,30 'user_id' => $user->id31 ]);32 33 // 6. Send that voucher with a welcome email34 $user->notify(new NewUserWelcomeNotification($voucher->code));35 36 // 7. Notify administrators about the new user37 foreach (config('app.admin_emails') as $adminEmail) {38 Notification::route('mail', $adminEmail)39 ->notify(new NewUserAdminNotification($user));40 }41 42 return redirect()->route('dashboard');43}
Seven things, to be precise. You will all probably agree that it's too much for one controller method, we need to separate the logic and move the parts somewhere. But where exactly?
- Services?
- Jobs?
- Events/listeners?
- Action classes?
- Something else?
The trickiest part is that all of the above would be the correct answers. That's probably the main message you should take home from this article. I will emphasize it for you, in bold and caps.
YOU ARE FREE TO STRUCTURE YOUR PROJECT HOWEVER YOU WANT.
There, I said it. In other words, if you see some structure recommended somewhere, it doesn't mean that you have to jump and apply it everywhere. The choice is always yours. You need to choose the structure that would be comfortable for yourself and your future team to maintain the code later.
With that, I probably could even end the article right now. But you probably want some "meat", right? Ok, fine, let's play around with the code above.
General Refactoring Strategy
First, a "disclaimer", so it would be clear what we're doing here, and why. Our general goal is to make the Controller method shorter, so it wouldn't contain any logic.
Controller methods need to do three things:
- Accept the parameters from routes or other inputs
- Call some logic classes/methods, passing those parameters
- Return the result: view, redirect, JSON return, etc.
So, controllers are calling the methods, not implementing the logic inside the controller itself.
Also, keep in mind, that my suggested changes are only ONE way of doing it, there are dozens of other ways which would also work. I will just provide you with my suggestions, from personal experience.
1. Validation: Form Request classes
It's a personal preference, but I like to keep the validation rules separately, and Laravel has a great solution for it: Form Requests
So, we generate:
1php artisan make:request StoreUserRequest
We move our validation rules from the controller to that class. Also, we need to add the Password
class on top and change the authorize()
method to return true:
1use Illuminate\Validation\Rules\Password; 2 3class StoreUserRequest extends FormRequest 4{ 5 public function authorize() 6 { 7 return true; 8 } 9 10 public function rules()11 {12 return [13 'name' => ['required', 'string', 'max:255'],14 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],15 'password' => ['required', 'confirmed', Password::defaults()],16 ];17 }18}
Finally, in our Controller method, we replace Request $request
with StoreUserRequest $request
and remove the validation logic from the Controller:
1use App\Http\Requests\StoreUserRequest; 2 3class RegisteredUserController extends Controller 4{ 5 public function store(StoreUserRequest $request) 6 { 7 // No $request->validate needed here 8 9 // Create user10 $user = User::create([...]) // ...11 }12}
Ok, the first shortening of the controller is done. Let's move on.
2. Create User: Service Class
Next, we need to create a user and upload the avatar for them:
1// Create user 2$user = User::create([ 3 'name' => $request->name, 4 'email' => $request->email, 5 'password' => Hash::make($request->password), 6]); 7 8// Avatar upload and update user 9if ($request->hasFile('avatar')) {10 $avatar = $request->file('avatar')->store('avatars');11 $user->update(['avatar' => $avatar]);12}
If we follow the recommendations, that logic should not be in a Controller. Controllers shouldn't know anything about the DB structure of the user, or where to store the avatars. It just needs to call some class method that would take care of everything.
A pretty common place to put such logic is to create a separate PHP Class around one Model's operations. It is called a Service class, but that's just a "fancy" official name for a PHP class that "provides a service" for the Controller.
That's why there's no command like php artisan make:service
because it's just a PHP class, with whatever structure you want, so you can create it manually within your IDE, in whatever folder you want.
Typically, Services are created when there are more than one method around the same entity or model. So, by creating a UserService here, we assume there will be more methods here in the future, not just to create the user.
Also, Services typically have methods that return something (so, "provides the service"). In comparison, Actions or Jobs are called typically without expecting anything back.
In my case, I will create the app/Services/UserService.php
class, with one method, for now.
1namespace App\Services; 2 3use App\Models\User; 4use Illuminate\Http\Request; 5use Illuminate\Support\Facades\Hash; 6 7class UserService 8{ 9 public function createUser(Request $request): User10 {11 // Create user12 $user = User::create([13 'name' => $request->name,14 'email' => $request->email,15 'password' => Hash::make($request->password),16 ]);17 18 // Avatar upload and update user19 if ($request->hasFile('avatar')) {20 $avatar = $request->file('avatar')->store('avatars');21 $user->update(['avatar' => $avatar]);22 }23 24 return $user;25 }26}
Then, in the Controller, we can just type-hint this Service class as a parameter of the method, and call the method inside.
1use App\Services\UserService;2 3class RegisteredUserController extends Controller4{5 public function store(StoreUserRequest $request, UserService $userService)6 {7 $user = $userService->createUser($request);8 9 // Login and other operations...
Yes, we don't need to call new UserService()
anywhere. Laravel allows you to type-hint any class like this in the Controllers, you can read more about Method Injection here in the docs.
2.1. Service Class with Single Responsibility Principle
Now, the Controller is much shorter, but this simple copy-paste separation of code is a bit problematic.
The first problem is that the Service method should act like a "black box" that just accepts the parameters and doesn't know where those come from. So this method would be possible to be called from a Controller, from Artisan command, or a Job, in the future.
Another problem is that the Service method violates the Single Responsibility principle: it creates the user and uploads the file.
So, we need two more "layers": one for file upload, and one for the transformation from the $request
to the parameters for the function. And, as always, there are various ways to implement it.
In my case, I will create a second service method that will upload the file.
app/Services/UserService.php:
1class UserService 2{ 3 public function uploadAvatar(Request $request): ?string 4 { 5 return ($request->hasFile('avatar')) 6 ? $request->file('avatar')->store('avatars') 7 : NULL; 8 } 9 10 public function createUser(array $userData): User11 {12 return User::create([13 'name' => $userData['name'],14 'email' => $userData['email'],15 'password' => Hash::make($userData['password']),16 'avatar' => $userData['avatar']17 ]);18 }19}
RegisteredUserController.php:
1public function store(StoreUserRequest $request, UserService $userService)2{3 $avatar = $userService->uploadAvatar($request);4 $user = $userService->createUser($request->validated() + ['avatar' => $avatar]);5 6 // ...
Again, I will repeat: it's only one way of separating the things, you may do it differently.
But my logic is this:
- The method
createUser()
now doesn't know anything about the Request, and we may call it from any Artisan command or elsewhere - The avatar upload is separated from the user creation operation
You may think that the Service methods are too small to separate them, but this is a very simplified example: in real-life projects, the file upload method may be much more complex, as well as the User creation logic.
In this case, we moved away a bit from the sacred rule "make a controller shorter" and added the second line of code, but for the right reasons, in my opinion.
3. Maybe Action Instead of Service?
In recent years, the concept of Action classes got popular in the Laravel community. The logic is this: you have a separate class for just ONE action only. In our case, the action classes may be:
- CreateNewUser
- UpdateUserPassword
- UpdateUserProfile
- etc.
So, as you can see, the same multiple operations around users, just not in one UserService class, but rather divided into Action classes. It may make sense, looking from the Single Responsibility Principle point of view, but I do like to group methods into classes, instead of having a lot of separate classes. Again, that's a personal preference.
Now, let's take a look at how our code would look in the case of the Action class.
Again, there's no php artisan make:action
, you just create a PHP class. For example, I will create app/Actions/CreateNewUser.php
:
1namespace App\Actions; 2 3use App\Models\User; 4use Illuminate\Http\Request; 5use Illuminate\Support\Facades\Hash; 6 7class CreateNewUser 8{ 9 public function handle(Request $request)10 {11 $avatar = ($request->hasFile('avatar'))12 ? $request->file('avatar')->store('avatars')13 : NULL;14 15 return User::create([16 'name' => $request->name,17 'email' => $request->email,18 'password' => Hash::make($request->password),19 'avatar' => $avatar20 ]);21 }22}
You are free to choose the method name for the Action class, I like handle()
.
RegisteredUserController:
1public function store(StoreUserRequest $request, CreateNewUser $createNewUser)2{3 $user = $createNewUser->handle($request);4 5 // ...
In other words, we offload ALL the logic to the action class that then takes care of everything around both file upload and user creation. To be honest, I'm not even sure if it's the best example to illustrate the Action classes, as I'm personally not a big fan of them and haven't used them much. As another source of examples, you may take a look at the code of Laravel Fortify.
4. Voucher Creation: Same or Different Service?
Next, in the Controller method, we find three operations:
1Auth::login($user);2 3$voucher = Voucher::create([4 'code' => Str::random(8),5 'discount_percent' => 10,6 'user_id' => $user->id7]);8 9$user->notify(new NewUserWelcomeNotification($voucher->code));
The login operation will remain unchanged here in the controller, because it is already calling an external class Auth, similar to a Service, and we don't need to know what is happening under the hood there.
But with Voucher, in this case, the Controller contains the logic of how the voucher should be created and sent to the user with the welcome email.
First, we need to move the voucher creation to a separate class: I'm hesitating between creating a VoucherService
and putting it as a method within the same UserService
. That's almost a philosophical debate: what this method is related to the vouchers system, the users' system, or both?
Since one of the features of Services is to contain multiple methods, I decided to not create a "lonely" VoucherService with one method. We'll do it in the UserService:
1use App\Models\Voucher; 2use Illuminate\Support\Str; 3 4class UserService 5{ 6 // public function uploadAvatar() ... 7 // public function createUser() ... 8 9 public function createVoucherForUser(int $userId): string10 {11 $voucher = Voucher::create([12 'code' => Str::random(8),13 'discount_percent' => 10,14 'user_id' => $userId15 ]);16 17 return $voucher->code;18 }19}
Then, in the Controller, we call it like this:
1public function store(StoreUserRequest $request, UserService $userService)2{3 // ...4 5 Auth::login($user);6 7 $voucherCode = $userService->createVoucherForUser($user->id);8 $user->notify(new NewUserWelcomeNotification($voucherCode));
Something else to consider here: maybe we should move both of those lines into a separate method of UserService that would be responsible for the welcome email, which would in turn call the voucher method?
Something like this:
1class UserService2{3 public function sendWelcomeEmail(User $user)4 {5 $voucherCode = $this->createVoucherForUser($user->id);6 $user->notify(new NewUserWelcomeNotification($voucherCode));7 }
Then, Controller will have only one line of code for this:
1$userService->sendWelcomeEmail($user);
5. Notifying Admins: Queueable Jobs
Finally, we see this piece of code in the Controller:
1foreach (config('app.admin_emails') as $adminEmail) {2 Notification::route('mail', $adminEmail)3 ->notify(new NewUserAdminNotification($user));4}
It is sending potentially multiple emails, which may take time, so we need to put it into the queue, to run in the background. That's where we need Jobs.
Laravel Notification classes may be queueable, but for this example, let's imagine there may be something more complex than just sending a notification email. So let's create a Job for it.
In this case, Laravel provides the Artisan command for us:
1php artisan make:job NewUserNotifyAdminsJob
app/Jobs/NewUserNotifyAdminsJob.php:
1class NewUserNotifyAdminsJob implements ShouldQueue 2{ 3 use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; 4 5 private User $user; 6 7 public function __construct(User $user) 8 { 9 $this->user = $user;10 }11 12 public function handle()13 {14 foreach (config('app.admin_emails') as $adminEmail) {15 Notification::route('mail', $adminEmail)16 ->notify(new NewUserAdminNotification($this->user));17 }18 }19}
Then, in the Controller, we need to call that Job with the parameter:
1use App\Jobs\NewUserNotifyAdminsJob;2 3class RegisteredUserController extends Controller4{5 public function store(StoreUserRequest $request, UserService $userService)6 {7 // ...8 9 NewUserNotifyAdminsJob::dispatch($user);
So, now, we've moved all the logic from the Controller to elsewhere, and let's recap what we have:
1public function store(StoreUserRequest $request, UserService $userService) 2{ 3 $avatar = $userService->uploadAvatar($request); 4 $user = $userService->createUser($request->validated() + ['avatar' => $avatar]); 5 Auth::login($user); 6 $userService->sendWelcomeEmail($user); 7 NewUserNotifyAdminsJob::dispatch($user); 8 9 return redirect(RouteServiceProvider::HOME);10}
Shorter, separated into various files, and still readable, right? Again, will repeat once more, that it's only one way to accomplish this mission, you can decide to structure it in another way.
But that's not all. Let's also discuss the "passive" way.
6. Events/Listeners
Philosophically speaking, we can divide all the operations in this Controller method, into two types: active and passive.
- We're actively creating the user and logging them in
- And then something with that user may (or may not) happen in the background. So we're passively waiting for those other operations: sending a welcome email and notifying the admins.
So, as one way of separating the code, it should not be called in the Controller at all, but be fired automatically when some event happens.
You can use a combination of Events and Listeners for it:
1php artisan make:event NewUserRegistered2php artisan make:listener NewUserWelcomeEmailListener --event=NewUserRegistered3php artisan make:listener NewUserNotifyAdminsListener --event=NewUserRegistered
The event class should accept the User model, which is then passed to ANY listener of that event.
app/Events/NewUserRegistered.php
1use App\Models\User; 2 3class NewUserRegistered 4{ 5 use Dispatchable, InteractsWithSockets, SerializesModels; 6 7 public User $user; 8 9 public function __construct(User $user)10 {11 $this->user = $user;12 }13}
Then, the Event is dispatched from the Controller, like this:
1public function store(StoreUserRequest $request, UserService $userService) 2{ 3 $avatar = $userService->uploadAvatar($request); 4 $user = $userService->createUser($request->validated() + ['avatar' => $avatar]); 5 Auth::login($user); 6 7 NewUserRegistered::dispatch($user); 8 9 return redirect(RouteServiceProvider::HOME);10}
And, in the Listener classes, we repeat the same logic:
1use App\Events\NewUserRegistered; 2use App\Services\UserService; 3 4class NewUserWelcomeEmailListener 5{ 6 public function handle(NewUserRegistered $event, UserService $userService) 7 { 8 $userService->sendWelcomeEmail($event->user); 9 }10}
And, another one:
1use App\Events\NewUserRegistered; 2use App\Notifications\NewUserAdminNotification; 3use Illuminate\Support\Facades\Notification; 4 5class NewUserNotifyAdminsListener 6{ 7 public function handle(NewUserRegistered $event) 8 { 9 foreach (config('app.admin_emails') as $adminEmail) {10 Notification::route('mail', $adminEmail)11 ->notify(new NewUserAdminNotification($event->user));12 }13 }14}
What is the advantage of this approach, with events and listeners? They are used like "hooks" in the code, and anyone else in the future would be able to use that hook. In other words, you're saying to the future developers: "Hey, the user is registered, the event happened, and now if you want to add some other operation happening here, just create your listener for it".
7. Observers: "Silent" Events/Listeners
A very similar "passive" approach could be also implemented with a Model Observer, in this case.
1php artisan make:observer UserObserver --model=User
app/Observers/UserObserver.php:
1use App\Models\User; 2use App\Notifications\NewUserAdminNotification; 3use App\Services\UserService; 4use Illuminate\Support\Facades\Notification; 5 6class UserObserver 7{ 8 public function created(User $user, UserService $userService) 9 {10 $userService->sendWelcomeEmail($event->user);11 12 foreach (config('app.admin_emails') as $adminEmail) {13 Notification::route('mail', $adminEmail)14 ->notify(new NewUserAdminNotification($event->user));15 }16 }17}
In that case, you don't need to dispatch any events in the Controller, the Observer would be fired immediately after the Eloquent model is created.
Convenient, right?
But, in my personal opinion, this is a bit dangerous pattern. Not only the implementation logic is hidden from the Controller, but the mere existence of those operations is not clear. Imagine a new developer joining the team in a year, would they check all the possible observer methods when maintaining the User registration?
Of course, it's possible to figure it out, but still, it's not obvious. And our goal is to make the code more maintainable, so the fewer "surprises", the better. So, I'm not a big fan of Observers.
Conclusion
Looking at this article now, I realize I've only scratched the surface of possible separations of the code, on a very simple example.
In fact, in this simple example, it may seem that we made the application more complex, creating many more PHP classes instead of just one.
But, in this example, those separate code parts are short. In real life, they may be much more complex, and by separating them, we made them more manageable, so every part may be handled by a separate developer, for example.
In general, I will repeat for the last time: you are in charge of your application, and only you decide where you place the code. The goal is so that you or your teammates will understand it in the future, and will not have trouble adding new features and maintaining/fixing the existing ones.
0 comments:
Post a Comment
Thanks