Chapter 2: Routing#
1. How Routing Works in Tina4#
A URL arrives. The framework finds the function that handles it. The function runs. The result goes back. That mapping -- URL to code -- is routing.
In Tina4, routes live in PHP files inside src/routes/. Every .php file in that directory (and its subdirectories) is auto-loaded at startup. No registration file. No central config. Drop a file in. It works.
The simplest possible route:
<?phpuse Tina4\Router;โRouter::get("/hello", function ($request, $response) { return $response->json(["message" => "Hello, World!"]);});Save that as src/routes/hello.php. Start the server. Visit http://localhost:7145/hello:
{"message":"Hello, World!"}One line registers the route. One line handles the request.
2. HTTP Methods#
Five methods. Five static calls on the Route class.
<?phpuse Tina4\Router;โRouter::get("/products", function ($request, $response) { return $response->json(["action" => "list all products"]);});โRouter::post("/products", function ($request, $response) { return $response->json(["action" => "create a product"], 201);})->noAuth();โRouter::put("/products/{id}", function ($request, $response) { $id = $request->params["id"]; return $response->json(["action" => "replace product " . $id]);})->noAuth();โRouter::patch("/products/{id}", function ($request, $response) { $id = $request->params["id"]; return $response->json(["action" => "update product " . $id]);})->noAuth();โRouter::delete("/products/{id}", function ($request, $response) { $id = $request->params["id"]; return $response->json(["action" => "delete product " . $id]);})->noAuth();Default auth policy.
POST,PUT,PATCH, andDELETEare secured by default in Tina4, without->noAuth()the examples below return401 Unauthorized. In production leave the defaults alone and send anAuthorization: Bearer <token>header on write requests. For these Getting Started examples we chain->noAuth()socurlworks without setting up auth first.
Test each one:
curl http://localhost:7145/products{"action":"list all products"}curl -X POST http://localhost:7145/products \ -H "Content-Type: application/json" \ -d '{"name": "Widget"}'{"action":"create a product"}curl -X PUT http://localhost:7145/products/42{"action":"replace product 42"}curl -X PATCH http://localhost:7145/products/42{"action":"update product 42"}curl -X DELETE http://localhost:7145/products/42{"action":"delete product 42"}GET reads. POST creates. PUT replaces. PATCH patches. DELETE removes. REST convention. Predictable API.
HEAD and OPTIONS - automatic, no boilerplate#
Tina4 handles HEAD and OPTIONS for you. You don't register them. They follow from your Router::get(...) / Router::post(...) / etc. routes:
HEADis identical toGETexcept the response carries no body (RFC 9110 ยง9.3.2). EveryGETroute automatically responds toHEAD, the framework strips the response body on the way out and preservesContent-Lengthpointing at the byte count the equivalentGETwould have sent. Cache validators, link checkers, and uptime probes work without you writing anything.OPTIONSon any registered path returns204 No Contentwith anAllow:header listing every method the path supports (RFC 9110 ยง9.3.7).- Wrong method on an existing path returns
405 Method Not Allowedwith the sameAllow:header (RFC 9110 ยง15.5.6 + ยง10.2.1). SendingPUTto aGET-only route used to return404, semantically wrong and confusing for link checkers. Now you get a real405. TRACEandCONNECTare rejected with405(security default for origin servers).
# HEAD on a GET route - same headers, empty bodycurl -sI http://localhost:7145/products# HTTP/1.1 200 OK# Content-Type: application/json# Content-Length: 33โ# OPTIONS - discover what the path supportscurl -sI -X OPTIONS http://localhost:7145/products# HTTP/1.1 204 No Content# Allow: GET, POST, HEAD, OPTIONSโ# Wrong method - clear 405 with Allow headercurl -sI -X PUT http://localhost:7145/products# HTTP/1.1 405 Method Not Allowed# Allow: GET, POST, HEAD, OPTIONSExplicit Router::head() and Router::options() registration#
The automatic behaviour is enough for 95% of apps. When you need custom HEAD or OPTIONS handlers, register them explicitly:
use Tina4\Router;use Tina4\Request;use Tina4\Response;โ// HEAD handler that doesn't run the full GET body - useful for// expensive endpoints where the client only needs to check existence// or read validators (ETag, Last-Modified).Router::head("/expensive/{id}", function (Request $request, Response $response) { $response->header("ETag", computeEtag($request->params["id"])); return $response;});โ// OPTIONS handler that returns more than just Allow - for example// a discovery endpoint.Router::options("/api/discovery", function (Request $request, Response $response) { return $response->json(["version" => "v1", "endpoints" => [...]]);});The framework still strips the response body for HEAD handlers (RFC 9110 ยง9.3.2 is a MUST), so you can't accidentally leak content even if your handler returns a body.
3. Path Parameters#
Curly braces capture values from the URL.
<?phpuse Tina4\Router;โRouter::get("/users/{id}/posts/{postId}", function ($request, $response) { $userId = $request->params["id"]; $postId = $request->params["postId"];โ return $response->json([ "user_id" => $userId, "post_id" => $postId ]);});curl http://localhost:7145/users/5/posts/99{"user_id":"5","post_id":"99"}Notice: user_id came back as the string "5", not the integer 5. Path parameters are strings by default.
Auto-casting: Tina4 automatically casts path parameter values that are purely numeric to integers. For example, requesting
/users/42/posts/99will give you$request->params["id"]as the integer42and$request->params["postId"]as the integer99-- no explicit:inttype hint required. The:inttype hint adds validation (rejecting non-numeric values with a 404), but the auto-casting happens regardless.
Typed Parameters#
Add a colon and a type to enforce constraints:
<?phpuse Tina4\Router;โRouter::get("/orders/{id:int}", function ($request, $response) { $id = $request->params["id"]; // This is now an integer return $response->json([ "order_id" => $id, "type" => gettype($id) ]);});curl http://localhost:7145/orders/42{"order_id":42,"type":"integer"}Pass a non-integer and the route does not match. A 404 comes back instead:
curl http://localhost:7145/orders/abc{"error":"Not found","path":"/orders/abc","status":404}Supported types:
| Type | Matches | Auto-cast | Example |
|---|---|---|---|
int | Digits only | Integer | {id:int} matches 42 but not abc |
float | Decimal numbers | Float | {price:float} matches 19.99 |
path | All remaining path segments (catch-all) | String | {slug:path} matches docs/api/auth |
alpha | Letters only | String | {slug:alpha} matches hello but not hello123 |
alphanumeric | Letters and digits | String | {code:alphanumeric} matches abc123 |
The {name} form (no type) matches any single path segment and returns it as a string.
Typed Parameters in Action#
Here is a complete example showing the most commonly used typed parameters together:
<?phpuse Tina4\Router;โ// Integer parameter -- only digits match, auto-cast to integerRouter::get("/products/{id:int}", function ($request, $response) { $id = $request->params["id"]; // integer, e.g. 42 return $response->json([ "product_id" => $id, "type" => gettype($id) ]);});โ// Float parameter -- decimal numbers, auto-cast to floatRouter::get("/products/{id:int}/price/{price:float}", function ($request, $response) { $id = $request->params["id"]; $price = $request->params["price"]; return $response->json([ "product_id" => $id, "price" => $price, "type" => gettype($price) ]);});โ// Path parameter -- catch-all, captures remaining segments as a stringRouter::get("/files/{filepath:path}", function ($request, $response) { $filepath = $request->params["filepath"]; // filepath could be "images/photos/cat.jpg" return $response->json([ "filepath" => $filepath, "type" => gettype($filepath) ]);});# Integer route -- matches digits, returns an integercurl http://localhost:7145/products/42{"product_id":42,"type":"integer"}# Integer route -- non-integer gives a 404curl http://localhost:7145/products/abc{"error":"Not found","path":"/products/abc","status":404}# Path catch-all -- captures everything after /files/curl http://localhost:7145/files/images/photos/cat.jpg{"filepath":"images/photos/cat.jpg","type":"string"}The :int and :float types act as both a constraint and a converter. If the URL segment does not match the expected pattern, the route is skipped entirely and Tina4 moves on to the next registered route (or returns 404 if nothing matches). The :path type is greedy -- it consumes all remaining segments, making it ideal for file paths and documentation URLs.
4. Query Parameters#
Key-value pairs after the ? in a URL. Access them through $request->params:
<?phpuse Tina4\Router;โRouter::get("/search", function ($request, $response) { $q = $request->params["q"] ?? ""; $page = (int) ($request->params["page"] ?? 1); $limit = (int) ($request->params["limit"] ?? 10);โ return $response->json([ "query" => $q, "page" => $page, "limit" => $limit, "offset" => ($page - 1) * $limit ]);});curl "http://localhost:7145/search?q=keyboard&page=2&limit=20"{"query":"keyboard","page":2,"limit":20,"offset":20}Missing query parameters do not exist in the array. Always use the null coalescing operator (??) for defaults.
5. Route Groups#
Shared prefix. No repetition.
<?phpuse Tina4\Router;โRouter::group("/api/v1", function () {โ Router::get("/users", function ($request, $response) { return $response->json(["users" => []]); });โ Router::get("/users/{id:int}", function ($request, $response) { $id = $request->params["id"]; return $response->json(["user" => ["id" => $id, "name" => "Alice"]]); });โ Router::post("/users", function ($request, $response) { return $response->json(["created" => true], 201); });โ Router::get("/products", function ($request, $response) { return $response->json(["products" => []]); });});These register as /api/v1/users, /api/v1/users/{id}, and /api/v1/products. Short paths inside the group. Tina4 prepends the prefix.
curl http://localhost:7145/api/v1/users{"users":[]}curl http://localhost:7145/api/v1/products{"products":[]}Groups nest:
<?phpuse Tina4\Router;โRouter::group("/api", function () { Router::group("/v1", function () { Router::get("/status", function ($request, $response) { return $response->json(["version" => "1.0"]); }); });โ Router::group("/v2", function () { Router::get("/status", function ($request, $response) { return $response->json(["version" => "2.0"]); }); });});curl http://localhost:7145/api/v1/status{"version":"1.0"}curl http://localhost:7145/api/v2/status{"version":"2.0"}6. Middleware#
Code that runs before or after your handler. Authentication, logging, rate limiting, input validation -- anything that should apply to multiple routes without polluting each handler.
Middleware on a Single Route#
Pass the middleware name as the third argument:
<?phpuse Tina4\Router;โfunction logRequest($request, $response, $next) { $start = microtime(true); error_log("[" . date("Y-m-d H:i:s") . "] " . $request->method . " " . $request->path);โ $result = $next($request, $response);โ $duration = round((microtime(true) - $start) * 1000, 2); error_log(" Completed in " . $duration . "ms");โ return $result;}โRouter::get("/api/data", function ($request, $response) { return $response->json(["data" => [1, 2, 3]]);}, "logRequest");The middleware receives $request, $response, and $next. Call $next($request, $response) to continue to the handler. Skip the call and the handler never runs -- the chain stops cold.
Blocking Middleware#
A gate that checks for an API key:
<?phpuse Tina4\Router;โfunction requireApiKey($request, $response, $next) { $apiKey = $request->headers["X-API-Key"] ?? "";โ if ($apiKey !== "my-secret-key") { return $response->json(["error" => "Invalid API key"], 401); }โ return $next($request, $response);}โRouter::get("/api/secret", function ($request, $response) { return $response->json(["secret" => "The answer is 42"]);}, "requireApiKey");curl http://localhost:7145/api/secret{"error":"Invalid API key"}Status: 401 Unauthorized.
curl http://localhost:7145/api/secret -H "X-API-Key: my-secret-key"{"secret":"The answer is 42"}Middleware on a Group#
Third argument to Router::group(). Every route inside inherits it.
<?phpuse Tina4\Router;โfunction requireAuth($request, $response, $next) { $token = $request->headers["Authorization"] ?? "";โ if (empty($token)) { return $response->json(["error" => "Authentication required"], 401); }โ return $next($request, $response);}โRouter::group("/api/admin", function () {โ Router::get("/dashboard", function ($request, $response) { return $response->json(["page" => "admin dashboard"]); });โ Router::get("/users", function ($request, $response) { return $response->json(["page" => "user management"]); });โ}, "requireAuth");No per-route repetition. The group handles it.
Multiple Middleware#
Pass an array. They run in order.
Router::get("/api/important", function ($request, $response) { return $response->json(["data" => "important stuff"]);}, ["logRequest", "requireApiKey", "requireAuth"]);logRequest first, then requireApiKey, then requireAuth, then the handler. If any middleware skips $next, the chain stops there.
7. Route Decorators: @noauth and @secured#
Two annotations for controlling authentication at the route level.
@noauth -- Public Routes#
When your application has global authentication, @noauth exempts specific routes:
<?phpuse Tina4\Router;โ/** * @noauth */Router::get("/api/public/info", function ($request, $response) { return $response->json([ "app" => "My Store", "version" => "1.0.0" ]);});The @noauth decorator tells Tina4 to skip authentication for this route, even if global auth middleware is configured.
@secured -- Protected GET Routes#
@secured marks a GET route as requiring authentication:
<?phpuse Tina4\Router;โ/** * @secured */Router::get("/api/profile", function ($request, $response) { // $request->user is populated by the auth middleware return $response->json([ "user" => $request->user ]);});The convention: POST, PUT, PATCH, and DELETE routes are secured by default. GET routes are public unless you add @secured. Reading is open. Writing requires proof.
8. Route Chaining: secure() and cache()#
Routes return a chainable object. Two methods you can call on any route: secure() and cache().
secure()#
secure() requires a valid bearer token in the Authorization header. If the token is missing or invalid, the route returns 401 Unauthorized without ever reaching your handler:
Router::get("/api/account", function ($request, $response) { return $response->json(["account" => $request->user]);})->secure();curl http://localhost:7145/api/account# 401 Unauthorizedโcurl http://localhost:7145/api/account -H "Authorization: Bearer eyJhbGci..."# 200 OKThis is a lighter alternative to @secured -- it works inline without a docblock annotation.
cache()#
cache() enables response caching for the route. Once the handler runs and produces a response, subsequent requests to the same URL return the cached result without re-executing the handler:
Router::get("/api/catalog", function ($request, $response) { // Expensive database query return $response->json(["products" => $products]);})->cache();Chaining Both#
Chain secure() and cache() together on the same route:
Router::get("/api/data", function ($request, $response) { return $response->json(["data" => $data]);})->secure()->cache();This route requires a bearer token and caches the response. Order does not matter -- ->cache()->secure() produces the same result.
9. Wildcard and Catch-All Routes#
Wildcard Routes#
Use {name:path} at the end of a path to capture everything after it:
<?phpuse Tina4\Router;โRouter::get("/docs/{path:path}", function ($request, $response) { return $response->json([ "section" => "docs", "path" => $request->params["path"] ]);});curl http://localhost:7145/docs/getting-started{"section":"docs","path":"getting-started"}curl http://localhost:7145/docs/api/authentication/jwt{"section":"docs","path":"api/authentication/jwt"}Catch-All Route (Custom 404)#
Handle any unmatched URL:
<?phpuse Tina4\Router;โRouter::get("/{path:path}", function ($request, $response) { return $response->json([ "error" => "Page not found", "path" => $request->params["path"] ], 404);});Define this last. Tina4 matches routes in registration order -- first match wins. Place this in a file that sorts alphabetically after your other route files, or it will shadow everything.
You can also create a custom 404 template at src/templates/errors/404.twig:
{% extends "base.html" %} {% block title %}Not Found{% endblock %} {% block content %} <h1>404 - Page Not Found</h1> <p>The page you are looking for does not exist.</p> <a href="/">Go back home</a>{% endblock %}Tina4 uses this template for any unmatched route when the file exists.
10. Route Listing via CLI#
Your application grows. You need a map. The CLI provides one.
tina4 routesMethod Path Middleware Auth------ ---- ---------- ----GET /hello - publicGET /products - publicPOST /products - securedPUT /products/{id} - securedPATCH /products/{id} - securedDELETE /products/{id} - securedGET /api/v1/users - publicGET /api/v1/users/{id:int} - publicPOST /api/v1/users - securedGET /api/admin/dashboard requireAuth publicGET /api/admin/users requireAuth publicGET /api/public/info - @noauthGET /api/profile - @securedGET /search - publicGET /docs/* - publicFilter by method:
tina4 routes --method POSTMethod Path Middleware Auth------ ---- ---------- ----POST /products - securedPOST /api/v1/users - securedSearch for a path pattern:
tina4 routes --filter usersMethod Path Middleware Auth------ ---- ---------- ----GET /api/v1/users - publicGET /api/v1/users/{id:int} - publicPOST /api/v1/users - securedGET /api/admin/users requireAuth public11. Organizing Route Files#
Tina4 loads every .php file in src/routes/ recursively. The directory structure is yours to organize. Two common patterns:
Pattern 1: One File Per Resource#
src/routes/โโโ products.php # All product routesโโโ users.php # All user routesโโโ orders.php # All order routesโโโ pages.php # HTML page routesPattern 2: Subdirectories by Feature#
src/routes/โโโ api/โ โโโ products.phpโ โโโ users.phpโ โโโ orders.phpโโโ admin/โ โโโ dashboard.phpโ โโโ settings.phpโโโ pages/ โโโ home.php โโโ about.phpBoth work identically. The directory structure has no effect on URL paths -- only the route definitions inside the files matter. Pick whichever pattern keeps your project navigable.
12. Exercise: Build a Full CRUD API for Products#
Build a REST API for managing products. All data stored in a PHP array. No database yet -- Chapter 5 handles that.
Requirements#
Create src/routes/product-api.php with these routes:
| Method | Path | Description |
|---|---|---|
GET | /api/products | List all products. Support ?category= filter. |
GET | /api/products/{id:int} | Get a single product by ID. Return 404 if not found. |
POST | /api/products | Create a new product. Return 201. |
PUT | /api/products/{id:int} | Replace a product. Return 404 if not found. |
DELETE | /api/products/{id:int} | Delete a product. Return 204 with no body. |
Each product: id (int), name (string), category (string), price (float), in_stock (bool).
Seed data:
$products = [ ["id" => 1, "name" => "Wireless Keyboard", "category" => "Electronics", "price" => 79.99, "in_stock" => true], ["id" => 2, "name" => "Yoga Mat", "category" => "Fitness", "price" => 29.99, "in_stock" => true], ["id" => 3, "name" => "Coffee Grinder", "category" => "Kitchen", "price" => 49.99, "in_stock" => false], ["id" => 4, "name" => "Standing Desk", "category" => "Office", "price" => 549.99, "in_stock" => true], ["id" => 5, "name" => "Running Shoes", "category" => "Fitness", "price" => 119.99, "in_stock" => true]];Test with:
# List allcurl http://localhost:7145/api/productsโ# Filter by categorycurl "http://localhost:7145/api/products?category=Fitness"โ# Get onecurl http://localhost:7145/api/products/3โ# Createcurl -X POST http://localhost:7145/api/products \ -H "Content-Type: application/json" \ -d '{"name": "Desk Lamp", "category": "Office", "price": 39.99, "in_stock": true}'โ# Updatecurl -X PUT http://localhost:7145/api/products/3 \ -H "Content-Type: application/json" \ -d '{"name": "Burr Coffee Grinder", "category": "Kitchen", "price": 59.99, "in_stock": true}'โ# Deletecurl -X DELETE http://localhost:7145/api/products/3โ# Not foundcurl http://localhost:7145/api/products/99913. Solution#
Create src/routes/product-api.php:
<?phpuse Tina4\Router;โ// In-memory product store (resets on server restart)$products = [ ["id" => 1, "name" => "Wireless Keyboard", "category" => "Electronics", "price" => 79.99, "in_stock" => true], ["id" => 2, "name" => "Yoga Mat", "category" => "Fitness", "price" => 29.99, "in_stock" => true], ["id" => 3, "name" => "Coffee Grinder", "category" => "Kitchen", "price" => 49.99, "in_stock" => false], ["id" => 4, "name" => "Standing Desk", "category" => "Office", "price" => 549.99, "in_stock" => true], ["id" => 5, "name" => "Running Shoes", "category" => "Fitness", "price" => 119.99, "in_stock" => true]];โ$nextId = 6;โ// List all products, optionally filter by categoryRouter::get("/api/products", function ($request, $response) use (&$products) { $category = $request->params["category"] ?? null;โ if ($category !== null) { $filtered = array_values(array_filter( $products, fn($p) => strtolower($p["category"]) === strtolower($category) )); return $response->json(["products" => $filtered, "count" => count($filtered)]); }โ return $response->json(["products" => $products, "count" => count($products)]);});โ// Get a single product by IDRouter::get("/api/products/{id:int}", function ($request, $response) use (&$products) { $id = $request->params["id"];โ foreach ($products as $product) { if ($product["id"] === $id) { return $response->json($product); } }โ return $response->json(["error" => "Product not found", "id" => $id], 404);});โ// Create a new productRouter::post("/api/products", function ($request, $response) use (&$products, &$nextId) { $body = $request->body;โ if (empty($body["name"])) { return $response->json(["error" => "Name is required"], 400); }โ $product = [ "id" => $nextId++, "name" => $body["name"], "category" => $body["category"] ?? "Uncategorized", "price" => (float) ($body["price"] ?? 0), "in_stock" => (bool) ($body["in_stock"] ?? true) ];โ $products[] = $product;โ return $response->json($product, 201);});โ// Replace a productRouter::put("/api/products/{id:int}", function ($request, $response) use (&$products) { $id = $request->params["id"]; $body = $request->body;โ foreach ($products as $index => $product) { if ($product["id"] === $id) { $products[$index] = [ "id" => $id, "name" => $body["name"] ?? $product["name"], "category" => $body["category"] ?? $product["category"], "price" => (float) ($body["price"] ?? $product["price"]), "in_stock" => (bool) ($body["in_stock"] ?? $product["in_stock"]) ]; return $response->json($products[$index]); } }โ return $response->json(["error" => "Product not found", "id" => $id], 404);});โ// Delete a productRouter::delete("/api/products/{id:int}", function ($request, $response) use (&$products) { $id = $request->params["id"];โ foreach ($products as $index => $product) { if ($product["id"] === $id) { array_splice($products, $index, 1); return $response->json(null, 204); } }โ return $response->json(["error" => "Product not found", "id" => $id], 404);});Expected output for the test commands:
List all:
{"products":[{"id":1,"name":"Wireless Keyboard","category":"Electronics","price":79.99,"in_stock":true},{"id":2,"name":"Yoga Mat","category":"Fitness","price":29.99,"in_stock":true},{"id":3,"name":"Coffee Grinder","category":"Kitchen","price":49.99,"in_stock":false},{"id":4,"name":"Standing Desk","category":"Office","price":549.99,"in_stock":true},{"id":5,"name":"Running Shoes","category":"Fitness","price":119.99,"in_stock":true}],"count":5}Filter by category:
{"products":[{"id":2,"name":"Yoga Mat","category":"Fitness","price":29.99,"in_stock":true},{"id":5,"name":"Running Shoes","category":"Fitness","price":119.99,"in_stock":true}],"count":2}Get one:
{"id":3,"name":"Coffee Grinder","category":"Kitchen","price":49.99,"in_stock":false}Create:
{"id":6,"name":"Desk Lamp","category":"Office","price":39.99,"in_stock":true}(Status: 201 Created)
Update:
{"id":3,"name":"Burr Coffee Grinder","category":"Kitchen","price":59.99,"in_stock":true}Delete: empty response with status 204 No Content.
Not found:
{"error":"Product not found","id":999}(Status: 404 Not Found)
14. Gotchas#
1. Trailing Slashes Are Normalised#
Tina4 strips trailing slashes automatically. Both /products and /products/ match the same route. You do not need to register both or configure anything, it just works.
2. Parameter Names Must Be Unique in a Path#
Problem: /users/{id}/posts/{id} produces wrong results -- the second {id} overwrites the first.
Fix: Use distinct names: /users/{userId}/posts/{postId}.
3. Method Conflicts#
Problem: Router::get("/items/{id}", ...) and Router::get("/items/{action}", ...) collide. The wrong handler runs.
Cause: Both patterns match /items/42. First registration wins.
Fix: Use typed parameters to disambiguate: Router::get("/items/{id:int}", ...) matches only integers, leaving /items/export free. Or restructure: /items/{id:int} and /items/actions/{action}.
4. Route Handler Must Return a Response#
Problem: The handler runs but the browser shows empty or 500.
Cause: No return statement. Without return, the handler returns null. Tina4 has nothing to send.
Fix: Every handler must return something. return $response->json(...), return $response->html(...), return $response->render(...).
5. Route Files Must Start with <?php#
Problem: Route file is ignored. No errors. No routes registered.
Cause: Missing PHP opening tag. Without <?php, the file is not parsed.
Fix: First line of every route file: <?php.
6. Middleware Uses Class-Based Pattern#
Problem: Passing a function name string as middleware doesn't work.
Fix: Use class-based middleware with before*/after* static methods. See Chapter 10 for the full middleware pattern:
class AuthMiddleware { public static function beforeAuth($request, $response) { if (!$request->headers['authorization']) { return [$request, $response->json(["error" => "Unauthorized"], 401)]; } return [$request, $response]; }}Router::use(AuthMiddleware::class);7. Group Prefix Normalisation#
Tina4 auto-normalises group prefixes, it prepends / if missing and strips trailing slashes. Router::group("api/v1", ...) and Router::group("/api/v1", ...) both work. However, for clarity, always start with /.