Holkerveen Logo

Creating your controllers | Build Your Own PHP Framework

Now that we have created a basic router, what should we route to? In the previous lesson we defined some basic functions to serve as placeholders. Now, lets create some controllers and modify the router to call these for us!

In this episode we will create a Dashboard and Comment controller. Yes - we're building a comment system now. We will also use the relatively new PHP Attributes to define which url the controller will map to.


Creating our controller classes

Now that we have a basic router working, let's create some proper controllers! In traditional MVC architecture, controllers are responsible for handling requests, coordinating with models, and preparing data for views.

We'll start by creating two controllers: one for our dashboard and one for managing comments. Let's organize these in a Controllers directory within our src folder.

First, create our DashboardController:

<?php // src/Controllers/DashboardController.php
namespace Framework\Controllers;
class DashboardController
{
public function index(): string
{
return "Welcome to the Dashboard!";
}
}

And now our CommentController:

<?php // src/Controllers/CommentController.php
namespace Framework\Controllers;
class CommentController
{
public function index(): string
{
return "All comments will be listed here!";
}
}

Notice that each controller has methods that return strings. These methods are called actions - they handle specific requests and return responses. For now we're returning simple strings, but later on we'll have these render proper HTML views.

Modify the router

Now let's modify our router to use these new controllers instead of the inline functions we defined earlier. Update the Router class:

<?php // src/Router.php
namespace Framework;
use Framework\Controllers\DashboardController;
use Framework\Controllers\CommentController;
class Router
{
public function getController(string $path): callable
{
return match ($path) {
'/' => fn() => (new DashboardController())->index(),
'/comments' => fn() => (new CommentController())->index(),
};
}
}

We are importing our controller classes at the top. When a route is called, we instantiate the controller we need and call the proper method. The callables we used for testing have been removed.

If you restart your server and navigate to http://localhost:8000, you should see the dashboard message. Navigate to http://localhost:8000/comments to see the comments page!

This works, but we can already see a problem: every time we add a new controller or route, we need to manually update the router. This doesn't scale well. Let's solve this using PHP Attributes!

About attributes

PHP Attributes were introduced in PHP 8.0 as a way to add structured metadata to classes, methods, properties, and more. If you're familiar with Java annotations or Python decorators, attributes serve a similar purpose.

One way to not have to edit the router, is defining routes in a separate file and adding entries there. That does provide some separation between code and config, but you would still need to update your routes file every time.

Therefore, before attributes, developers often used docblock comments to add metadata:

/**
* @Route("/dashboard")
*/
public function index() { }

This did solve the problem as we could then define the route in the same file as our controller, keeping related stuff close together. The problem with docblock annotations is that they're just strings in comments - PHP doesn't parse them natively, so you need third-party libraries to extract and process them.

Attributes, on the other hand, are native PHP syntax:

#[Route("/dashboard")]
public function index() { }

These annotations can be read using PHP's Reflection API. They also benefit from the type checking features, IDE autocompletions, and refactoring tools that are available for PHP.

We'll be using attributes to define which URL each controller action should respond to. This way, our routing information lives right next to the code that handles each route, making it much easier to maintain!

Creating our Route attribute

Let's create our own Route attribute. In PHP, attributes are simply classes marked with the #[Attribute] attribute. Yes, using attributes to mark a class as an attribute. Don't you just love how met that is?

<?php // src/Attributes/Route.php
namespace Framework\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
readonly class Route
{
public function __construct(
public string $path
) {
}
}

#[Attribute(Attribute::TARGET_METHOD)] tells PHP that our Route attribute can only be used on methods. The constructor parameters define what data can be passed to the attribute. In our example, just the path.

Now let's update our controllers to use this attribute:

// src/Controllers/DashboardController.php
// ...
use Framework\Attributes\Route;
class DashboardController {
#[Route('/')]
public function index(): string
{
// ...
// src/Controllers/CommentController.php
// ...
use Framework\Attributes\Route;
class CommentController
{
#[Route('/comments')]
public function index(): string
{
// ...

Now our routes are defined right alongside the code that handles them. Much better! But how do we make the router actually use these attributes?

Modify the router once more

Now comes the interesting part - we need to scan our controllers for Route attributes and build our routing table automatically. This is called route discovery or auto-registration. Let's rewrite our Router class to scan for Route attributes:

<?php // src/Router.php
namespace Framework;
use Framework\Attributes\Route;
use ReflectionClass;
use ReflectionMethod;
class Router
{
private array $routes = [];
public function __construct()
{
$controllerFiles = glob(__DIR__ . '/Controllers/*.php');
foreach ($controllerFiles as $file) {
$className = 'Framework\\Controllers\\' . basename($file, '.php');
foreach (new ReflectionClass($className)->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
$attributes = $method->getAttributes(Route::class);
foreach ($attributes as $attribute) {
$route = $attribute->newInstance();
$this->routes[$route->path] = [
'controller' => $className,
'method' => $method->getName(),
];
}
}
}
}
public function getController(string $path): Closure
{
$route = $this->routes[$path];
return Closure::fromCallable([new $route['controller'](), $route['method']]);
}
}

Let's walk through what this does:

  1. When the router is created, it scans the src/Framework/Controllers directory
  2. For each class found, it iterates over the available (public) methods
  3. Get the path from the Route attribute and store it in the $routes array

The getController method has also been modified. The previous way of calling the controller has been removed. Instead of that we now select the correct route from the $routes array and return a Closure created from the controller / method combination.

Now when you restart your server, everything should work exactly as before - except now you can add new routes simply by creating new controller methods with Route attributes!

Note on performance: In a production application, you should definitely cache the discovered routes. Scanning files and using reflection on every request is slow! You could write the routes array to a cached PHP file during deployment, or use a caching layer like Redis or APCu. For our learning purposes, we'll keep it simple for now.

If you want to see how all this comes together, check out the repository at this commit.

Next

We now have a working controller system with automatic route discovery! But we're still just returning static strings from our controller methods. In the next episode, we will be introducing services and dependency injection. We will use these to provide database template, logging and security services to the rest of our application!

Navigation

Recent posts