Tina4

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:

php
<?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:

json
{"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.

php
<?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, and DELETE are secured by default in Tina4, without ->noAuth() the examples below return 401 Unauthorized. In production leave the defaults alone and send an Authorization: Bearer <token> header on write requests. For these Getting Started examples we chain ->noAuth() so curl works without setting up auth first.

Test each one:

bash
curl http://localhost:7145/products
json
{"action":"list all products"}
bash
curl -X POST http://localhost:7145/products \  -H "Content-Type: application/json" \  -d '{"name": "Widget"}'
json
{"action":"create a product"}
bash
curl -X PUT http://localhost:7145/products/42
json
{"action":"replace product 42"}
bash
curl -X PATCH http://localhost:7145/products/42
json
{"action":"update product 42"}
bash
curl -X DELETE http://localhost:7145/products/42
json
{"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:

  • HEAD is identical to GET except the response carries no body (RFC 9110 ยง9.3.2). Every GET route automatically responds to HEAD, the framework strips the response body on the way out and preserves Content-Length pointing at the byte count the equivalent GET would have sent. Cache validators, link checkers, and uptime probes work without you writing anything.
  • OPTIONS on any registered path returns 204 No Content with an Allow: header listing every method the path supports (RFC 9110 ยง9.3.7).
  • Wrong method on an existing path returns 405 Method Not Allowed with the same Allow: header (RFC 9110 ยง15.5.6 + ยง10.2.1). Sending PUT to a GET-only route used to return 404, semantically wrong and confusing for link checkers. Now you get a real 405.
  • TRACE and CONNECT are rejected with 405 (security default for origin servers).
bash
# 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, OPTIONS

Explicit 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:

php
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.

php
<?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    ]);});
bash
curl http://localhost:7145/users/5/posts/99
json
{"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/99 will give you $request->params["id"] as the integer 42 and $request->params["postId"] as the integer 99 -- no explicit :int type hint required. The :int type 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:

php
<?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)    ]);});
bash
curl http://localhost:7145/orders/42
json
{"order_id":42,"type":"integer"}

Pass a non-integer and the route does not match. A 404 comes back instead:

bash
curl http://localhost:7145/orders/abc
json
{"error":"Not found","path":"/orders/abc","status":404}

Supported types:

TypeMatchesAuto-castExample
intDigits onlyInteger{id:int} matches 42 but not abc
floatDecimal numbersFloat{price:float} matches 19.99
pathAll remaining path segments (catch-all)String{slug:path} matches docs/api/auth
alphaLetters onlyString{slug:alpha} matches hello but not hello123
alphanumericLetters and digitsString{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:

php
<?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)    ]);});
bash
# Integer route -- matches digits, returns an integercurl http://localhost:7145/products/42
json
{"product_id":42,"type":"integer"}
bash
# Integer route -- non-integer gives a 404curl http://localhost:7145/products/abc
json
{"error":"Not found","path":"/products/abc","status":404}
bash
# Path catch-all -- captures everything after /files/curl http://localhost:7145/files/images/photos/cat.jpg
json
{"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:

php
<?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    ]);});
bash
curl "http://localhost:7145/search?q=keyboard&page=2&limit=20"
json
{"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.

php
<?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.

bash
curl http://localhost:7145/api/v1/users
json
{"users":[]}
bash
curl http://localhost:7145/api/v1/products
json
{"products":[]}

Groups nest:

php
<?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"]);        });    });});
bash
curl http://localhost:7145/api/v1/status
json
{"version":"1.0"}
bash
curl http://localhost:7145/api/v2/status
json
{"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:

php
<?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:

php
<?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");
bash
curl http://localhost:7145/api/secret
json
{"error":"Invalid API key"}

Status: 401 Unauthorized.

bash
curl http://localhost:7145/api/secret -H "X-API-Key: my-secret-key"
json
{"secret":"The answer is 42"}

Middleware on a Group#

Third argument to Router::group(). Every route inside inherits it.

php
<?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.

php
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:

php
<?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:

php
<?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:

php
Router::get("/api/account", function ($request, $response) {    return $response->json(["account" => $request->user]);})->secure();
bash
curl http://localhost:7145/api/account# 401 Unauthorizedโ€‹curl http://localhost:7145/api/account -H "Authorization: Bearer eyJhbGci..."# 200 OK

This 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:

php
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:

php
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:

php
<?phpuse Tina4\Router;โ€‹Router::get("/docs/{path:path}", function ($request, $response) {    return $response->json([        "section" => "docs",        "path" => $request->params["path"]    ]);});
bash
curl http://localhost:7145/docs/getting-started
json
{"section":"docs","path":"getting-started"}
bash
curl http://localhost:7145/docs/api/authentication/jwt
json
{"section":"docs","path":"api/authentication/jwt"}

Catch-All Route (Custom 404)#

Handle any unmatched URL:

php
<?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:

html
{% 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.

bash
tina4 routes
Method   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/*                       -                   public

Filter by method:

bash
tina4 routes --method POST
Method   Path                          Middleware          Auth------   ----                          ----------          ----POST     /products                     -                   securedPOST     /api/v1/users                 -                   secured

Search for a path pattern:

bash
tina4 routes --filter users
Method   Path                          Middleware          Auth------   ----                          ----------          ----GET      /api/v1/users                 -                   publicGET      /api/v1/users/{id:int}        -                   publicPOST     /api/v1/users                 -                   securedGET      /api/admin/users              requireAuth         public

11. 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 routes

Pattern 2: Subdirectories by Feature#

src/routes/โ”œโ”€โ”€ api/โ”‚   โ”œโ”€โ”€ products.phpโ”‚   โ”œโ”€โ”€ users.phpโ”‚   โ””โ”€โ”€ orders.phpโ”œโ”€โ”€ admin/โ”‚   โ”œโ”€โ”€ dashboard.phpโ”‚   โ””โ”€โ”€ settings.phpโ””โ”€โ”€ pages/    โ”œโ”€โ”€ home.php    โ””โ”€โ”€ about.php

Both 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:

MethodPathDescription
GET/api/productsList all products. Support ?category= filter.
GET/api/products/{id:int}Get a single product by ID. Return 404 if not found.
POST/api/productsCreate 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:

php
$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:

bash
# 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/999

13. Solution#

Create src/routes/product-api.php:

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:

json
{"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:

json
{"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:

json
{"id":3,"name":"Coffee Grinder","category":"Kitchen","price":49.99,"in_stock":false}

Create:

json
{"id":6,"name":"Desk Lamp","category":"Office","price":39.99,"in_stock":true}

(Status: 201 Created)

Update:

json
{"id":3,"name":"Burr Coffee Grinder","category":"Kitchen","price":59.99,"in_stock":true}

Delete: empty response with status 204 No Content.

Not found:

json
{"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:

php
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 /.