A JSON API¶
This is a separate, local development API with persistent articles in SQLite. Begin with
the core-only installation, not the website starter: this
exercise uses only naf/framework and PHP's pdo_sqlite extension. It has no login, session or
form plugin. Keep the development server bound to 127.0.0.1.
Every titled block is a complete file. Keep public/index.php, .env and composer.json from
the core-only installation; add or replace the files below.
mkdir -p app/Controllers app/Repositories bin storage
Bootstrap and JSON errors¶
<?php
define('BASE_PATH', __DIR__);
require __DIR__ . '/vendor/autoload.php';
use Naf\Core\{ErrorHandler, Event};
use function Naf\{app, event, json, log};
app()->container()->set(PDO::class, static fn() => new PDO(
'sqlite:' . BASE_PATH . '/storage/articles.sqlite', null, null,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC],
));
event()->listen(Event::EXCEPTION, static function (Throwable $exception) {
log()->error('API request failed', ['exception' => $exception]);
$status = ErrorHandler::resolveStatusCode($exception);
return json(['error' => $status >= 500 ? 'Internal server error' : 'Request refused'], $status);
});
app()->run();
The PDO factory is resolved by type when NAF builds the repository. The exception listener also covers unknown routes, so a client receives JSON errors. Expected failures return JSON directly from the controller. A bootstrap parse error can happen before the listener is registered.
Create the table¶
<?php
require dirname(__DIR__) . '/bootstrap.php';
use function Naf\app;
app()->container()->get(PDO::class)->exec('CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL
)');
echo "Articles table ready.\n";
composer dump-autoload
php bin/create-articles.php
Expect Articles table ready.. The database file lives outside the public directory and
survives requests and server restarts. This example uses PDO directly, without an ORM.
The repository¶
<?php
namespace App\Repositories;
use PDO;
final class ArticleRepository
{
public function __construct(private PDO $pdo) {}
public function all(): array
{
return $this->pdo->query('SELECT id, title, body FROM articles ORDER BY id')->fetchAll();
}
public function find(string $id): ?array
{
$stmt = $this->pdo->prepare('SELECT id, title, body FROM articles WHERE id = ?');
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
public function create(string $title, string $body): array
{
$stmt = $this->pdo->prepare('INSERT INTO articles (title, body) VALUES (?, ?)');
$stmt->execute([$title, $body]);
return $this->find($this->pdo->lastInsertId());
}
public function delete(string $id): bool
{
$stmt = $this->pdo->prepare('DELETE FROM articles WHERE id = ?');
$stmt->execute([$id]);
return $stmt->rowCount() > 0;
}
}
Routes and controller¶
<?php
use App\Controllers\ArticleController;
use function Naf\route;
route()->add('GET', '/api/articles', [ArticleController::class, 'index'], 'api.articles.index');
route()->add('GET', '/api/articles/{id}', [ArticleController::class, 'show'], 'api.articles.show');
route()->add('POST', '/api/articles', [ArticleController::class, 'store'], 'api.articles.store');
route()->add('DELETE', '/api/articles/{id}', [ArticleController::class, 'destroy'], 'api.articles.destroy');
Placeholder names must match method argument names: {id} becomes $id.
<?php
namespace App\Controllers;
use App\Repositories\ArticleRepository;
use JsonException;
use Psr\Http\Message\ResponseInterface;
use function Naf\{json, request, response};
final class ArticleController
{
public function __construct(private ArticleRepository $articles) {}
public function index(): ResponseInterface
{
return json(['data' => $this->articles->all()]);
}
public function show(string $id): ResponseInterface
{
$article = $this->articles->find($id);
return $article === null
? json(['error' => 'No article with that id.'], 404)
: json(['data' => $article]);
}
public function store(): ResponseInterface
{
$mediaType = strtolower(trim(explode(';', request()->getHeaderLine('Content-Type'))[0]));
if ($mediaType !== 'application/json') {
return json(['error' => 'Use Content-Type: application/json.'], 415);
}
try {
$data = json_decode((string) request()->getBody(), false, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return json(['error' => 'Malformed JSON.'], 400);
}
if (!$data instanceof \stdClass) {
return json(['error' => 'Expected a JSON object.'], 422);
}
$fields = [];
foreach (['title' => 200, 'body' => 10000] as $field => $maxBytes) {
$value = $data->$field ?? null;
if (!is_string($value) || trim($value) === '' || strlen($value) > $maxBytes) {
$fields[$field] = ["Use a non-empty string of at most {$maxBytes} bytes."];
}
}
if ($fields !== []) {
return json(['error' => 'The article could not be saved.', 'fields' => $fields], 422);
}
$article = $this->articles->create($data->title, $data->body);
return json(['data' => $article], 201, ['Location' => '/api/articles/' . $article['id']]);
}
public function destroy(string $id): ResponseInterface
{
if (!$this->articles->delete($id)) {
return json(['error' => 'No article with that id.'], 404);
}
return response('', 204);
}
}
Explicit decoding distinguishes malformed JSON (400) from invalid fields (422) and prevents
query parameters from supplying a missing body field. The limits here count bytes. Use an
explicit Unicode character rule if your application needs a character limit.
A 204 response has no body; json(null, 204) would try to encode null as content.
Try it¶
php -S 127.0.0.1:8000 -t public
In another terminal:
curl -i http://127.0.0.1:8000/api/articles
curl -i -X POST http://127.0.0.1:8000/api/articles \
-H 'Content-Type: application/json' \
-d '{"title":"First article","body":"Hello from NAF."}'
curl -i http://127.0.0.1:8000/api/articles/1
curl -i -X DELETE http://127.0.0.1:8000/api/articles/1
On a fresh database expect 200 with an empty list, 201 with the saved article, 200 with that
article, then 204 with an empty body. Use the returned ID when repeating the exercise.
A subsequent GET for the deleted ID returns 404. POST {} gives 422, broken JSON gives 400,
and an unknown route returns a JSON 404.
Adding authentication¶
This local demo lets any caller read and change its articles. Before exposing it, add an appropriate authentication and authorization policy to each operation:
| Use case | Starting point |
|---|---|
| A browser using your login session | Auth and forms/CSRF |
| An API accessed with OAuth access tokens | OAuth server |
| Tools called by a language model | MCP |
If you adapt this code into the website starter, naf/form also runs: POST and DELETE require
CSRF protection. Keep that protection for requests authenticated by cookies. A header beginning
with Bearer bypasses the form plugin's CSRF check, but does not authenticate the request.
Validate credentials independently and never fall back to cookie authentication after an
invalid bearer token. See requests that carry their own credentials.