Adopting MVC: How many layers? what responsibilities to inject in each layer?

Hi, I’ve recently been restructuring my mobile app-facing API to make it more RESTful. I was using Slim before, but I wasn’t using any Middleware. Now that I have a better grasp of how they work, I’m trying to deal with a set of global functions.

Say a have a “login” and “signup” routes, and they both call a global function that’s called grabUser() at some point during their execution. Before my redesign, if something went wrong inside grabUser(), I would just call exit(["status" => "error", "message" => "some message"]);, but now that I’m updating the API code I want to send a proper Response (change http status codes, add custom headers if needed, maybe an empty body or maybe with an error like my example above). The problem is that right now I can only see one way to do this, and that is to pass the $request and $response objects to every single one of those global functions, and make them return [$data, $request, $response] (with $data being null if error occurred inside the function). I can then call this function and unwrap it with a list:

list($data, $request, $response) = grabUser($request, $response, $other_args);
if (!isset($data)) return $response;

I feel like this is really dumb, but since I’m not super familiar with PHP, I wonder is there’s a better approach to dealing with “shared functionality” inside the main routes.

I wonder if Controllers is what I need, I’m just not sure I understand them.

Hello @merricat

You should add an layer for the business logic (Services) and a data access layer. Here is an example:

Hi @odan,
Thank you so much for pointing me in the right direction!

I’ve created a DataSource class (you call it ‘data access layer’), which holds a Database connection and functions corresponding to a single database query (I have thus extracted the business logic out of the ‘data access layer’). Each route creates the DataSource objects it needs, injecting the database connection (this connection gets created beforehand by a middleware, and gets passed to route through request->withAttribute($conn)).

The problem is that Routes are getting pretty big, and some of the ‘business logic’ that could be shared is now duplicated across different Routes (for instance, a login route with username and password, and a login through a third-party platform: they validate the user differently, but retrieve the same user/account data). So now my routes are getting beefier and with duplicate code.

Are you suggesting another middle-man (such as the ‘Controller’ class) would solve my issue? Would I put in it just the ‘shared business logic’ functions? Or would I put all the business logic into it – meaning, there would be a 1:1 relationship between routes and controller functions (i.e. api/account/login would just call accountController->loginWithUsernameAndPassword($username, $pw), and api/account/amazon/login would call accountController->loginWithAmazon($amazon_token)? If this is the case, then I’d need to also inject the request/response/args to the Controller…and I feel like Routes just lose their meaning if all they do is call a a controller’s function. Shouldn’t routes handle the business logic?

Hope you understand what I mean, thank you!!

EDIT: after doing some more reading, I found something interesting on this codereview thread. It seems valid to disregard a “business layer” if it adds no value. In my case, I can’t find a good reason to separate the Slim Routes from its business logic: all it does is manipulate data, handle errors, and create custom responses and headers, so why delegate it to a Controller? Why create another layer of complexity? The only positive I see is making my Routes look extremely tidy, but I’d still be bloating the Controller AND I’d have to inject $request, $response and $args, as well as the $app or $container itself in order to access my other containers (like a logger). I don’t know. I’m not convinced :confused:

Hi @merricat

If I get you right, your architecture is missing an Controller (from MVC) resp. an so called “Single Action Controller”. Please don’t put the business logic into the Controller (layer). Don’t mix the (MVC) Controller layer with the Model layer. Put the business logic (caluclation, complex logic) into service classes for better reusabilitity and testability.

The theory behind a Controller is simple:

The user interfaces with the view, which passes information to a controller. The controller then passes that information to a model (layer), and the model passes information back to the controller. The controller effectively stands between the view and the model. (Brandon James Savage)

Data flow: Client request > Router > Controller / Action class > Service class (business logic, transactions) > Repository (data access) > and back to the Response (optional via Responder and Transformer).

Why create another layer of complexity?

Separating the Controller from the Model is importand. Imagine you want to implement unit / integration tests. Then it would’t be a good idea to call the HTTP routes. Instead you could tests the a very specific service class and check the return value. Or imagine you want to execute an application specific task via the console (or an cronjob), then you have no HTTP request. There is no real “overhead” or so if you consider more then just a very specific use case. In the long run you will gain more flexibility, testability and better code quality.

@odan
Thanks for the response, although now I’m even more confused. I get your point about the Controller (for testing purposes). However, you now mentioned an additional layer (Service Layer). I’m so lost. Do you consider this Service Layer as part of the MVC’s Model layer?

In your case, who handles the creation of the Response? Is it the Router? Or the Controller? Or the Service? (definitely not the Data). Can you tell me if the following example is what you mean?

Router: index.php

$app->put('/api/account/login', function ($request, $response, $args){
   $database_connection = $request->getAttribute('database_connection');
   $controller = new AccountController($request, $response, $database_connection);
   $response = $controller->loginWithEmail();
   return $response;
});

Controller: controllers.php

abstract class Controller {
    protected $request;
    protected $response;
    protected $database_connection;

    public function __construct($request, $response, $database_connection) {
        $this->request = $request;
        $this->response = $response;
        $this->database_connection = $database_connection; //or should I create a new instance of instead of injecting it from the route?
    }
}

class AccountController extends Controller {
    public function loginWithEmail () {
        $service = new AccountService($this->request, $this->response, $this->database_connection);
        $this->response = $service->loginWithEmail();
        return $this->response;
    }
}

Service: services.php

abstract class Service {
    protected $request;
    protected $response;
    protected $database_connection;

    public function __construct($request, $response, $database_connection) {
        $this->request = $request;
        $this->response = $response;
        $this->database_connection = $database_connection; //or should I create a new instance of instead of injecting it from the route?
    }
}

class AccountService extends Service {
    public function __construct($request, $response, $database_connection) {
        parent::__construct($request, $response, $database_connection);

        //validate each argument from request->getParsedBody() else throw error
        // ...             
    }

    public function loginWithEmail () {
        $email = $this->request->getParsedBody()['email_address'];
        $hash = $this->request->getParsedBody()['hashed_password'];

        $data_source = new AccountDataSource($this->database_connection);
        try { $user_data = $data_source->getUserByEmailAndHash($email, $hash); } catch($e) { throw $e };
        $user_data = this->commonLoginStuff($user_id, $user_data);             

        $this->response = $this->response->withJson($user_data)->withStatus(200);
        return $this->response;
    }

    public function loginWithAmazon () {
        //some special amazon stuff
        // ...

        $user_data = this->commonLoginStuff($user_id, $user_data);             

        $this->response = $this->response->withJson($user_data)->withStatus(200);
        return $this->response;             
    }

    //this is what started the entire thread: this is shared functionality, for instance called during loginWithEmail() and with loginWithAmazon()
    private function commonLoginStuff ($user_id, $user_data) {
        if (!isset($user_data)) throw new CustomException('no account');
        try { $data_source->updateUserLogin($user_data['id']); } catch($e) { throw $e };
        try { $auth_token = $data_source->createNewAuthToken($user_data['id']); } catch($e) { throw $e };

        $notes_data_source = new NotesDataSource($this->database_connection);
        try { $user_data['notes'] = notes_data_source->getUserNotes($user_data['id']); } catch($e) { throw $e };
        
        //fetch all sorts of other things on first login
        // ...

        return $user_data;
    }
}

Data: datasources.php

abstract class DataSource {
    protected $database_connection;

    public function __construct($request, $response, $database_connection) {
        $this->database_connection = $database_connection;
    }
}

class AccountDataSource extends DataSource {
    public function getUserByEmailAndHash($email, $hash) {
        // call SQL functions on database, retrieve rows, etc.
    }

    public function updateUserLogin($user_id) {
        // call SQL functions on database, retrieve rows, etc.
    }

    public function createNewAuthToken($user_id) {
        // call SQL functions on database, retrieve rows, etc.
    }

    public function getUserByAmazon($amazon_client_token) {
        // call SQL functions on database, retrieve rows, etc.
    }
}

class NotesDataSource extends DataSource {
    public function getUserNotes($user_id) {
        // call SQL functions on database, retrieve rows, etc.
    }
 }

Is this more or less what you’re talking about? Now I feel like both the Router and the Controller aren’t doing anything but adding calls onto the stack, injecting $requests, $responses, and databse connections along the way.

No, this is not I was talking about.

In MVC there are 3 layers: Controller, Model and View.

The most people confuse layers with classes. In the past many people created Controlle r class and a Model class. This was not correct in any case. A layer can be have mutliple sub-layers, for example the model layer.

The controller can be implemented in multiple ways:

  1. As callback (Slims default, simple but not flexible)
  2. As classic Controller class (controller class with multiple action methods)
  3. As Single Action Controller (controller class with only one action method: __invoke)

In your example you are using option 1 AND option 2. But this makes no sense. You should decide beetween one of these options, but don’t use multiple options at the same time. I would recommend using Single Action Controller: Examples.

The model (M) is responsible for Data and calculation. It’s the “core” (or the Domain) of the application. Within the Model layer you can have multiple “sub-layers”:

  • Service classes (responsible for business logic, validation, calculation, logging, file creation, transaction etc…) Example
  • Repositories (This is mostly a collection of complex SQL queries. The source of any data your application may be in the need for. No ORM required) Example

The model layer is completely independent from the controller. This means, you have no request or response object within the model layer. You also have no database connection within a controller. The controller extracts the data from the request object and passes that data as parameter to the Service (model layer). The controller never passes the request or response object to the Service (model layer).

I have written a blog Post about this topic:

You can also find an full example, implemented in Slim 3: Prisma

Also some nice articles about MVC:

1 Like

@odan thank you so much, I feel like this makes a lot more sense now. I was having a hard time understanding where to draw the line between each layer. What clicked for me is realizing that the Service layer is part of the Model. :slight_smile:

1 Like