Documentation

Complete implementation guide for Narciso: routing, middleware, database, API, and views.

Installation

Install via Composer:

composer require marcuwynu23/narciso

Requires PHP 7.4+ (or as required by your environment). No other dependencies.

Quick start

<?php
require_once __DIR__ . '/vendor/autoload.php';

use Marcuwynu23\Narciso\Application;

$app = new Application();
$app->setViewPath(__DIR__ . '/views');

$app->route('GET', '/', function ($app) {
    $app->json(['message' => 'Welcome to Narciso']);
});

$app->run();

Run with PHP built-in server:

php -S localhost:8080 -t public

(or your document root).

Application

The Application class is the single entry point. Key methods:

  • setViewPath(string $path) — Directory for view files (e.g. views/).
  • run() — Execute middleware stack and dispatch to the matching route. Call once at the end of your entry script.

When no route matches, Narciso returns 404 with Content-Type: application/json and body {"error":"Not Found","path":"/requested-path"}.

Routing

Registering routes

route(string $method, string $path, callable $callback): self

Method: GET, POST, PUT, PATCH, DELETE (case-insensitive).

Path: Exact path or named parameters with :name (e.g. /users/:id, /posts/:slug/comments/:cid). Param names must match [a-zA-Z_][a-zA-Z0-9_]*.

Callback: function (Application $app, array $params). $params contains captured path parameters (e.g. ['id' => '42']).

Examples

// Exact path
$app->route('GET', '/', function ($app) {
    $app->json(['message' => 'Hello']);
});

// Path parameters
$app->route('GET', '/users/:id', function ($app, $params) {
    $app->json(['user_id' => $params['id']]);
});

$app->route('GET', '/posts/:slug/comments/:cid', function ($app, $params) {
    $app->json([
        'post_slug'  => $params['slug'],
        'comment_id' => $params['cid'],
    ]);
});

// POST with JSON body
$app->route('POST', '/echo', function ($app) {
    $body = $app->requestPost();
    $app->json(['received' => $body]);
});

Matching uses the request URI path (without query string). With the built-in server, the script name may be part of the path (e.g. /index.php/); design your routes accordingly or use a front controller.

Middleware

Middleware runs in order before the route handler. First added = first executed (before request), last runs closest to the response.

use($middleware)

Accepts:

  • Callable: function ($app, $next) { ...; return $next(); }. Call $next() to continue the pipeline.
  • MiddlewareInterface: Class with handle(callable $next) that calls $next() to continue.
$app->use(function ($app, $next) {
    // before
    $result = $next();
    // after
    return $result;
});

$app->use(new class implements \Marcuwynu23\Narciso\Middleware\MiddlewareInterface {
    public function handle(callable $next) {
        // before
        return $next();
    }
});

useSecurityHeaders(?array $headers = null)

Adds security-related HTTP headers to every response. Default headers:

  • X-Content-Type-Options: nosniff
  • X-Frame-Options: SAMEORIGIN
  • X-XSS-Protection: 1; mode=block
  • Referrer-Policy: strict-origin-when-cross-origin
  • Permissions-Policy: geolocation=(), microphone=(), camera=()

Pass a custom ['Header-Name' => 'value'] array to override, or null for defaults.

useCors(...)

Configurable CORS middleware. Parameters:

  • $origins — e.g. ['*'] or ['https://app.example.com']
  • $methods — e.g. ['GET','POST','PUT','PATCH','DELETE','OPTIONS']
  • $headers — e.g. ['Content-Type','Authorization','X-Requested-With','Accept']
  • $credentials — allow credentials (default false)
  • $maxAge — preflight cache in seconds (default 86400)
$app->useCors(
    ['*'],
    ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
    ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept'],
    false,
    86400
);

On OPTIONS requests the middleware responds with 204 and exits.

useRateLimit(int $maxRequests, int $windowSeconds)

Rate limit per client (by IP; respects X-Forwarded-For). In-memory store — use Redis or similar in production for multi-process.

$app->useRateLimit(60, 60); // 60 requests per 60 seconds

When exceeded, responds with 429 Too Many Requests, Retry-After header, and JSON body {"error":"Too Many Requests","retry_after":60}.

Database

handleDatabase(array $config): self

Connects and exposes $app->db (mysqli for MySQL, SQLite3 for SQLite).

MySQL

$app->handleDatabase([
    'type'     => 'mysql',
    'host'     => 'localhost',
    'database' => 'mydb',
    'user'     => 'user',      // or 'username'
    'password' => 'secret',
]);

$app->db is a mysqli instance. Throws RuntimeException on connection failure.

SQLite

$app->handleDatabase([
    'type'     => 'sqlite',
    'database' => '/path/to/file.sqlite',  // or ':memory:'
]);

$app->db is an SQLite3 instance. Requires the sqlite3 extension.

Request

  • requestPost(): ?array — Reads php://input, JSON-decodes, and returns an array or null if invalid/empty. Use for POST/PUT/PATCH JSON bodies.
  • requestRaw(): string — Returns the raw request body as a string.

Responses

json($data, int $statusCode = 200)

Sets Content-Type: application/json; charset=utf-8, status code, and outputs json_encode($data).

sendAPI($data, array $options = [])

Responds as JSON or XML. Format can be forced or auto-detected from ?format=json|xml or Accept header. Options:

  • format — 'json', 'xml', or null (auto)
  • statusCode — HTTP status (default 200)
  • root — XML root tag (default 'response')
  • xmlItemName — Tag name for list items (default 'item')

$data can be array or object; for XML it is converted to a tree. Numeric arrays become repeated xmlItemName elements.

getPreferredApiFormat(): string

Returns 'json' or 'xml' from query ?format= or Accept header. Default 'json'.

arrayToXml(array $data, string $rootTag, string $itemTag): string

Public helper to convert an array to an XML string (UTF-8, SimpleXMLElement).

Views

setViewPath(string $path) — Base directory for view files.

render(string $view, array $data = []): void — Includes a PHP file from the view path. $view is the path without .php (e.g. /home.view or /about/about.view). Variables are extracted into the view (e.g. 'message' => 'Hi' becomes $message).

$app->setViewPath(__DIR__ . '/views');
$app->route('GET', '/', function ($app) {
    $app->render('/home.view', ['message' => 'Hello']);
});
// includes views/home.view.php with $message

Redirect

redirect(string $url, int $statusCode = 302): void — Sends Location header and exits. Use for redirects.

Technology signature (X-Powered-By)

setPoweredBy($value): self

  • false — Remove X-Powered-By (recommended for production).
  • '' — Send blank value.
  • 'Express' (or any string) — Custom value.
  • null — Leave server default unchanged.

Applied when run() is called.

Session

handleSession(string $name = "Narciso"): self — Sets session name and calls session_start(). Use when you need $_SESSION.

Deprecated CORS: handleCORS() reflects the request Origin and allows credentials. Prefer useCors() for configurable CORS.

Full example

Combining routing, middleware, database, API, and views:

<?php
require_once __DIR__ . '/vendor/autoload.php';

use Marcuwynu23\Narciso\Application;

$app = new Application();
$app->setViewPath(__DIR__ . '/views');

$app->setPoweredBy(false);
$app->handleSession('MyApp');
$app->useSecurityHeaders();
$app->useCors(['*'], ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], ['Content-Type', 'Authorization']);
$app->useRateLimit(60, 60);

$app->handleDatabase(['type' => 'sqlite', 'database' => ':memory:']);
$app->db->exec('CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, name TEXT)');
$app->db->exec("INSERT OR IGNORE INTO items (id, name) VALUES (1, 'Alpha'), (2, 'Beta')");

$app->route('GET', '/', function ($app) {
    $app->json(['app' => 'Narciso', 'routes' => ['GET /api', 'GET /api/items', 'GET /page']]);
});

$app->route('GET', '/api', function ($app) {
    $app->sendAPI(['message' => 'API', 'time' => date('c')]);
});

$app->route('GET', '/api/items', function ($app) {
    $res = $app->db->query('SELECT id, name FROM items');
    $rows = [];
    while ($row = $res->fetchArray(SQLITE3_ASSOC)) $rows[] = $row;
    $app->sendAPI(['items' => $rows]);
});

$app->route('GET', '/api/items/:id', function ($app, $params) {
    $id = (int) $params['id'];
    $stmt = $app->db->prepare('SELECT id, name FROM items WHERE id = :id');
    $stmt->bindValue(':id', $id, SQLITE3_INTEGER);
    $r = $stmt->execute();
    $row = $r->fetchArray(SQLITE3_ASSOC);
    if (!$row) { $app->json(['error' => 'Not found'], 404); return; }
    $app->sendAPI($row);
});

$app->route('GET', '/page', function ($app) {
    $app->render('/home.view', ['message' => 'Hello']);
});

$app->run();