| Home | Getting Started | Core Concepts | Helpers | Extensions | Repo |
This example builds a small versioned JSON API with token authentication, rate limiting, structured errors, and CORS. It uses only first-party Tiny primitives — no extra packages.
my-app/
├── app/
│ ├── controllers/
│ │ └── api/
│ │ └── v1/
│ │ ├── auth.php # POST /api/v1/auth
│ │ └── posts.php # /api/v1/posts[/<id>]
│ ├── middleware/
│ │ ├── api-cors.php
│ │ └── api-auth.php
│ ├── middleware.php
│ └── models/
│ ├── user.php
│ └── post.php
└── migrations/
└── 20240101_api_tokens.php
<?php
// migrations/20240101_api_tokens.php
class ApiTokens extends TinyMigration
{
public function up(): void
{
tiny::db()->execute("
CREATE TABLE api_tokens (
token VARCHAR(64) PRIMARY KEY,
user_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
");
}
public function down(): void
{
tiny::db()->execute("DROP TABLE IF EXISTS api_tokens");
}
}
app/controllers/api/v1/auth.php:
<?php
class ApiV1Auth extends TinyController
{
public function post($request, $response)
{
$body = $request->body(true);
$email = $body['email'] ?? '';
$pass = $body['password'] ?? '';
if (!$email || !$pass) {
return $response->sendJSON(['error' => 'email and password required'], 400);
}
$user = tiny::db()->getOne('users', ['email' => $email]);
if (!$user || !password_verify($pass, $user->password_hash)) {
return $response->sendJSON(['error' => 'invalid credentials'], 401);
}
$token = bin2hex(random_bytes(32));
tiny::db()->insert('api_tokens', [
'token' => $token,
'user_id' => $user->id,
]);
$response->sendJSON([
'token' => $token,
'user' => ['id' => $user->id, 'email' => $user->email, 'name' => $user->name],
], 201);
}
}
Note: passwords are verified with PHP’s stdlib password_verify() — no framework helper required.
app/middleware/api-cors.php:
<?php
class ApiCorsMiddleware
{
public function handle(): void
{
$request = tiny::request();
if (!str_starts_with($request->path->full, '/api/')) {
return;
}
tiny::header('Access-Control-Allow-Origin: *');
tiny::header('Access-Control-Allow-Headers: Authorization, Content-Type, X-CSRF-Token');
tiny::header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
if ($request->method === 'OPTIONS') {
tiny::response()->send('', 204);
}
}
}
app/middleware/api-auth.php:
<?php
class ApiAuthMiddleware
{
public function handle(): void
{
$request = tiny::request();
$response = tiny::response();
// Skip CORS pre-flight and the auth endpoint itself.
if (!str_starts_with($request->path->full, '/api/')) return;
if ($request->path->full === '/api/v1/auth') return;
// Rate limit by client IP (100 req / 60s).
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
if (!tiny::rateLimiter('api', 100, 60)->check($ip)) {
return $response->sendJSON(['error' => 'rate limit exceeded'], 429);
}
// Bearer token.
$auth = $request->headers['Authorization'] ?? '';
$token = str_starts_with($auth, 'Bearer ') ? substr($auth, 7) : null;
if (!$token) {
return $response->sendJSON(['error' => 'missing bearer token'], 401);
}
$row = tiny::db()->getOne('api_tokens', ['token' => $token]);
if (!$row) {
return $response->sendJSON(['error' => 'invalid token'], 401);
}
$user = tiny::db()->getOne('users', ['id' => $row->user_id]);
if (!$user) {
return $response->sendJSON(['error' => 'user not found'], 401);
}
// Attach the user so controllers can read `tiny::user()`.
tiny::user($user);
// Touch last_seen.
tiny::db()->update('api_tokens', ['last_seen' => date('Y-m-d H:i:s')], ['token' => $token]);
}
}
Register both in app/middleware.php:
<?php
// order matters: CORS first (handles OPTIONS pre-flight), then auth
tiny::middleware('api-cors');
tiny::middleware('api-auth');
app/controllers/api/v1/posts.php:
<?php
class ApiV1Posts extends TinyController
{
public function get($request, $response)
{
// /api/v1/posts/<id> → single
if ($request->path->section) {
$post = tiny::db()->getOne('posts', ['id' => (int)$request->path->section]);
if (!$post) {
return $response->sendJSON(['error' => 'not found'], 404);
}
return $response->sendJSON(['data' => $post]);
}
// /api/v1/posts?page=&limit=
$page = max(1, (int)$request->params('page', 1));
$limit = min(100, max(1, (int)$request->params('limit', 20)));
$offset = ($page - 1) * $limit;
$pdo = tiny::db()->getPdo();
$stmt = $pdo->prepare("SELECT * FROM posts ORDER BY created_at DESC LIMIT :l OFFSET :o");
$stmt->bindValue('l', $limit, \PDO::PARAM_INT);
$stmt->bindValue('o', $offset, \PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(\PDO::FETCH_OBJ);
$total = (int)$pdo->query("SELECT COUNT(*) FROM posts")->fetchColumn();
$response->sendJSON([
'data' => $rows,
'meta' => [
'page' => $page,
'limit' => $limit,
'total' => $total,
'total_pages' => (int)ceil($total / $limit),
],
]);
}
public function post($request, $response)
{
$data = $request->body(true);
$errors = $this->validate($data);
if ($errors) {
return $response->sendJSON(['error' => 'validation failed', 'errors' => $errors], 422);
}
$data['user_id'] = tiny::user()->id;
$data['created_at'] = date('Y-m-d H:i:s');
$id = tiny::db()->insert('posts', $data);
$post = tiny::db()->getOne('posts', ['id' => $id]);
$response->sendJSON(['data' => $post], 201);
}
public function patch($request, $response)
{
$id = (int)$request->path->section;
if (!$id) {
return $response->sendJSON(['error' => 'id required'], 400);
}
$existing = tiny::db()->getOne('posts', ['id' => $id]);
if (!$existing) {
return $response->sendJSON(['error' => 'not found'], 404);
}
if ($existing->user_id !== tiny::user()->id) {
return $response->sendJSON(['error' => 'forbidden'], 403);
}
$data = $request->body(true);
tiny::db()->update('posts', $data, ['id' => $id]);
$response->sendJSON(['data' => tiny::db()->getOne('posts', ['id' => $id])]);
}
public function delete($request, $response)
{
$id = (int)$request->path->section;
$existing = tiny::db()->getOne('posts', ['id' => $id]);
if (!$existing) {
return $response->sendJSON(['error' => 'not found'], 404);
}
if ($existing->user_id !== tiny::user()->id) {
return $response->sendJSON(['error' => 'forbidden'], 403);
}
tiny::db()->delete('posts', ['id' => $id]);
$response->send('', 204);
}
private function validate(array $data): array
{
$errors = [];
if (empty($data['title'])) {
$errors['title'] = 'title is required';
} elseif (mb_strlen($data['title']) > 255) {
$errors['title'] = 'title is too long';
}
if (empty($data['body'])) {
$errors['body'] = 'body is required';
}
return $errors;
}
}
URLs:
| Method | Path | Action |
|---|---|---|
POST |
/api/v1/auth |
Issue a bearer token |
GET |
/api/v1/posts |
List posts (paginated) |
GET |
/api/v1/posts/<id> |
Fetch one |
POST |
/api/v1/posts |
Create |
PATCH |
/api/v1/posts/<id> |
Update (owner only) |
DELETE |
/api/v1/posts/<id> |
Delete (owner only) |
# Get a token
curl -X POST http://localhost/api/v1/auth \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com","password":"hunter2"}'
# Use it
curl http://localhost/api/v1/posts \
-H 'Authorization: Bearer <token>'
# Create
curl -X POST http://localhost/api/v1/posts \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{"title":"Hello","body":"World"}'
Every error response above has the shape:
{ "error": "<reason>" }
…with validation errors adding an errors map keyed by field. Stick to a single shape and clients will be much easier to write.
/api/v<n>/ — the filesystem router makes this free.password_hash or similar columns — select explicitly, or strip in the controller.Content-Type: application/json everywhere — set by sendJSON(), but verify it isn’t overridden by other middleware.422; don’t silently coerce.204 immediately on OPTIONS, before auth checks.