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.
<?php
use Tina4\Router;
Router::get("/echo", function ($request, $response) {
return $response->json([
"method" => $request->method,
"path" => $request->path,
"your_ip" => $request->ip
]);
});curl http://localhost:7146/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"] // File object with name, type, size, tmpPathInspecting the Full Request
A diagnostic route that dumps everything:
<?php
use 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:7146/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:7146",
"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.
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:7146/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. Each file is an object with metadata and a temporary path.
Handling a Single File Upload
<?php
use 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->name, // "photo.jpg"
"type" => $file->type, // "image/jpeg"
"size" => $file->size, // 245760 (bytes)
"tmp_path" => $file->tmpPath // Temporary file location
]);
});curl -X POST http://localhost:7146/api/upload \
-F "image=@/path/to/photo.jpg"{
"name": "photo.jpg",
"type": "image/jpeg",
"size": 245760,
"tmp_path": "/tmp/tina4_upload_abc123"
}Saving the Uploaded File
The file sits in a temporary location. Move it to permanent storage:
<?php
use 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->name, 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);
}
// Move the file
rename($file->tmpPath, $destination);
return $response->json([
"message" => "File uploaded successfully",
"filename" => $filename,
"url" => "/uploads/" . $filename,
"size" => $file->size
], 201);
});curl -X POST http://localhost:7146/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:7146/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->name, PATHINFO_EXTENSION);
$filename = uniqid("file_") . "." . $extension;
$destination = __DIR__ . "/../../public/uploads/" . $filename;
if (!is_dir(dirname($destination))) {
mkdir(dirname($destination), 0755, true);
}
rename($file->tmpPath, $destination);
$results[] = [
"original_name" => $file->name,
"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():
<?php
use 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:
<?php
use 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:7146/api/products/1{"id":1,"name":"Wireless Keyboard","price":79.99}# Plain text
curl http://localhost:7146/api/products/1 -H "Accept: text/plain"Product #1: Wireless Keyboard - $79.99# HTML (renders the template)
curl http://localhost:7146/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->sendError() 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->sendError("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->sendError() returns a consistent JSON error envelope:
return $response->sendError("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:
# Upload
curl -X POST http://localhost:7146/api/images \
-F "image=@/path/to/photo.jpg"
# Download
curl http://localhost:7146/api/images/img_65f3a7b8c1234.jpg --output downloaded.jpg12. Solution
Create src/routes/images.php:
<?php
use 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->name, 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);
}
// Move the uploaded file
rename($file->tmpPath, $destination);
return $response->json([
"message" => "Image uploaded successfully",
"original_name" => $file->name,
"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_BODY_SIZE in .env. Default is 10mb. For file upload endpoints, you may need 50mb or more:
TINA4_MAX_BODY_SIZE=50mb