Limitations
MVC is a very broad topic with much different understanding so that by the term alone this is hard to answer properly - even in context of a PHP application. You normally refer to an existing implementation of MVC which is not the case here as you want to do it your own (Hint: Read code of existing implementations that is available and about you want to learn more).
Discussion
With that being said, you can find some practical "first next steps" suggestions at the end of the answer.
But I read your question as well that you're concerned about the HTML templates and perhaps also what this has to do with how you wrote your example. So I start a non-binding discussion about the View and then go over to Route and Controller. The Model layer I've kept out of the discussion mainly, at least for that you have to face third-party libraries as otherwise your application structure would not be a good host for broad functionality, this is touched by autoloading.
I have no authority in MVC, I just used some of the early implementations in PHP and applications influenced by them but never implemented it fully. So don't read out any suggestion from the discussion regarding it, it is merely about your example and what came to my mind in specific to PHP. At the end of the day it is you who will find the answer to your own programming questions.
Let's go.
A suggestion/assumption first: You certainly don't want to implement the view creation with the Controller class but with a View class. It would not change much just that the controller does not "care" about it (MVC = Model View Controller).
You can refactor (change) your code by introducing a View class and move the Controller::createView()
to View::create()
(compare: extract/move method).
Then using require_once
- while it may work - it would only work if the template file is only used once. This is certainly not what you want to express here (and later in the discussion we'll see that with the existing example this can also more easily happen than perhaps intentionally thought), instead use require
(or include
depending on how you want to handle errors) as they will always execute the code in the file (for potential problems redefining controllers, see later in the discussion first routing and then second autoloading).
Apart from obvious code errors (typos) you'd need to address to get it to run (which is a good opportunity to explore PHP error handling and monitoring for your application) you still need to pass the output data of the controller to the view.
This can be so called view models or just objects (in the broader sense) holding the data to be viewed (rendered by the view). Just require
/include
-ing the (HTML layout) template files won't suffice as they may contain the HTML structure but not the controllers' output data. On the level of the templates this is typically in variables, e.g. the title of the hypertext document:
<title>
<?= htmlspecialchars($title, ENT_QUOTES | ENT_HTML5) ?>
</title>
If this would be the body of a function, the function definition would be:
function outputHeader(string $title): void {
# ...
}
As we don't have a function by requiring the template files, this is just exemplary. However we could create a generic function that handles requiring a template file and passing the variables to the template (compare include_helper()
). In that layer you can also do some ground level error handling (try {} catch (Throwable $throwable) {}
etc.). For starters you could collect and group such code in the View class.
What you also likely want to prevent is to bind the view within the controllers' constructor method (Controller::__construct()
, ctor in short). It forces you to have a named view - and always the same - makes the controller dependent on that view.
That would mean you couldn't configure any view to any controller. While it wouldn't make sense in most cases to allow an any-to-any relationship here in the concrete practice, it allows you to actually have layer boundaries and to not couple things too tightly (compare: Spaghetti Code 1) and to write code on a higher level (in grade of abstraction, compare Layer of Indirection).
An example in a HTTP application would be to do content negotiation. This would happen on the level of request processing (more in the Router in your example), e.g. a HTTP client requests JSON instead of HTML. Now the HTML templates wouldn't fit here. But the Controller could still do the work if not the view template would be hard-encoded.
To keep things more flexible (so you can use it to a greater extend), one benefit of the MVC model is to use (and to a certain degree somehow pass the result of) the Model by the Controller to the View. It helps you define clear boundaries between those three and keep them more apart from each other (less coupled).
The routing then could negotiate and decide what to bring together, similar as in your example for the Controller already but extended with the View (template), each route could be assigned a layout/template.
As this would work quite the same as with the controller - just for the view - let's see where the current Controller not only is standing in the way for the view but already for the routing (if you find a flaw or bug, look around, often they are not in a single place and alone).
While you already configure the routes in the router, the actual routing you've put in the Controller base-class (Controller::get($route, $controller)
). Similar to the __construct()
method, this makes the Controller implementation dependent on the Route and even implements the routing. This is pretty convoluted and will certainly become awkward. There is also the problem when you add more routes you loose control which one matches as the matching is done within each Controller etc. . In short, while the code may be functional, it just seems to me it can benefit to be at a different place. As it's about the routing, first place that comes into my mind would be the Router itself. The Router then could do the actual work, "do the routing":
$router = new Router(); # <-- bootstrap
$router->get('/', 'home.contr.php'); # <-- prepare
$router->get('/home', 'home.contr.php'); # <-- prepare
$router->get('/about', 'about.contr.php'); # <-- prepare
$router->get('/portfolio', 'projects.contr.php'); # <-- prepare
$router->route(); # <-- do the work here
The Routers get()
method then could stay the same from the outside but you would just store the routes inside and when you invoke the route()
method, that configuration is matched against your request implementation.
You could then extend the router configuration with the view name.
It would be then that you still have bound a route to a controller and a view, however you have a central location where this is done (configured/parameterized). Controller and View are more independent to each other and you can concentrate more with their own implementation than the overall wiring which now moved into the router.
Finally while being here, what your example also shows is its dependence on the file-system, you have a certain file-naming convention for the controllers and also the view templates. While it is implicitly necessary to place the code into files, at least in your example on the level of the controllers you can already rely on PHP autoloading. While you want to write everything yourself (e.g. not using a ready-made MVC library), I'd still suggest to make use of some standards, like Autoloader (PSR-4) and as being inherently lazy, make the app a Composer project (it has a composer.json
file) as Composer allows you to configure the autoloader and there is a well-defined process developing with it (you can also bring in more easily third-party libraries which you'll certainly need within your application logic, so this is just forward-thinking in a good sense, just start without any requirements just using the Composer autoloader).
So instead of hard-linking controller PHP file-paths, you could say instead that a controller basically is a class definition with at least a single method that the router is able to call. With the autoloader in action, the routing configuration would only need to reference that class/method and PHP then would take care to load the class. This could be done as strings (lazy-loading) or more explicit with the First class callable syntax (PHP 8.1). A good middle-ground for starters perhaps is to have one Controller per class and require to have it an interface so that you have a contract (compare: programming against interfaces 1, 2, 3, 4, 5, 6, 7 etc.). You can then simply pass the class-name and handle the instantiation in the route()
method.
$router->get(
/* route */ '/',
/* $controller */ MyApp\MVC\Crontroller\Home::class,
/* $viewName */ 'home'
);
<?php
namespace MyApp\MVC\Controller;
class Home implements Interface {
# ...
}
<?php
namespace MyApp\MVC\Controller;
interface Interface {
public function invoke(InputParameter $params): InvocationResult
}
The route()
then could check for the interface to verify some class can be used as a controller (instanceof
) and would know how to invoke()
the controller by passing the input parameters to receive the result that can be further delegated to the template layer.
This is made possible by also introducing the InputParameter and InvocationResult implementations (classes/interfaces) that help to define the layer boundary of the Controller part.
You can then do something similar for the View layer however the output comes relatively late and you're perhaps not yet settled with it (and you may have different template "engines" depending on use-case) so I would leave it more thin and less engineered and try with the Controllers first and do the delegation in the routing until you learn more about your actual requirements (Session handling, Authentication, Content-Negotiation, Redirects etc.).
At the end of the day you have to make your own decisions here.
Next Steps Suggestions
- Add at least one test-script that you can run from your development environment "with a single key-press / click" and simple OK/Fail result (e.g. a simple PHP script that you execute in the shell)
- Think about how to improve the error handling so you learn about defects faster (e.g. introduce exception and
- Fix the bugs first, your code should actually run first of all (it might not produce the intended results in full but it should at least run - your example does not)
- Init Composer / add
composer.json
to your project
- Then change the code to your liking which can benefit having it under test first (compare Unit Tests)