diff --git a/src/Http/Router.php b/src/Http/Router.php
new file mode 100644
index 0000000..2d0b54c
--- /dev/null
+++ b/src/Http/Router.php
@@ -0,0 +1,47 @@
+): Response}> */
+ private array $routes = [];
+
+ public function add(string $method, string $path, callable $handler): void
+ {
+ $pattern = '#^' . preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>[^/]+)', $path) . '$#';
+
+ $this->routes[] = [
+ 'method' => strtoupper($method),
+ 'pattern' => $pattern,
+ 'handler' => $handler,
+ ];
+ }
+
+ public function dispatch(Request $request): Response
+ {
+ foreach ($this->routes as $route) {
+ if ($route['method'] !== $request->method) {
+ continue;
+ }
+
+ if (preg_match($route['pattern'], $request->path, $matches) === 1) {
+ $params = [];
+
+ foreach ($matches as $key => $value) {
+ if (is_string($key)) {
+ $params[$key] = $value;
+ }
+ }
+
+ return ($route['handler'])($request, $params);
+ }
+ }
+
+ return Response::html(404, '
Not found
');
+ }
+}
diff --git a/tests/Unit/Http/RouterTest.php b/tests/Unit/Http/RouterTest.php
new file mode 100644
index 0000000..68008af
--- /dev/null
+++ b/tests/Unit/Http/RouterTest.php
@@ -0,0 +1,83 @@
+add('GET', '/', static fn (Request $request): Response => Response::html(200, 'home'));
+
+ $response = $router->dispatch($this->request('GET', '/'));
+
+ self::assertSame(200, $response->status);
+ self::assertSame('home', $response->body);
+ }
+
+ public function testItReturnsA404ForUnknownPaths(): void
+ {
+ $router = new Router();
+
+ $response = $router->dispatch($this->request('GET', '/missing'));
+
+ self::assertSame(404, $response->status);
+ }
+
+ public function testItReturnsAMethodNotAllowedResponseForMismatchedMethods(): void
+ {
+ $router = new Router();
+ $router->add('POST', '/', static fn (): Response => Response::html(200, 'ok'));
+
+ $response = $router->dispatch($this->request('GET', '/'));
+
+ self::assertSame(404, $response->status);
+ }
+
+ public function testItCapturesPathParams(): void
+ {
+ $router = new Router();
+ $router->add('GET', '/scenarios/{id}/edit', static function (Request $request, array $params): Response {
+ return Response::html(200, 'id=' . $params['id']);
+ });
+
+ $response = $router->dispatch($this->request('GET', '/scenarios/demo/edit'));
+
+ self::assertSame(200, $response->status);
+ self::assertSame('id=demo', $response->body);
+ }
+
+ public function testItMatchesMoreSpecificRoutesFirstByRegistrationOrder(): void
+ {
+ $router = new Router();
+ $router->add('GET', '/scenarios/{id}', static fn (): Response => Response::html(200, 'any'));
+ $router->add('GET', '/scenarios/special', static fn (): Response => Response::html(200, 'special'));
+
+ $response = $router->dispatch($this->request('GET', '/scenarios/special'));
+
+ self::assertSame('any', $response->body);
+ }
+
+ private function request(string $method, string $path): Request
+ {
+ return new Request(
+ get: [],
+ post: [],
+ files: [],
+ cookies: [],
+ server: [],
+ queryString: '',
+ method: $method,
+ path: $path,
+ contentType: null,
+ rawBody: '',
+ );
+ }
+}