Build a PHP Attribute-Based Router

Build a production routing system handling 90k requests/second using phpMyAdmin's cached attribute pattern.

Introduction

30 Second Summary

Every time you visit a URL in a web app, something behind the scenes decides which code handles your request. In production applications with hundreds of pages, managing those route connections manually becomes a maintenance nightmare.

In this project, you will build an attribute-based routing system in PHP that automatically discovers controllers and registers routes without any manual configuration.

What You'll Build

A complete PHP routing system modeled after phpMyAdmin's architecture, where controllers declare their routes using PHP 8 Attributes and the routing engine auto-discovers them at runtime.

By the end of this project, you'll have:

  • A Route attribute class that lets controllers declare their own routes using PHP 8 attributes.
  • An auto-discovery engine that scans controller files and registers routes via the Reflection API.
  • A working Task Management API with full CRUD operations proving the routing system handles real HTTP requests end-to-end.
  • Secret Mission: Add route caching for performance and a CLI tool that lists all registered routes.

Are there any prerequisites?

You should be comfortable with basic PHP concepts like classes, namespaces, and functions. No prior experience with routing systems or FastRoute is required.

Set Up the Project

Before you can build the routing system, you need a properly configured PHP project with its dependencies installed.

In this step, you will initialize a Composer project, install FastRoute as a dependency, and set up PSR-4 autoloading so PHP can find your classes automatically.

In this step, get ready to:
  • Initialize a Composer project with FastRoute as a dependency.
  • Configure PSR-4 autoloading to map the App namespace to your source directory.
  • Create the project directory structure and verify PHP is accessible.
Create the project directory and initialize Composer

Composer is PHP's package manager. It handles dependencies and autoloading for your project.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create and enter your project folder by running these commands:
mkdir task-router
cd task-router
  • Initialize the Composer project by running this command:
composer init --name=app/task-router --type=project --require="nikic/fast-route:^1.3" --autoload="src/" --no-interaction

What does this command do?

  • --name=app/task-router sets your package name, which Composer uses to identify your project.
  • --type=project marks this as a standalone project rather than a reusable library.
  • --require="nikic/fast-route:^1.3" adds FastRoute as a dependency. This is the same version phpMyAdmin uses in production.
  • --autoload="src/" sets up PSR-4 autoloading pointing at your src/ directory.
  • --no-interaction skips all interactive prompts and uses the provided values directly.
  • Install the dependencies by running:
composer install
  • Confirm the installation succeeded by running:
ls vendor/
  • You should see directories including nikic/ and composer/ in the output.

Seeing a 'command not found' error?

Make sure Composer is installed globally. You can check by running composer --version in your terminal.

If Composer is not installed, follow the instructions at https://getcomposer.org/download/ to install it.

Help me troubleshoot my Composer installation

Configure PSR-4 autoloading

The composer init command generated a namespace from your package name. It mapped App\TaskRouter\ to src/. You need to simplify this to just App\ so your class paths stay short and clean.

  • Open the task-router folder in VS Code. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the task-router folder on your Desktop, then click Open.
  • Open composer.json from the Explorer sidebar.
  • Replace the entire contents of the file with the following:
{
    "name": "app/task-router",
    "description": "Attribute-based PHP routing system inspired by phpMyAdmin",
    "type": "project",
    "require": {
        "php": "^8.2",
        "nikic/fast-route": "^1.3"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    }
}

What changed in this file?

  • The autoload namespace was simplified from App\TaskRouter\ to just App\. This means a class at src/Routing/Route.php uses the namespace App\Routing instead of the longer App\TaskRouter\Routing.
  • A description field was added to describe the project.
  • A "php": "^8.2" requirement was added to enforce the minimum PHP version needed for attributes.
  • Save composer.json.
  • Regenerate the autoloader to pick up the namespace change by running this in your terminal:
composer dump-autoload
  • You should see output including Generating autoload files.

Seeing a JSON parsing error?

Check that your composer.json has valid JSON syntax. A missing comma, extra trailing comma, or mismatched brace will cause the error.

Make sure the App\\ key in the psr-4 section uses double backslashes. JSON requires backslashes to be escaped.

Help me fix my composer.json syntax

Create the directory structure and verify PHP

Your project needs a few directories to organize the routing code, controllers, public entry point, and data storage.

  • Create all the project directories by running these commands:
mkdir -p src/Routing src/Controllers public data

What are these directories for?

  • src/Routing/ holds the routing engine classes (the Route attribute, collector, and dispatcher).
  • src/Controllers/ holds the controller classes that handle HTTP requests.
  • public/ holds the front controller entry point that receives all web requests.
  • data/ stores task data as JSON files.
  • Confirm the directories exist by running:
ls src/ public/ data/
  • You should see Routing and Controllers listed under src/.

Now confirm that PHP is the right version for this project. PHP 8 attributes require PHP 8.0 or higher, and this project pins to 8.2.

  • Check your PHP version by running:
php -v

✔️ I see version 8.2 or higher

Your PHP version is compatible. You are ready to start building the routing system.

ⓧ I see an older version

This project requires PHP 8.2 or higher because it uses PHP 8 attributes and readonly classes.

  • Visit https://www.php.net/downloads to download and install the latest PHP 8.2 release.
  • On macOS, if you installed PHP via Homebrew, your system may still point to an older version. Run brew install php@8.2 and follow the linking instructions Homebrew provides.
  • After installing, run php -v again to confirm the new version is active.

ⓧ Command not found

PHP is not installed or not in your system PATH.

  • On macOS, install PHP using Homebrew by running brew install php@8.2 in your terminal.
  • On Windows, download the PHP 8.2 installer from https://www.php.net/downloads and add the install directory to your system PATH.
  • After installing, close and reopen your terminal, then run php -v to confirm it works.

Finally, confirm that FastRoute installed correctly.

  • Verify the installed FastRoute version by running:
composer show nikic/fast-route
  • You should see version information for nikic/fast-route in the output.

✔️ Awesome, I've got everything!

Great. Double check you have saved composer.json and that your terminal is inside the task-router directory.

ⓧ I'd like to double check the full code

Here is the complete composer.json file. Make sure yours matches exactly:

{
    "name": "app/task-router",
    "description": "Attribute-based PHP routing system inspired by phpMyAdmin",
    "type": "project",
    "require": {
        "php": "^8.2",
        "nikic/fast-route": "^1.3"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    }
}

Your project foundation is solid. Next up, you will build the Route attribute class that controllers use to declare their URL paths.

Build the Route Attribute

Your project structure is ready with Composer, FastRoute, and all the directories in place.

Now you need a way for controller classes to declare their own routes without a central configuration file. PHP 8 Attributes solve this by letting you attach structured metadata directly to a class. In this step, you will create a Route attribute class and verify it works using the Reflection API.

In this step, get ready to:
  • Create a PHP 8 attribute class that stores a route path and HTTP methods.
  • Test the attribute with a script that reads it via Reflection.
Create the Route attribute class

In production PHP applications like phpMyAdmin, each controller class declares its routes with an attribute like #[Route('/navigation', ['GET', 'POST'])]. The routing engine reads this metadata and registers the routes automatically.

You are building the exact same pattern. Your Route attribute will hold a URL path and a list of allowed HTTP methods.

  • Create the file src/Routing/Route.php by running this command:
touch src/Routing/Route.php
  • Open src/Routing/Route.php in VS Code and paste the following code:
<?php

declare(strict_types=1);

namespace App\Routing;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
final readonly class Route
{
    /**
     * @param string $path
     * @param list<string> $methods
     */
    public function __construct(
        public string $path,
        public array $methods,
    ) {
    }
}

What does this code do?

This class is a PHP 8 attribute. The #[Attribute(...)] declaration tells PHP this class can be used as metadata on other classes.

  • Attribute::TARGET_CLASS restricts it to class-level declarations only. You cannot accidentally put it on a method or property.
  • Attribute::IS_REPEATABLE allows a controller to have multiple Route attributes for responding to multiple URL paths.
  • The final readonly keywords prevent subclassing and mutation. Route declarations are immutable once defined.
  • The constructor uses promoted properties, so $path and $methods are publicly accessible after instantiation.
  • Save src/Routing/Route.php.

Seeing a syntax error?

  • Make sure the namespace line reads App\Routing exactly. A typo here will break autoloading later.
  • The readonly keyword requires PHP 8.2+. Confirm your version with php -v.

Help me debug my Route attribute class.

Test the attribute with Reflection

Before building the full routing engine, you need to confirm that PHP can read your attribute from a class at runtime. A quick test script using ReflectionClass will prove this works.

  • Create a temporary test file in the project root by running:
touch test_attribute.php
  • Open test_attribute.php and paste the following code:
<?php

require_once __DIR__ . '/vendor/autoload.php';

use App\Routing\Route;

// A dummy controller to test the attribute on
#[Route('/tasks', ['GET', 'POST'])]
class DummyController {}

// Use Reflection to read the Route attribute from DummyController
$reflector = new ReflectionClass(DummyController::class);
$attributes = $reflector->getAttributes(Route::class);

foreach ($attributes as $attribute) {
    $route = $attribute->newInstance();
    echo "Path: " . $route->path . "\n";
    echo "Methods: " . implode(', ', $route->methods) . "\n";
}

What does this test do?

The script applies your Route attribute to a DummyController class, then uses ReflectionClass to inspect it at runtime.

  • getAttributes(Route::class) retrieves only Route attributes from the class.
  • newInstance() calls the attribute's constructor with the arguments you passed, giving you a real Route object.
  • You then read $route->path and $route->methods to confirm the values are accessible.
  • Save test_attribute.php and run the test by executing:
php test_attribute.php

You should see the following output:

Path: /tasks
Methods: GET, POST

This confirms PHP can read your attribute's path and methods at runtime. The same technique powers the full auto-discovery engine you will build next.

Not seeing the expected output?

  • If you see a class not found error, make sure you ran composer dump-autoload in Step 1. The autoloader needs to know about the App\ namespace.
  • Check that src/Routing/Route.php starts with namespace App\Routing; and the filename matches the class name exactly.
  • If the script runs but prints nothing, check that the #[Route('/tasks', ['GET', 'POST'])] line sits directly above the class declaration.

Help me figure out why my test_attribute.php script is not working.

  • Clean up the temporary test file by running:
rm test_attribute.php

✔️ Awesome, I've got everything!

Great. Make sure you have saved src/Routing/Route.php.

ⓧ I'd like to double check the full code

<?php

declare(strict_types=1);

namespace App\Routing;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
final readonly class Route
{
    /**
     * @param string $path
     * @param list<string> $methods
     */
    public function __construct(
        public string $path,
        public array $methods,
    ) {
    }
}

Your Route attribute is working. Next up, you will build the auto-discovery engine that scans an entire controllers directory and collects every Route attribute it finds.

Build the Auto-Discovery Route Collector

Your Route attribute can now declare routes on controller classes. The next piece of the puzzle is something that actually finds those attributes and registers the routes they describe.

In this step, you'll build the auto-discovery engine. This component scans your controllers directory, uses PHP's Reflection API to read Route attributes off each class, and feeds the discovered routes into FastRoute's dispatcher.

In this step, get ready to:
  • Build a RouteCollector class that scans a directory for PHP controller files.
  • Add reflection-based route discovery that reads attributes and registers routes with FastRoute.
  • Test the collector with a sample controller to verify routes are discovered automatically.
Create the RouteCollector class with directory scanning

The RouteCollector is responsible for finding every controller file in a directory and preparing them for route extraction. It uses PHP's RecursiveDirectoryIterator to walk through all files, including nested subdirectories.

  • Create a new file at src/Routing/RouteCollector.php and paste the following code:
<?php

declare(strict_types=1);

namespace App\Routing;

use FastRoute\RouteCollector as FastRouteCollector;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionClass;
use SplFileInfo;

final class RouteCollector
{
    public function __construct(
        // Path to the controllers directory on disk
        private string $controllersPath,
        // PSR-4 namespace prefix that maps to that directory
        private string $controllersNamespace,
    ) {
    }

    public function collect(FastRouteCollector $collector): void
    {
        // Resolve the absolute path to the controllers directory
        $basePath = realpath($this->controllersPath);
        if ($basePath === false) {
            return;
        }

        // Recursively walk all files in the controllers directory
        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($basePath, FilesystemIterator::SKIP_DOTS),
        );

        $routes = [];
        // Route discovery logic goes here
    }
}

What does this code do?

  • The constructor takes two arguments: the filesystem path to your controllers folder, and the PSR-4 namespace prefix that maps to it.
  • realpath() resolves the absolute path. If the directory does not exist, the method returns early.
  • RecursiveDirectoryIterator with FilesystemIterator::SKIP_DOTS walks all files in the directory tree, ignoring . and .. entries.
  • Save src/Routing/RouteCollector.php.

You won't see a change yet. The next substep fills in the route discovery logic that makes this class useful.

Add route discovery and registration logic

Now that the scanner walks the directory, it needs to inspect each PHP file, derive the class name, and use the Reflection API to check for Route attributes. This is the same pattern phpMyAdmin uses to discover 100+ controller routes without a manual configuration file.

  • In src/Routing/RouteCollector.php, find the comment // Route discovery logic goes here and replace it with the following code:
        foreach ($iterator as $file) {
            assert($file instanceof SplFileInfo);
            // Only process PHP files
            if ($file->getExtension() !== 'php') {
                continue;
            }

            $realPath = $file->getRealPath();
            if ($realPath === false) {
                continue;
            }

            // Derive the fully-qualified class name from the file path
            $pathFromControllersRoot = substr($realPath, strlen($basePath), -strlen('.php'));
            $className = $this->controllersNamespace . str_replace(DIRECTORY_SEPARATOR, '\\', $pathFromControllersRoot);

            if (!class_exists($className)) {
                continue;
            }

            // Use Reflection to read Route attributes from the class
            $reflector = new ReflectionClass($className);
            foreach ($reflector->getAttributes(Route::class) as $attribute) {
                $route = $attribute->newInstance();
                // Key by path to prevent duplicate route registrations
                $routes[$route->path] = [$route->methods, $route->path, $reflector->getName()];
            }
        }

        // Sort routes alphabetically by path for consistent registration order
        ksort($routes);
        foreach ($routes as $route) {
            $collector->addRoute(...$route);
        }

What does this code do?

  • For each .php file, the code strips the base path and file extension, then converts the remaining path into a fully-qualified class name using the PSR-4 namespace prefix.
  • ReflectionClass inspects the class at runtime. getAttributes(Route::class) returns only the attributes that match your Route class.
  • Routes are keyed by path ($routes[$route->path]) so that only one controller handles each path. This prevents route conflicts.
  • ksort() sorts the routes alphabetically by path before registering them with FastRoute's collector via addRoute().

Why key routes by path?

phpMyAdmin uses this exact pattern in its Routes.php file. Keying by path means one controller handles all HTTP methods for a given path. If two controllers accidentally declare the same path, the last one wins instead of causing a runtime conflict.

  • Save src/Routing/RouteCollector.php.

You won't see output yet. The next substep tests that routes are being discovered correctly.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

<?php

declare(strict_types=1);

namespace App\Routing;

use FastRoute\RouteCollector as FastRouteCollector;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionClass;
use SplFileInfo;

final class RouteCollector
{
    public function __construct(
        private string $controllersPath,
        private string $controllersNamespace,
    ) {
    }

    public function collect(FastRouteCollector $collector): void
    {
        $basePath = realpath($this->controllersPath);
        if ($basePath === false) {
            return;
        }

        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($basePath, FilesystemIterator::SKIP_DOTS),
        );

        $routes = [];
        foreach ($iterator as $file) {
            assert($file instanceof SplFileInfo);
            if ($file->getExtension() !== 'php') {
                continue;
            }

            $realPath = $file->getRealPath();
            if ($realPath === false) {
                continue;
            }

            $pathFromControllersRoot = substr($realPath, strlen($basePath), -strlen('.php'));
            $className = $this->controllersNamespace . str_replace(DIRECTORY_SEPARATOR, '\\', $pathFromControllersRoot);

            if (!class_exists($className)) {
                continue;
            }

            $reflector = new ReflectionClass($className);
            foreach ($reflector->getAttributes(Route::class) as $attribute) {
                $route = $attribute->newInstance();
                $routes[$route->path] = [$route->methods, $route->path, $reflector->getName()];
            }
        }

        ksort($routes);
        foreach ($routes as $route) {
            $collector->addRoute(...$route);
        }
    }
}
Test the route collector

To verify the auto-discovery works, you need a controller class with a Route attribute and a test script that wraps your RouteCollector in FastRoute's dispatcher.

  • Create a simple test controller at src/Controllers/TestController.php by pasting the following code:
<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Routing\Route;

// A minimal controller to verify route discovery
#[Route('/test', ['GET'])]
final class TestController
{
    public function __invoke(array $vars): void
    {
        echo 'Test route works!';
    }
}

What does this controller do?

This is a minimal controller decorated with #[Route('/test', ['GET'])]. If your auto-discovery logic works correctly, the RouteCollector will find this file, read its attribute, and register the /test route with FastRoute.

  • Save src/Controllers/TestController.php.
  • Create a test script at the project root called test_routes.php by pasting the following code:
<?php

require_once __DIR__ . '/vendor/autoload.php';

use App\Routing\RouteCollector;

// Point the collector at the Controllers directory
$routeCollector = new RouteCollector(
    __DIR__ . '/src/Controllers',
    'App\\Controllers'
);

// Wrap in FastRoute's simpleDispatcher to register discovered routes
$dispatcher = FastRoute\simpleDispatcher(
    function (FastRoute\RouteCollector $r) use ($routeCollector): void {
        $routeCollector->collect($r);
    }
);

echo "Routes collected successfully!\n";

// Verify the /test route was discovered
$routeInfo = $dispatcher->dispatch('GET', '/test');
if ($routeInfo[0] === FastRoute\Dispatcher::FOUND) {
    echo 'Route /test found! Handler: ' . $routeInfo[1] . "\n";
} else {
    echo "Route /test NOT found.\n";
}

What does this script do?

  • It creates a RouteCollector pointing at src/Controllers with the namespace prefix App\Controllers.
  • FastRoute\simpleDispatcher calls your collect() method, which scans the directory, finds TestController.php, reads its Route attribute, and registers the route.
  • The script then dispatches a GET /test request to confirm the route was found.
  • Save test_routes.php.
  • Run the test script by executing this command in your terminal:
php test_routes.php
  • You should see two lines of output: Routes collected successfully! followed by Route /test found! Handler: App\Controllers\TestController.

Seeing an error or 'Route /test NOT found'?

  • If you see a class-not-found error, run composer dump-autoload to regenerate the autoloader. Composer needs to know about new files in src/.
  • Make sure the namespace in TestController.php is exactly App\Controllers. A typo here means the autoloader cannot find the class.
  • Check that TestController.php is inside src/Controllers/ (not src/Routing/ or the project root).

Why isn't my RouteCollector finding my TestController?

Your auto-discovery engine works. Controllers can now declare routes using attributes, and the system finds them automatically. Next up, you'll build the front controller and dispatcher that tie incoming HTTP requests to these discovered routes.

Build the Front Controller and Dispatcher

Your RouteCollector can scan controllers and register their routes with FastRoute. But there is no way for an HTTP request to actually reach those controllers yet.

You need two pieces to close this gap. A Dispatcher class that takes an incoming request and matches it against registered routes. And a front controller script that acts as the single entry point for every HTTP request hitting your server.

In this step, get ready to:
  • Create the Dispatcher class that wraps FastRoute's matching logic.
  • Create the front controller entry point in public/index.php.
  • Start the PHP development server and verify 404 handling.
Create the Dispatcher class

The Dispatcher is responsible for taking a raw HTTP method and URI, running them through FastRoute's matching engine, and handling the three possible outcomes: route not found, method not allowed, or a successful match.

  • Create a new file at src/Routing/Dispatcher.php and add the following code:
<?php

declare(strict_types=1);

namespace App\Routing;

use FastRoute;
use FastRoute\Dispatcher as FastRouteDispatcher;

final class Dispatcher
{
    private FastRouteDispatcher $dispatcher;

    public function __construct(RouteCollector $routeCollector)
    {
        // Initialize FastRoute's dispatcher with our route collector
        $this->dispatcher = FastRoute\simpleDispatcher(
            function (FastRoute\RouteCollector $r) use ($routeCollector): void {
                $routeCollector->collect($r);
            }
        );
    }

    public function dispatch(string $httpMethod, string $uri): void
    {
        // Decode the URI and extract just the path component
        $uri = rawurldecode(parse_url($uri, PHP_URL_PATH) ?? '/');

        $routeInfo = $this->dispatcher->dispatch($httpMethod, $uri);

        switch ($routeInfo[0]) {
            case FastRouteDispatcher::NOT_FOUND:
                // No route matches this path
                http_response_code(404);
                header('Content-Type: application/json');
                echo json_encode(['error' => 'Not Found', 'path' => $uri]);
                break;

            case FastRouteDispatcher::METHOD_NOT_ALLOWED:
                // Path exists but not for this HTTP method
                $allowedMethods = $routeInfo[1];
                http_response_code(405);
                header('Content-Type: application/json');
                header('Allow: ' . implode(', ', $allowedMethods));
                echo json_encode([
                    'error' => 'Method Not Allowed',
                    'allowed_methods' => $allowedMethods,
                ]);
                break;

            case FastRouteDispatcher::FOUND:
                // Route matched - instantiate and invoke the controller
                $controllerClass = $routeInfo[1];
                $vars = $routeInfo[2];
                $controller = new $controllerClass();
                $controller($vars);
                break;
        }
    }
}

What does this code do?

The constructor passes your RouteCollector into FastRoute\simpleDispatcher. This builds FastRoute's internal matching data structure from all your discovered routes.

The dispatch() method handles the three outcomes FastRoute can return. NOT_FOUND means no route exists for that path. METHOD_NOT_ALLOWED means the path is registered but not for the requested HTTP method. FOUND means a match was found, so the Dispatcher creates the controller and invokes it.

This is the same dispatch flow phpMyAdmin uses. It decodes the URI with rawurldecode() before matching, and includes the Allow header on 405 responses so clients know which methods are valid.

  • Save src/Routing/Dispatcher.php.

Seeing a class not found error?

Make sure the namespace at the top of your file is exactly App\Routing. Also confirm your composer.json has the psr-4 autoload mapping pointing App\\ to src/.

help me debug a namespace or autoloading issue with my Dispatcher class

Create the front controller

A front controller is the single PHP file that receives every HTTP request. Instead of each URL mapping to a separate PHP file, all traffic flows through one entry point that decides what code to run.

  • Create the public/ directory if it does not already exist, then create public/index.php with the following code:
<?php

declare(strict_types=1);

// Router for PHP's built-in development server
if (php_sapi_name() === 'cli-server') {
    $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
    if ($uri !== '/' && file_exists(__DIR__ . $uri)) {
        return false;
    }
}

require_once __DIR__ . '/../vendor/autoload.php';

use App\Routing\Dispatcher;
use App\Routing\RouteCollector;

$routeCollector = new RouteCollector(
    __DIR__ . '/../src/Controllers',
    'App\\Controllers'
);

$dispatcher = new Dispatcher($routeCollector);

$httpMethod = $_SERVER['REQUEST_METHOD'];
$uri = $_SERVER['REQUEST_URI'];

$dispatcher->dispatch($httpMethod, $uri);

What does this code do?

The cli-server block at the top handles a quirk of PHP's built-in development server. Without it, the server would route requests for static files (like CSS or images) through your routing logic. The return false tells the server to serve those files directly instead.

The rest is your application's wiring. It creates a RouteCollector pointed at the src/Controllers directory, wraps it in a Dispatcher, then pulls the HTTP method and URI from $_SERVER and dispatches the request.

Why a front controller instead of one-file-per-route?

Traditional PHP apps map URLs directly to files on disk (/about.php serves about.php). This works for small sites but breaks down when you need dynamic URL segments, middleware, or consistent error handling across routes.

A front controller centralizes all request handling. Every URL enters through index.php, which makes it easy to add cross-cutting logic later. This is how every major PHP framework operates.

  • Save public/index.php.

Server won't start later?

Confirm you have the public/ directory at the root of your task-router project folder, with index.php inside it. The PHP server needs the -t public flag to point at this directory.

help me figure out why my PHP development server is not starting or not routing requests to index.php

Start the development server and test

With both files in place, you can boot PHP's built-in development server. It will use public/index.php as the router script, funnelling every request through your new Dispatcher.

  • Start the PHP development server by running this command from your task-router directory:
php -S localhost:8000 -t public public/index.php

What does this command do?

The -S localhost:8000 flag starts a server on port 8000. The -t public flag sets the document root to your public/ directory. The last argument public/index.php tells PHP to use that file as the router script for all incoming requests.

You should see output indicating the server is listening. Keep this terminal running.

  • Open a second terminal window. Test the routing by requesting a path that does not exist:

macOS

curl http://localhost:8000/nonexistent

Windows

Invoke-RestMethod http://localhost:8000/nonexistent

You should see a JSON response containing an error message and the path you requested:

{"error":"Not Found","path":"/nonexistent"}

What should I see?

The 404 JSON response proves the full chain is working. Your request entered through public/index.php, got dispatched by your Dispatcher class, and since no controller is registered for /nonexistent, it correctly returned a structured error.

Seeing a blank page or HTML error?

Check the terminal where your server is running for PHP error messages. Common causes: a typo in the require_once path (make sure you have the /../ prefix to go up one level from public/), or a missing vendor/ directory (run composer install if needed).

help me debug why my PHP development server is returning a blank page or HTML error instead of JSON

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the full src/Routing/Dispatcher.php:

<?php

declare(strict_types=1);

namespace App\Routing;

use FastRoute;
use FastRoute\Dispatcher as FastRouteDispatcher;

final class Dispatcher
{
    private FastRouteDispatcher $dispatcher;

    public function __construct(RouteCollector $routeCollector)
    {
        $this->dispatcher = FastRoute\simpleDispatcher(
            function (FastRoute\RouteCollector $r) use ($routeCollector): void {
                $routeCollector->collect($r);
            }
        );
    }

    public function dispatch(string $httpMethod, string $uri): void
    {
        $uri = rawurldecode(parse_url($uri, PHP_URL_PATH) ?? '/');

        $routeInfo = $this->dispatcher->dispatch($httpMethod, $uri);

        switch ($routeInfo[0]) {
            case FastRouteDispatcher::NOT_FOUND:
                http_response_code(404);
                header('Content-Type: application/json');
                echo json_encode(['error' => 'Not Found', 'path' => $uri]);
                break;

            case FastRouteDispatcher::METHOD_NOT_ALLOWED:
                $allowedMethods = $routeInfo[1];
                http_response_code(405);
                header('Content-Type: application/json');
                header('Allow: ' . implode(', ', $allowedMethods));
                echo json_encode([
                    'error' => 'Method Not Allowed',
                    'allowed_methods' => $allowedMethods,
                ]);
                break;

            case FastRouteDispatcher::FOUND:
                $controllerClass = $routeInfo[1];
                $vars = $routeInfo[2];
                $controller = new $controllerClass();
                $controller($vars);
                break;
        }
    }
}

Here is the full public/index.php:

<?php

declare(strict_types=1);

// Router for PHP's built-in development server
if (php_sapi_name() === 'cli-server') {
    $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
    if ($uri !== '/' && file_exists(__DIR__ . $uri)) {
        return false;
    }
}

require_once __DIR__ . '/../vendor/autoload.php';

use App\Routing\Dispatcher;
use App\Routing\RouteCollector;

$routeCollector = new RouteCollector(
    __DIR__ . '/../src/Controllers',
    'App\\Controllers'
);

$dispatcher = new Dispatcher($routeCollector);

$httpMethod = $_SERVER['REQUEST_METHOD'];
$uri = $_SERVER['REQUEST_URI'];

$dispatcher->dispatch($httpMethod, $uri);

Your routing infrastructure is complete. The RouteCollector discovers routes, the Dispatcher matches requests, and the front controller ties them together. Next up, you will create actual controllers and watch the system dispatch real API requests.

Create Task Controllers and Test the API

Your routing engine is fully operational. It can scan controllers, read attributes, and dispatch requests. Now it is time to prove the system works end-to-end by building real controllers that handle HTTP requests.

In this step, you will create three controllers that form a Task Management API. Each controller declares its own routes using the Route attribute, and the auto-discovery engine registers them automatically.

In this step, get ready to:
  • Create a HomeController and TasksController that handle listing and creating tasks.
  • Create a TaskDetailController that handles viewing and deleting individual tasks.
  • Test all CRUD operations to verify the routing system dispatches correctly.
Create the HomeController

The HomeController is a landing page for the API. It returns a list of all available endpoints so anyone using the API knows what is available.

  • Create a new file called src/Controllers/HomeController.php and add the following code:
<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Routing\Route;

// This attribute registers the '/' path for GET requests
#[Route('/', ['GET'])]
final class HomeController
{
    public function __invoke(array $vars): void
    {
        header('Content-Type: application/json');
        // Return a map of all available API endpoints
        echo json_encode([
            'message' => 'Task Router API',
            'endpoints' => [
                'GET /' => 'This help message',
                'GET /tasks' => 'List all tasks',
                'POST /tasks' => 'Create a new task',
                'GET /tasks/{id}' => 'View a specific task',
                'DELETE /tasks/{id}' => 'Delete a specific task',
            ],
        ]);
    }
}

What does this code do?

The #[Route('/', ['GET'])] attribute tells the auto-discovery engine that this controller handles GET requests to the root path.

The __invoke method makes the class callable. When the dispatcher finds a matching route, it instantiates the controller and calls it like a function.

  • Save src/Controllers/HomeController.php.
  • In your browser, visit http://localhost:8000/. You should see a JSON response listing all the API endpoints.

Seeing a 404 instead of the endpoint list?

  • Make sure the file is saved at src/Controllers/HomeController.php (inside Controllers, not Routing).
  • Check that the namespace is App\Controllers and the use App\Routing\Route import is present.
  • Confirm your PHP development server is still running in the other terminal.

Ask for help: Why is my HomeController returning a 404 instead of the JSON endpoint list?

✔️ Awesome, I've got everything!

Great. Make sure the file is saved before moving on.

ⓧ I'd like to double check the full code

<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Routing\Route;

#[Route('/', ['GET'])]
final class HomeController
{
    public function __invoke(array $vars): void
    {
        header('Content-Type: application/json');
        echo json_encode([
            'message' => 'Task Router API',
            'endpoints' => [
                'GET /' => 'This help message',
                'GET /tasks' => 'List all tasks',
                'POST /tasks' => 'Create a new task',
                'GET /tasks/{id}' => 'View a specific task',
                'DELETE /tasks/{id}' => 'Delete a specific task',
            ],
        ]);
    }
}
Create the TasksController

The TasksController handles the collection endpoint. It needs to support both listing all tasks (GET) and creating new tasks (POST) at the same path.

  • Create a new file called src/Controllers/TasksController.php and add the following code:
<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Routing\Route;

// Handle both GET (list) and POST (create) on /tasks
#[Route('/tasks', ['GET', 'POST'])]
final class TasksController
{
    public function __invoke(array $vars): void
    {
        // Dispatch to the right method based on HTTP verb
        match ($_SERVER['REQUEST_METHOD']) {
            'GET' => $this->list(),
            'POST' => $this->create(),
        };
    }

    private function list(): void
    {
        $tasks = $this->loadTasks();
        header('Content-Type: application/json');
        echo json_encode(['tasks' => $tasks]);
    }

    private function create(): void
    {
        // Read the raw JSON body from the request
        $input = json_decode(file_get_contents('php://input'), true);

        if (!isset($input['title'])) {
            http_response_code(400);
            header('Content-Type: application/json');
            echo json_encode(['error' => 'Title is required']);
            return;
        }

        $tasks = $this->loadTasks();

        // Build the new task with an auto-incremented ID
        $newTask = [
            'id' => count($tasks) + 1,
            'title' => $input['title'],
            'completed' => false,
            'created_at' => date('Y-m-d H:i:s'),
        ];

        $tasks[] = $newTask;
        $this->saveTasks($tasks);

        http_response_code(201);
        header('Content-Type: application/json');
        echo json_encode($newTask);
    }

    private function loadTasks(): array
    {
        $file = __DIR__ . '/../../data/tasks.json';
        if (!file_exists($file)) {
            return [];
        }

        return json_decode(file_get_contents($file), true) ?? [];
    }

    private function saveTasks(array $tasks): void
    {
        $file = __DIR__ . '/../../data/tasks.json';
        $dir = dirname($file);
        if (!is_dir($dir)) {
            mkdir($dir, 0777, true);
        }

        file_put_contents($file, json_encode($tasks, JSON_PRETTY_PRINT));
    }
}

What does this code do?

The #[Route('/tasks', ['GET', 'POST'])] attribute registers both HTTP methods on the same path. One controller handles the entire /tasks resource. This is the same one-controller-per-path pattern that phpMyAdmin uses.

Inside __invoke, a match expression dispatches to the correct private method based on the HTTP verb.

The loadTasks() and saveTasks() methods use a simple data/tasks.json file as storage. The data/ directory is created automatically on the first write.

  • Save src/Controllers/TasksController.php.
  • In your browser, visit http://localhost:8000/tasks. You should see {"tasks":[]} since no tasks exist yet.

Seeing an empty page or a 500 error?

  • Check that the match expression uses $_SERVER['REQUEST_METHOD'] exactly as shown.
  • Make sure the data/ directory path in loadTasks() uses __DIR__ . '/../../data/tasks.json' (two levels up from the Controllers folder).

Ask for help: Why is my TasksController returning a 500 error or blank page?

✔️ Awesome, I've got everything!

Great. Make sure the file is saved before moving on.

ⓧ I'd like to double check the full code

<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Routing\Route;

#[Route('/tasks', ['GET', 'POST'])]
final class TasksController
{
    public function __invoke(array $vars): void
    {
        match ($_SERVER['REQUEST_METHOD']) {
            'GET' => $this->list(),
            'POST' => $this->create(),
        };
    }

    private function list(): void
    {
        $tasks = $this->loadTasks();
        header('Content-Type: application/json');
        echo json_encode(['tasks' => $tasks]);
    }

    private function create(): void
    {
        $input = json_decode(file_get_contents('php://input'), true);

        if (!isset($input['title'])) {
            http_response_code(400);
            header('Content-Type: application/json');
            echo json_encode(['error' => 'Title is required']);
            return;
        }

        $tasks = $this->loadTasks();

        $newTask = [
            'id' => count($tasks) + 1,
            'title' => $input['title'],
            'completed' => false,
            'created_at' => date('Y-m-d H:i:s'),
        ];

        $tasks[] = $newTask;
        $this->saveTasks($tasks);

        http_response_code(201);
        header('Content-Type: application/json');
        echo json_encode($newTask);
    }

    private function loadTasks(): array
    {
        $file = __DIR__ . '/../../data/tasks.json';
        if (!file_exists($file)) {
            return [];
        }

        return json_decode(file_get_contents($file), true) ?? [];
    }

    private function saveTasks(array $tasks): void
    {
        $file = __DIR__ . '/../../data/tasks.json';
        $dir = dirname($file);
        if (!is_dir($dir)) {
            mkdir($dir, 0777, true);
        }

        file_put_contents($file, json_encode($tasks, JSON_PRETTY_PRINT));
    }
}
Create the TaskDetailController

The TaskDetailController handles individual task operations. It uses FastRoute's parameter matching with a regex constraint to ensure only numeric IDs are accepted.

  • Create a new file called src/Controllers/TaskDetailController.php and add the following code:
<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Routing\Route;

// {id:\d+} matches only digits, so /tasks/abc won't match
#[Route('/tasks/{id:\d+}', ['GET', 'DELETE'])]
final class TaskDetailController
{
    public function __invoke(array $vars): void
    {
        // FastRoute passes matched parameters in $vars
        $id = (int) $vars['id'];
        match ($_SERVER['REQUEST_METHOD']) {
            'GET' => $this->show($id),
            'DELETE' => $this->delete($id),
        };
    }

    private function show(int $id): void
    {
        $task = $this->findTask($id);

        header('Content-Type: application/json');

        if ($task === null) {
            http_response_code(404);
            echo json_encode(['error' => 'Task not found']);
            return;
        }

        echo json_encode($task);
    }

    private function delete(int $id): void
    {
        $tasks = $this->loadTasks();
        $found = false;

        // Filter out the task with the matching ID
        $tasks = array_filter($tasks, function ($task) use ($id, &$found) {
            if ($task['id'] === $id) {
                $found = true;
                return false;
            }
            return true;
        });

        header('Content-Type: application/json');

        if (!$found) {
            http_response_code(404);
            echo json_encode(['error' => 'Task not found']);
            return;
        }

        // Re-index the array after filtering
        $this->saveTasks(array_values($tasks));
        echo json_encode(['message' => 'Task deleted']);
    }

    private function findTask(int $id): ?array
    {
        $tasks = $this->loadTasks();
        foreach ($tasks as $task) {
            if ($task['id'] === $id) {
                return $task;
            }
        }
        return null;
    }

    private function loadTasks(): array
    {
        $file = __DIR__ . '/../../data/tasks.json';
        if (!file_exists($file)) {
            return [];
        }

        return json_decode(file_get_contents($file), true) ?? [];
    }

    private function saveTasks(array $tasks): void
    {
        $file = __DIR__ . '/../../data/tasks.json';
        file_put_contents($file, json_encode($tasks, JSON_PRETTY_PRINT));
    }
}

What does this code do?

The route /tasks/{id:\d+} uses FastRoute's parameter syntax. The {id:\d+} segment captures a named parameter id that only matches one or more digits. A request to /tasks/abc would fall through as a 404 because it does not match the regex.

The matched id arrives in the $vars array that the Dispatcher passes to __invoke. This is how the controller knows which task to show or delete.

  • Save src/Controllers/TaskDetailController.php.
  • In your browser, visit http://localhost:8000/tasks/1. You should see a JSON error {"error":"Task not found"} because no tasks exist yet.

Getting a 404 instead of the "Task not found" error?

  • A 404 from the dispatcher (with a path field) means the route was never registered. Check that the attribute uses \d+ (not \\d+) inside the route path string.
  • Make sure the class name matches the filename exactly: TaskDetailController in TaskDetailController.php.

Ask for help: Why is my TaskDetailController route not being discovered by the auto-discovery engine?

✔️ Awesome, I've got everything!

Great. Make sure the file is saved before moving on.

ⓧ I'd like to double check the full code

<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Routing\Route;

#[Route('/tasks/{id:\d+}', ['GET', 'DELETE'])]
final class TaskDetailController
{
    public function __invoke(array $vars): void
    {
        $id = (int) $vars['id'];
        match ($_SERVER['REQUEST_METHOD']) {
            'GET' => $this->show($id),
            'DELETE' => $this->delete($id),
        };
    }

    private function show(int $id): void
    {
        $task = $this->findTask($id);

        header('Content-Type: application/json');

        if ($task === null) {
            http_response_code(404);
            echo json_encode(['error' => 'Task not found']);
            return;
        }

        echo json_encode($task);
    }

    private function delete(int $id): void
    {
        $tasks = $this->loadTasks();
        $found = false;

        $tasks = array_filter($tasks, function ($task) use ($id, &$found) {
            if ($task['id'] === $id) {
                $found = true;
                return false;
            }
            return true;
        });

        header('Content-Type: application/json');

        if (!$found) {
            http_response_code(404);
            echo json_encode(['error' => 'Task not found']);
            return;
        }

        $this->saveTasks(array_values($tasks));
        echo json_encode(['message' => 'Task deleted']);
    }

    private function findTask(int $id): ?array
    {
        $tasks = $this->loadTasks();
        foreach ($tasks as $task) {
            if ($task['id'] === $id) {
                return $task;
            }
        }
        return null;
    }

    private function loadTasks(): array
    {
        $file = __DIR__ . '/../../data/tasks.json';
        if (!file_exists($file)) {
            return [];
        }

        return json_decode(file_get_contents($file), true) ?? [];
    }

    private function saveTasks(array $tasks): void
    {
        $file = __DIR__ . '/../../data/tasks.json';
        file_put_contents($file, json_encode($tasks, JSON_PRETTY_PRINT));
    }
}
Test the API endpoints

All three controllers are in place. The auto-discovery engine picks them up on every request, so no restart is needed. Time to run through the full CRUD workflow to confirm everything dispatches correctly.

  • Create a new task by running this command:

macOS

curl -X POST http://localhost:8000/tasks -H "Content-Type: application/json" -d '{"title": "Learn PHP routing"}'

Windows

curl.exe -X POST http://localhost:8000/tasks -H "Content-Type: application/json" -d "{`"title`": `"Learn PHP routing`"}"

You should see a JSON response with the new task, including an id of 1, a completed status of false, and a created_at timestamp.

  • List all tasks by running:

macOS

curl http://localhost:8000/tasks

Windows

curl.exe http://localhost:8000/tasks

You should see a tasks array containing the task you just created.

  • View the specific task by its ID:

macOS

curl http://localhost:8000/tasks/1

Windows

curl.exe http://localhost:8000/tasks/1

You should see the single task object with all its fields.

  • Delete the task:

macOS

curl -X DELETE http://localhost:8000/tasks/1

Windows

curl.exe -X DELETE http://localhost:8000/tasks/1

You should see {"message":"Task deleted"} confirming the task was removed.

  • Now test the error handling. Try POSTing to a single-task endpoint:

macOS

curl -X POST http://localhost:8000/tasks/1

Windows

curl.exe -X POST http://localhost:8000/tasks/1

You should see a 405 Method Not Allowed response with an allowed_methods array showing GET and DELETE.

  • Finally, test a completely unknown route:

macOS

curl http://localhost:8000/unknown

Windows

curl.exe http://localhost:8000/unknown

You should see a 404 Not Found response with the path /unknown in the error message.

Why does this work without restarting the server?

PHP's built-in server re-executes public/index.php on every request. The RouteCollector scans the filesystem each time it runs, so new controller files are discovered immediately. This is the same behavior that makes development easy.

In production, you would add caching to avoid this overhead on every request. That is exactly what the Secret Mission covers.

Not seeing the expected responses?

  • If all routes return 404, check that the PHP server was started from the project root with php -S localhost:8000 -t public public/index.php.
  • If POST returns a blank response, make sure you included the -H "Content-Type: application/json" header and -d flag with valid JSON.
  • On Windows, make sure you are using curl.exe (not curl which is a PowerShell alias for a different command).

Ask for help: My curl requests are not returning the expected JSON responses from the task API.

Secret mission

Add Route Caching and a Route-Listing CLI Tool

Your routing system re-scans every controller file on every single request. In production, phpMyAdmin solves this with route caching and a CLI tool that lets developers inspect all registered routes. Can you add both to your router?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The task-router project folder on your Desktop (includes source code, Composer dependencies, route cache, and task data).
  • PHP built-in development server (if still running in your terminal).

Keep everything running

No action needed. Choose this if you are still actively building or want to experiment with the routing system further.

  • Your task-router folder stays on your Desktop with all source files, dependencies, and data intact.
  • If the PHP development server is still running, it continues serving requests on localhost:8000.
  • You can restart the server anytime by navigating to the task-router folder and running php -S localhost:8000 -t public public/index.php.

Pause - I'll come back to this later

Shut down the running server process to free up your terminal. Your project files stay on disk so you can pick up where you left off.

  • Switch to the terminal where the PHP server is running and press Ctrl+C to stop it.
  • Your task-router folder remains on your Desktop. When you are ready to resume, navigate back to it and restart the server.

Delete - I don't want to use this again

Remove all project files and stop any running processes. This is a full cleanup.

  • Switch to the terminal where the PHP server is running and press Ctrl+C to stop it.
  • Delete the entire project folder by running the following command (macOS/Linux):
rm -rf ~/Desktop/task-router

On Windows, run this in PowerShell:

Remove-Item -Recurse -Force ~\Desktop\task-router
  • This removes all source code, the vendor/ directory with Composer dependencies, the cache/ directory, and the data/ directory with any saved tasks.

Nice Work!

Nice Work!

You just built a production-grade attribute-based routing system modeled after phpMyAdmin's architecture. Your Task API proves that the same pattern powering 100+ routes in a major open-source project works just as well in your own applications.

What you learned:

  • Built a PHP 8 Route attribute and an auto-discovery engine that scans controllers using RecursiveDirectoryIterator and the Reflection API.
  • Built a Dispatcher that handles 404, 405, and successful route matches using FastRoute.
  • Created a working Task API with full CRUD operations, proving the routing architecture handles real HTTP requests end-to-end.
  • Secret Mission: Added route caching for performance and a CLI route-listing tool to inspect all registered routes.

Ready to quiz yourself?