Chapter 3: Request & Response#
1. The Two Objects You Always Get#
Every route handler receives two arguments. $request carries what the client sent. $response builds what goes back. These two objects are the entire HTTP conversation.
<?phpuse Tina4\Router;โRouter::get("/echo", function ($request, $response) { return $response->json([ "method" => $request->method, "path" => $request->path, "your_ip" => $request->ip ]);});curl http://localhost:7145/echo{"method":"GET","path":"/echo","your_ip":"127.0.0.1"}The pattern never changes. Read the request. Build the response. Return it.
2. The Request Object#
The $request object holds everything the client sent. Here is the full inventory.
method#
The HTTP method as an uppercase string: "GET", "POST", "PUT", "PATCH", or "DELETE".
$request->method // "GET"path#
The URL path, stripped of query parameters:
// Request to /api/users?page=2$request->path // "/api/users"params#
Path parameters captured from the URL pattern (see Chapter 2):
// Route: /users/{id}/posts/{postId}// Request: /users/5/posts/42$request->params["id"] // "5" (or 5 if typed as {id:int})$request->params["postId"] // "42"query#
Query string parameters. An associative array:
// Request: /search?q=keyboard&page=2&sort=price$request->query["q"] // "keyboard"$request->query["page"] // "2"$request->query["sort"] // "price"The queryParam() helper provides a default value when a key is missing:
$request->queryParam("q") // "keyboard"$request->queryParam("missing", "N/A") // "N/A"body#
The parsed request body. JSON requests become associative arrays. Form submissions contain form fields:
// POST with {"name": "Widget", "price": 9.99}$request->body["name"] // "Widget"$request->body["price"] // 9.99headers#
Request headers as an associative array. Keys are normalised to lowercase:
$request->headers["content-type"] // "application/json"$request->headers["authorization"] // "Bearer eyJhbGci..."$request->headers["x-custom"] // "my-value"The header() helper method is the preferred way to read a single header. It accepts any casing and handles the lowercase lookup for you:
$request->header("Content-Type") // "application/json"$request->header("Authorization") // "Bearer eyJhbGci..."$request->header("X-Custom") // "my-value"$request->header("Missing") // nullip#
The client's IP address:
$request->ip // "127.0.0.1"Tina4 respects X-Forwarded-For and X-Real-IP headers behind a reverse proxy.
cookies#
Cookies the client sent:
$request->cookies["session_id"] // "abc123"$request->cookies["preferences"] // "dark-mode"files#
Uploaded files (section 7 covers this in detail):
$request->files["avatar"] // ["filename", "type", "size", "content"] - content is raw bytesInspecting the Full Request#
A diagnostic route that dumps everything:
<?phpuse Tina4\Router;โRouter::post("/debug/request", function ($request, $response) { return $response->json([ "method" => $request->method, "path" => $request->path, "params" => $request->params, "query" => $request->query, "body" => $request->body, "headers" => $request->headers, "ip" => $request->ip, "cookies" => $request->cookies ]);});curl -X POST "http://localhost:7145/debug/request?page=1" \ -H "Content-Type: application/json" \ -H "X-Custom: hello" \ -d '{"name": "test"}'{ "method": "POST", "path": "/debug/request", "params": {}, "query": {"page": "1"}, "body": {"name": "test"}, "headers": { "content-type": "application/json", "x-custom": "hello", "host": "localhost:7145", "user-agent": "curl/8.4.0", "accept": "*/*", "content-length": "16" }, "ip": "127.0.0.1", "cookies": {}}3. The Response Object#
The $response object shapes what travels back to the client. Every method returns the response object, so calls chain.
json() -- JSON Response#
The API workhorse. Pass an array. It becomes JSON:
return $response->json(["name" => "Alice", "age" => 30]);{"name":"Alice","age":30}Second argument sets the status code:
return $response->json(["id" => 7, "name" => "Widget"], 201);201 Created with the JSON body.
html() -- Raw HTML Response#
Return raw HTML:
return $response->html("<h1>Hello</h1><p>This is HTML.</p>");Sets Content-Type: text/html; charset=utf-8.
text() -- Plain Text Response#
return $response->text("Just a plain string.");Sets Content-Type: text/plain; charset=utf-8.
render() -- Template Response#
Render a Frond template with data (Chapter 4 goes deep):
return $response->render("products.html", [ "products" => $products, "title" => "Our Products"]);Tina4 finds the template in src/templates/, renders it, returns the HTML.
redirect() -- Redirect Response#
Send the client somewhere else:
return $response->redirect("/login");302 Found by default. For permanent redirects:
return $response->redirect("/new-location", 301);file() -- File Download Response#
Push a file to the client:
return $response->file("/path/to/report.pdf");Tina4 sets the correct Content-Type from the file extension and adds Content-Disposition so the browser downloads instead of displaying.
Custom filename:
return $response->file("/path/to/report.pdf", "monthly-report-march-2026.pdf");4. Status Codes#
Every response method accepts a status code. Here are the ones you reach for most:
| Code | Meaning | When to Use |
|---|---|---|
200 | OK | Default. Successful GET, PUT, PATCH. |
201 | Created | Successful POST that created a resource. |
204 | No Content | Successful DELETE. No body needed. |
301 | Moved Permanently | URL has permanently changed. |
302 | Found | Temporary redirect. |
400 | Bad Request | Invalid input from the client. |
401 | Unauthorized | Missing or invalid authentication. |
403 | Forbidden | Authenticated but not allowed. |
404 | Not Found | Resource does not exist. |
409 | Conflict | Duplicate or conflicting data. |
413 | Payload Too Large | Request body or file exceeds the size limit. |
422 | Unprocessable Entity | Valid JSON but fails business rules. |
500 | Internal Server Error | Something broke on the server. |
You can also chain status() explicitly:
return $response->status(201)->json(["id" => 7, "created" => true]);Same result as $response->json(["id" => 7, "created" => true], 201). Pick whichever reads cleaner in context.
HTTP Status Constants#
Tina4 exports named constants for every status code. Use them instead of raw integers -- the intent reads at a glance and you avoid typos like 419 when you meant 401:
use Tina4\Router;โRouter::get("/api/widgets/{id}", function ($request, $response) { $widget = Widget::load(["id" => $request->params["id"]]);โ if (!$widget->exists()) { return $response->json(["error" => "Widget not found"], \Tina4\HTTP_NOT_FOUND); }โ return $response->json($widget->asArray(), \Tina4\HTTP_OK);});Available constants (all in the \Tina4 namespace):
| Status | Constant | Status | Constant |
|---|---|---|---|
200 | HTTP_OK | 404 | HTTP_NOT_FOUND |
201 | HTTP_CREATED | 405 | HTTP_METHOD_NOT_ALLOWED |
202 | HTTP_ACCEPTED | 409 | HTTP_CONFLICT |
204 | HTTP_NO_CONTENT | 410 | HTTP_GONE |
301 | HTTP_MOVED_PERMANENTLY | 422 | HTTP_UNPROCESSABLE |
302 | HTTP_FOUND | 429 | HTTP_TOO_MANY |
304 | HTTP_NOT_MODIFIED | 500 | HTTP_INTERNAL_SERVER_ERROR |
400 | HTTP_BAD_REQUEST | 502 | HTTP_BAD_GATEWAY |
401 | HTTP_UNAUTHORIZED | 503 | HTTP_SERVICE_UNAVAILABLE |
403 | HTTP_FORBIDDEN | 504 | HTTP_GATEWAY_TIMEOUT |
Content-Type Constants#
For non-JSON responses, pair a status constant with a content-type constant. Tina4 also supports a three-argument response form that sets body, status, and content type in one call:
use Tina4\Router;โRouter::get("/api/legacy-feed", function ($request, $response) { $xml = "<feed><item>...</item></feed>"; return $response($xml, \Tina4\HTTP_OK, \Tina4\APPLICATION_XML);})->noAuth();Content-type constants:
| Constant | Value |
|---|---|
APPLICATION_JSON | application/json |
APPLICATION_XML | application/xml |
APPLICATION_FORM | application/x-www-form-urlencoded |
APPLICATION_OCTET_STREAM | application/octet-stream |
TEXT_HTML | text/html |
TEXT_PLAIN | text/plain |
TEXT_CSV | text/csv |
TEXT_XML | text/xml |
Using constants also makes your code grep-friendly -- searching for HTTP_BAD_REQUEST across the codebase finds every validation branch, where searching for 400 finds false positives (file sizes, timeouts, anything with the number 400 in it).
5. Custom Headers#
Set response headers with header():
Router::get("/api/data", function ($request, $response) { return $response ->header("X-Request-Id", uniqid()) ->header("X-Rate-Limit-Remaining", "57") ->header("Cache-Control", "no-cache") ->json(["data" => [1, 2, 3]]);});curl -v http://localhost:7145/api/data 2>&1 | grep "< X-"< X-Request-Id: 65f3a7b8c1234< X-Rate-Limit-Remaining: 57Convention: Title-Case for custom headers. Prefix with X-.
CORS Headers#
Tina4 handles CORS from CORS_ORIGINS in .env. The default * allows all origins. Lock it down for production:
CORS_ORIGINS=https://myapp.com,https://admin.myapp.comManual CORS headers are rarely needed. They are available when you need them:
return $response ->header("Access-Control-Allow-Origin", "https://myapp.com") ->header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE") ->header("Access-Control-Allow-Headers", "Content-Type, Authorization") ->json(["data" => "value"]);6. Cookies#
Set cookies on the response:
Router::post("/login", function ($request, $response) { // After validating credentials... return $response ->cookie("session_id", "abc123xyz", [ "httpOnly" => true, "secure" => true, "sameSite" => "Strict", "maxAge" => 3600, // 1 hour in seconds "path" => "/" ]) ->json(["message" => "Logged in"]);});Cookie options:
| Option | Type | Default | Description |
|---|---|---|---|
httpOnly | bool | false | Invisible to JavaScript |
secure | bool | false | HTTPS only |
sameSite | string | "Lax" | "Strict", "Lax", or "None" |
maxAge | int | session | Lifetime in seconds |
path | string | "/" | URL path scope |
domain | string | current | Domain scope |
Read cookies from the request:
Router::get("/profile", function ($request, $response) { $sessionId = $request->cookies["session_id"] ?? null;โ if ($sessionId === null) { return $response->json(["error" => "Not logged in"], 401); }โ return $response->json(["session" => $sessionId]);});Delete a cookie by setting maxAge to 0:
return $response ->cookie("session_id", "", ["maxAge" => 0, "path" => "/"]) ->json(["message" => "Logged out"]);7. File Uploads#
Uploaded files land in $request->files as an associative array keyed by field name. Each entry has the file metadata plus the raw bytes, Tina4 does not write a temporary file to disk, so there is no tmp_path.
$file = $request->files["avatar"];// [// "fieldName" => "avatar",// "filename" => "photo.png",// "type" => "image/png",// "content" => "<raw bytes>", // binary, NOT base64// "size" => 102400,// ]Handling a Single File Upload#
<?phpuse Tina4\Router;โRouter::post("/api/upload", function ($request, $response) { if (empty($request->files["image"])) { return $response->json(["error" => "No file uploaded"], 400); }โ $file = $request->files["image"];โ return $response->json([ "name" => $file["filename"], // "photo.jpg" "type" => $file["type"], // "image/jpeg" "size" => $file["size"], // 245760 (bytes) ]);});curl -X POST http://localhost:7145/api/upload \ -F "image=@/path/to/photo.jpg"{ "name": "photo.jpg", "type": "image/jpeg", "size": 245760}Saving the Uploaded File#
The file sits in a temporary location. Move it to permanent storage:
<?phpuse Tina4\Router;โRouter::post("/api/upload", function ($request, $response) { if (empty($request->files["image"])) { return $response->json(["error" => "No file uploaded"], 400); }โ $file = $request->files["image"];โ // Validate file type $allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"]; if (!in_array($file["type"], $allowedTypes)) { return $response->json(["error" => "Invalid file type. Allowed: JPEG, PNG, GIF, WebP"], 400); }โ // Validate file size (max 5MB) $maxSize = 5 * 1024 * 1024; if ($file["size"] > $maxSize) { return $response->json(["error" => "File too large. Maximum size: 5MB"], 400); }โ // Generate a unique filename $extension = pathinfo($file["filename"], PATHINFO_EXTENSION); $filename = uniqid("img_") . "." . $extension; $destination = __DIR__ . "/../../public/uploads/" . $filename;โ // Ensure the uploads directory exists if (!is_dir(dirname($destination))) { mkdir(dirname($destination), 0755, true); }โ // Save the uploaded bytes - Tina4 stores them in $file["content"], not a temp file file_put_contents($destination, $file["content"]);โ return $response->json([ "message" => "File uploaded successfully", "filename" => $filename, "url" => "/uploads/" . $filename, "size" => $file["size"] ], 201);});curl -X POST http://localhost:7145/api/upload \ -F "image=@/path/to/photo.jpg"{ "message": "File uploaded successfully", "filename": "img_65f3a7b8c1234.jpg", "url": "/uploads/img_65f3a7b8c1234.jpg", "size": 245760}The file is now available at http://localhost:7145/uploads/img_65f3a7b8c1234.jpg.
Handling Multiple Files#
When the form uses multiple or has multiple file inputs:
Router::post("/api/upload-many", function ($request, $response) { $results = [];โ foreach ($request->files as $key => $file) { $extension = pathinfo($file["filename"], PATHINFO_EXTENSION); $filename = uniqid("file_") . "." . $extension; $destination = __DIR__ . "/../../public/uploads/" . $filename;โ if (!is_dir(dirname($destination))) { mkdir(dirname($destination), 0755, true); }โ file_put_contents($destination, $file["content"]);โ $results[] = [ "original_name" => $file["filename"], "saved_as" => $filename, "url" => "/uploads/" . $filename ]; }โ return $response->json(["uploaded" => $results, "count" => count($results)], 201);});8. File Downloads#
Send files to the client with $response->file():
<?phpuse Tina4\Router;โRouter::get("/api/reports/{filename}", function ($request, $response) { $filename = $request->params["filename"]; $filepath = __DIR__ . "/../../data/reports/" . $filename;โ if (!file_exists($filepath)) { return $response->json(["error" => "Report not found"], 404); }โ return $response->file($filepath);});The browser downloads the file. Tina4 detects the MIME type from the extension and sets headers accordingly.
Force a specific download filename:
return $response->file($filepath, "Q1-2026-Sales-Report.pdf");9. Content Negotiation#
One endpoint serves multiple formats. The Accept header decides which one:
<?phpuse Tina4\Router;โRouter::get("/api/products/{id:int}", function ($request, $response) { $id = $request->params["id"]; $product = [ "id" => $id, "name" => "Wireless Keyboard", "price" => 79.99 ];โ $accept = $request->header("Accept") ?? "application/json";โ if (strpos($accept, "text/html") !== false) { return $response->render("product-detail.html", ["product" => $product]); }โ if (strpos($accept, "text/plain") !== false) { $text = "Product #" . $id . ": " . $product["name"] . " - $" . $product["price"]; return $response->text($text); }โ // Default: JSON return $response->json($product);});# JSON (default)curl http://localhost:7145/api/products/1{"id":1,"name":"Wireless Keyboard","price":79.99}# Plain textcurl http://localhost:7145/api/products/1 -H "Accept: text/plain"Product #1: Wireless Keyboard - $79.99# HTML (renders the template)curl http://localhost:7145/api/products/1 -H "Accept: text/html"<!DOCTYPE html><html>...rendered template...</html>10. Input Validation#
Tina4 ships a Validator class for declarative input validation. Chain rules. Check the result. When validation fails, $response->error() returns a structured error envelope.
The Validator Class#
use Tina4\Validator;โRouter::post("/api/users", function ($request, $response) { $v = new Validator($request->body); $v->required("name")->required("email")->email("email")->minLength("name", 2);โ if (!$v->isValid()) { return $response->error("VALIDATION_FAILED", $v->errors()[0]["message"], 400); }โ // proceed with valid data});The Validator takes the request body (an associative array) and exposes chainable methods:
| Method | Description |
|---|---|
required(field) | Field must be present and non-empty |
email(field) | Field must be a valid email address |
minLength(field, n) | Field must have at least n characters |
maxLength(field, n) | Field must have at most n characters |
numeric(field) | Field must be a number |
inList(field, values) | Field must be one of the allowed values |
Call $v->isValid() to check all rules. Call $v->errors() to get the list of failures, each with a field and message key.
The Error Response Envelope#
$response->error() returns a consistent JSON error envelope:
return $response->error("VALIDATION_FAILED", "Name is required", 400);This produces:
{"error": true, "code": "VALIDATION_FAILED", "message": "Name is required", "status": 400}Three arguments: an error code string, a human-readable message, and the HTTP status code. Use this pattern across your API. Consistent error shapes save frontend developers hours of guesswork.
Upload Size Limits#
Tina4 enforces a maximum upload size through the TINA4_MAX_UPLOAD_SIZE environment variable. The value is in bytes. Default: 10485760 (10 MB).
TINA4_MAX_UPLOAD_SIZE=10485760A file exceeding this limit triggers a 413 Payload Too Large response before your handler runs. To allow larger uploads, increase the value in .env:
TINA4_MAX_UPLOAD_SIZE=52428800This sets the limit to 50 MB.
11. Exercise: Build an Image Upload API#
Two endpoints. One accepts images. The other serves them back.
Requirements#
| Method | Path | Description |
|---|---|---|
POST | /api/images | Upload an image. Validate type and size. Return the image URL. |
GET | /api/images/{filename} | Return the uploaded image file. 404 if not found. |
Rules:
- Accept JPEG, PNG, and WebP only
- Maximum file size: 2MB
- Save to
src/public/uploads/with a unique filename - Return original filename, saved filename, file size in KB, and URL
- The GET endpoint serves the raw file, not JSON
Test with:#
# Uploadcurl -X POST http://localhost:7145/api/images \ -F "image=@/path/to/photo.jpg"โ# Downloadcurl http://localhost:7145/api/images/img_65f3a7b8c1234.jpg --output downloaded.jpg12. Solution#
Create src/routes/images.php:
<?phpuse Tina4\Router;โRouter::post("/api/images", function ($request, $response) { // Check if a file was uploaded if (empty($request->files["image"])) { return $response->json(["error" => "No image file provided. Use field name 'image'."], 400); }โ $file = $request->files["image"];โ // Validate file type $allowedTypes = ["image/jpeg", "image/png", "image/webp"]; if (!in_array($file["type"], $allowedTypes)) { return $response->json([ "error" => "Invalid file type", "received" => $file["type"], "allowed" => $allowedTypes ], 400); }โ // Validate file size (max 2MB) $maxSize = 2 * 1024 * 1024; if ($file["size"] > $maxSize) { return $response->json([ "error" => "File too large", "size_bytes" => $file["size"], "max_bytes" => $maxSize ], 400); }โ // Generate unique filename preserving extension $extension = pathinfo($file["filename"], PATHINFO_EXTENSION); $savedName = uniqid("img_") . "." . strtolower($extension); $uploadDir = __DIR__ . "/../../public/uploads"; $destination = $uploadDir . "/" . $savedName;โ // Create uploads directory if it does not exist if (!is_dir($uploadDir)) { mkdir($uploadDir, 0755, true); }โ // Save the uploaded bytes - Tina4 stores them in $file["content"], not a temp file file_put_contents($destination, $file["content"]);โ return $response->json([ "message" => "Image uploaded successfully", "original_name" => $file["filename"], "saved_name" => $savedName, "size_kb" => round($file["size"] / 1024, 1), "type" => $file["type"], "url" => "/uploads/" . $savedName ], 201);});โRouter::get("/api/images/{filename}", function ($request, $response) { $filename = $request->params["filename"];โ // Prevent directory traversal if (strpos($filename, "..") !== false || strpos($filename, "/") !== false) { return $response->json(["error" => "Invalid filename"], 400); }โ $filepath = __DIR__ . "/../../public/uploads/" . $filename;โ if (!file_exists($filepath)) { return $response->json(["error" => "Image not found", "filename" => $filename], 404); }โ return $response->file($filepath);});Expected output for upload:
{ "message": "Image uploaded successfully", "original_name": "photo.jpg", "saved_name": "img_65f3a7b8c1234.jpg", "size_kb": 240.0, "type": "image/jpeg", "url": "/uploads/img_65f3a7b8c1234.jpg"}(Status: 201 Created)
Expected output for invalid type:
{ "error": "Invalid file type", "received": "application/pdf", "allowed": ["image/jpeg", "image/png", "image/webp"]}(Status: 400 Bad Request)
Expected output for file too large:
{ "error": "File too large", "size_bytes": 5242880, "max_bytes": 2097152}(Status: 400 Bad Request)
The GET endpoint returns the raw image file with the correct Content-Type header. The browser renders it. Curl with --output saves it to disk.
13. Gotchas#
1. Forgetting return#
Problem: The handler runs (log output confirms it) but the browser gets an empty response or 500.
Cause: $response->json([...]) without return. The response object builds the reply but nobody sends it.
Fix: return $response->json([...]). Always.
2. Body Is Null for JSON Requests#
Problem: $request->body is null or empty despite sending JSON.
Cause: Missing Content-Type: application/json header. Without it, Tina4 does not parse the body as JSON.
Fix: Include -H "Content-Type: application/json" with curl. In JavaScript fetch(), set headers: {"Content-Type": "application/json"}.
3. Content-Type Mismatch#
Problem: $response->json() returns HTML, or $response->html() returns plain text.
Cause: A middleware or error handler overwrites the response. Or you returned a string instead of using a response method.
Fix: Use $response->json(...), $response->html(...), or another response method. Never echo -- it bypasses the response object.
4. File Uploads Return Empty#
Problem: $request->files is empty despite uploading a file.
Cause: The form lacks enctype="multipart/form-data", or curl uses -d instead of -F.
Fix: HTML forms: <form enctype="multipart/form-data">. Curl: -F "field=@file.jpg" (with @), not -d.
5. Redirect Loops#
Problem: The browser shows "too many redirects."
Cause: Route A redirects to Route B. Route B redirects back to Route A. Common with login guards: /login redirects to /dashboard, /dashboard redirects to /login because the user is not authenticated.
Fix: Trace the redirect chain in your browser's network inspector. Make sure the auth check does not redirect authenticated users away from pages they should access.
6. Cookie Not Set#
Problem: $response->cookie(...) runs but the browser shows no cookie.
Cause: secure is true. The cookie only travels over HTTPS. Local development uses http://localhost. The cookie is silently dropped.
Fix: Set "secure" => false during development. Or use "secure" => ($_ENV["TINA4_DEBUG"] ?? "false") !== "true" to auto-switch.
7. Large Request Body Rejected#
Problem: POST requests with large bodies return 413.
Cause: Request body exceeds the configured maximum.
Fix: Increase TINA4_MAX_UPLOAD_SIZE in .env. Default is 10mb. For file upload endpoints, you may need 50mb or more:
TINA4_MAX_UPLOAD_SIZE=50mb