Search code examples
phplaraveldrylumenservice-layer

How to using DRY and service layer in Lumen?


I'm creating api in Lumen framework and recently i read about DRY and service layer. To this day i didn't use any of these in my code and all logic was in the controllers. So i would like to start using it, but i have some problems with it.

This is part of my controller (UsersController.php), because whole code is too long.

<?php

namespace App\Http\Controllers;

use App\User;
use Illuminate\Http\Request;

class UsersController extends Controller
{
    private $request;

    public function __construct(Request $request) {
        $this->request = $request;
    }

    public function destroy($id) {
        $user = User::find($id);

        if (!$user) {
            return response()->json([
                'error' => 'User not found'
            ], 404);
        }

        if ($user->role === 'admin') {
            return response()->json([
                'error' => 'You cant edit admin'
            ], 403);
        }

        $user->delete();

        return response()->json([], 204);
    }
}

After looking at this code i tried to change 2 things.

  1. Get user and return errors can be done in UserService.php (i have this code in other methods too, so thats why i think thats good idea for this method in service). But as you see i want to return response when there are errors and when i doing it, my code as intented tried to using delete method on json response, not on user model. Throwing exception in my opinion is not good, because is not compatible with DRY principle. Any idea how to fix it?

UserService.php

<?php
namespace App\Services;

use App\User;

class UserService
{
    public function getUserById($id)
    {
        $user = User::find($id);

        if (!$user) {
            return response()->json([
                'error' => 'User not found'
            ], 404);
        }

        if ($user->role === 'admin') {
            return response()->json([
                'error' => 'You cant edit admin'
            ], 403);
        }

        return $user;
    }
}

Modified UsersController.php/destroy

public function destroy($id) {
    $user = $this->userService->getUserById($id);
    $user->delete(); // not working because sometimes it can return json response

    return response()->json([], 204);
}
  1. I'm using so much json response in controllers, middlewares etc. and i want to unify this by creating new class, but i dont know how to properly use it. I mean returning json response in ResponderService.php probably wont stop execution in other places like controller. Or maybe i should create this as helper?

ResponderService.php

<?php
namespace App\Services;

class ResponderService
{
    private function base($data, $status_code)
    {
        $data['status_code'] = $status_code;

        return response()->json($data, $status_code);
    }

    public function error($message, $status_code)
    {
        $data['error'] = $message;
        $data['status'] = 'error';
        $this->base($data, $status_code);
    }
}

I read about Repositories too, but i dont think this pattern would be good in my project. If you have other suggestions that could be improved in controller code, I'm open to them.


Solution

  • I don't see any issue with using Exceptions for your scenario.

    <?php
    namespace App\Services;
    
    use App\User;
    
    class UserService
    {
        public function getUserById($id)
        {
            $user = User::find($id);
    
            if (!$user) {
                throw UserNotFoundException('User not found');
            }
    
            if ($user->role === 'admin') {
                throw EditAdminException("You can't edit admin.");
            }
    
            return $user;
        }
    }
    

    Where those exceptions are your own custom exceptions defined in app\Exception if you want. Then the getUserById() method can only return a User otherwise an exception bubbles up and returns a JSON response to the client.

    Laravel also already has a simple way to handle the first exception. You could do this:

    <?php
    namespace App\Services;
    
    use App\User;
    
    class UserService
    {
        public function getUserById($id)
        {
            $user = User::findOrFail($id);
    
            if ($user->role === 'admin') {
                throw EditAdminException("You can't edit admin.");
            }
    
            return $user;
        }
    }
    

    And Laravel will handle throwing a Illuminate\Database\Eloquent\ModelNotFoundException if a User can't be found.

    This way you don't need to worry about creating a ResponderService for what Exceptions can already do for you.

    If you want to standardize on the responses of your resources you could leverage Eloquent Resources which work as a transformation layer for your API: https://laravel.com/docs/5.7/eloquent-resources

    Lastly, if you find you're deleting a resource from more than one place and don't want to duplicate the response, you could put the response inside of an Event: https://laravel.com/docs/5.7/eloquent#events

    The documentation shows the convoluted way of handling events, but I personally would only do it once your model starts to feel bloated.

    You can do this as a simpler alternative to creating an Event and Observer class:

    public static function boot()
    {
        parent::boot();
    
        static::deleted(function ($model) {
            return response()->json([], 204);
        });
    }
    

    That method just goes on your User model. And the way I found that by the way is by looking inside the HasEvents trait on the Eloquent\Model.

    Now, all of that said, I would actually put all of the deletion logic inside your UserService and rename the method from getUserById to deleteById. The alternative is a little weird because you're saying you don't want to be able to get a user by id if it's an admin.

    Really what you're trying to do is encapsulate the logic of deleting a user, so just move it all into the method on the service, or better yet just use the delete event on the model and put all of the logic there. That way you don't even have to introduce a service yet.

    Edit

    Based on your comment below I think you may be misunderstanding how to user Exceptions in Laravel.

    In a fresh Laravel project there is a class in app\Exeptions\Handler which catches all unhandled exceptions in your app. That class first checks if the Exception is a ModelNotFoundException and then returns a json response.

    Else, it passes the caught Exception to the render method of it's parent.

    So basically when you want to create a custom exception you simply make a class that extends Exception and implementes a handle method.

    Here's an example Exception class:

    <?php
    
    namespace App\Exceptions;
    
    use Exception;
    
    class TicketNotPayableException extends Exception
    {
        /**
         * Render an exception into an HTTP response.
         *
         * @param  \Illuminate\Http\Request  $request
         * @param  \Exception  $exception
         * @return \Illuminate\Http\Response
         */
        public function render()
        {
            return response()->json([
                'errors' => [
                    [
                        'title' => 'Ticket Not Payable Exception',
                        'description' =>
                            'This ticket has already been paid.'
                    ],
                ],
                'status' => '409'
            ], 409);
        }
    }
    

    Now the response is totally reusable and I don't need a bunch of try-catch blocks in my code. Laravel's Exception Handler will catch it, and call the render method.

    So, if I wanted to encapsulate the logic of paying for a ticket inside of a service I just have to throw App\Exceptions\TicketNotPayableException; and then my controller only needs to do something like: $ticketPaymentService->pay($ticket); and there's no need for a try-catch. If the exception gets thrown, it'll bubble up, get caught by the handler, and the render method will be called, which will return the appropriate JSON response - no need for a Responder.