PHPackages                             cloude/framework - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Framework](/categories/framework)
4. /
5. cloude/framework

ActiveLibrary[Framework](/categories/framework)

cloude/framework
================

Minimalist PHP 8.3+ micro-framework: router, input, views, markdown and string helpers. No magic, no service container. Optional persistence: file-based (JSON, Markdown) and a thin PDO Active Record via Cloude\\Model. Ships its own test runner (bin/cloude-test).

v2.17.1(4w ago)0151MITPHPPHP &gt;=8.3

Since Apr 19Pushed 4w agoCompare

[ Source](https://github.com/polmartinez/cloude-php-workspace)[ Packagist](https://packagist.org/packages/cloude/framework)[ RSS](/packages/cloude-framework/feed)WikiDiscussions main Synced 1w ago

READMEChangelogDependencies (2)Versions (77)Used By (0)

Cloude Framework
================

[](#cloude-framework)

A minimalist PHP micro-framework. No magic, no service container. Persistence is opt-in: file-based (JSON / Markdown) by default, with a thin Active Record over PDO via `Cloude\Model` when you need a relational database.

- **PHP 8.3+** (tested on 8.3, 8.4 and 8.5)
- **PSR-4** autoloading, namespace `Cloude\`
- **PSR-12 / PER-CS 2.0** coding style
- `declare(strict_types=1)` everywhere
- Zero runtime dependencies. Markdown rendering is in-house. Slug transliteration uses `ext-intl` when present (Cyrillic, Greek and other scripts romanise correctly). Without `ext-intl` non-ASCII characters are dropped — install it for sites that need accent-aware slugs.

Built with Claude Code
----------------------

[](#built-with-claude-code)

Cloude is shaped while pair-coding with [**Claude Code**](https://claude.com/claude-code) — Anthropic's terminal-native AI coding agent. The "one file per class, no magic, no DSL, no annotations" rules aren't aesthetic preferences: they exist so the agent (and you) can reason about any piece of the framework without loading a runtime in your head.

Try it:

```
composer require cloude/framework
claude
```

Then ask Claude Code to scaffold a front controller with `Cloude\Bootstrap`, expose an MCP server with `Cloude\Mcp\Server`, or migrate a routes file into nested groups. The surface area is small enough that the agent usually nails it in one prompt.

> **For agents:** see [`AGENTS.md`](AGENTS.md) — a tight reference card with the decision matrix, idioms and anti-patterns. AI tools (Claude Code, Cursor, Codex, …) pick it up automatically when they see the file at the repo root.
>
> **For Claude Code users specifically:** [`CLAUDE.md`](CLAUDE.md) is the "from zero to running feature" playbook — quick start, project layout, the standard add-a-feature workflow, common patterns, and how to brief Claude effectively. Copy it into your own project root once you start building on top of `cloude/framework`.
>
> **For design-pattern guidance:** [`PATTERNS.md`](PATTERNS.md) is the decision guide for picking an architecture — Transaction Script, MVC
>
> - Repository, or DDD layered. It maps "what your app looks like" to "which example to copy", with migration paths and anti-patterns.
>
> **For brand-new projects, AI-guided:** [`SETUP.md`](SETUP.md) is an interview script for AI coding agents. Point Claude Code (or any tool-capable agent) at this file and it walks you through eight steps — namespace, docroot, run mode (`php -S` / Docker / both), pattern, CSS, JS, optional modules — and scaffolds the project based on your answers.

Installation
------------

[](#installation)

```
composer require cloude/framework
```

Repository layout
-----------------

[](#repository-layout)

```
cloude-php-workspace/
  src/                   # Framework source (PSR-4: Cloude\)
    Input.php
    Router.php
    View.php
    Str.php
    Markdown.php
    Markdown/
      Parser.php         # In-house markdown → HTML parser (no deps)
      File.php           # Disk I/O with transparent gzip
      Server.php         # HTTP serve with 304 / canonical / gzip
    Data/
      Repository.php     # Abstract base for "directory of files" repositories
      JsonRepository.php # One .json per entity, slug-addressed
      MarkdownRepository.php # One .md(.gz) per entity, slug-addressed
    Arr.php              # Array helpers with dot-notation access
    Bootstrap.php        # One-call front-controller bootstrap
    Cli.php              # Argv parsing + colored output for app/cli/ scripts
    Collection.php       # Fluent, chainable wrapper around an array
    Config.php
    EventLog.php
    Format.php           # Yaml / json / xml / markdown encode-decode dispatcher
    JsonFile.php
    JsonSchema.php       # In-house JSON Schema subset validator
    Logger.php           # File-backed logger with daily rotation
    TaskRunner.php       # CLI task runner: prefix:method dispatch over a class
    Http/
      Cache.php
      AssetUrl.php
      ErrorHandler.php
      HttpException.php   # Base: carries a status code
      NotFoundException.php  # extends HttpException, status 404
      Response.php
    Mcp/
      Server.php         # MCP (Model Context Protocol) server, HTTP transport
      JsonRpc.php        # JSON-RPC 2.0 + MCP error codes
      McpException.php
    views/               # Default 404 / 500 / 500-debug views (overridable)
  tests/                 # Cloude\Testing — run with `vendor/bin/cloude-test`
  examples/              # Runnable sample apps (see examples/README.md)
    recipes/             # Cookbook snippets for sitemap, JSON-LD, ...
    contacts/            # Mini address-book: form + live search demo
  bin/
    cloude-test          # Test runner entry-point (no PHPUnit dependency)
  composer.json          # Package manifest (name: cloude/framework)
  .php-cs-fixer.dist.php
  LICENSE
  README.md

```

Components
----------

[](#components)

### Core

[](#core)

ClassResponsibility`Cloude\Arr`Array helpers with dot-notation: `get/set/has/forget/pluck/only/except/dot/undot/merge``Cloude\Bootstrap`Front-controller bootstrap: `initPaths()` defines `DOCROOT`/`APPPATH`/`BASEPATH`; `run()` wires cli-server passthrough + `ob_start` + `ErrorHandler` + view base (pulls debug/views from `Config` when omitted)`Cloude\Cli`Argv parsing + colored output for `app/cli/` scripts`Cloude\Collection`Fluent, chainable wrapper: `map/filter/reduce/pluck/keyBy/groupBy/sortBy/take/chunk/unique/sum/avg/min/max/...``Cloude\Config`Env helpers (`env`/`boolEnv`); multi-env file loader (`configure`/`load`/`get`); typed accessors (`baseUrl`/`debug`/`path`); legacy `defineBaseUrl`/`defineDebug``Cloude\DateTime`Tiny immutable date helper extending `\DateTimeImmutable`. Static constructors (`now`/`today`/`parse`/`fromTimestamp`); format shortcuts (`toDateString`/`toTimeString`/`toDateTimeString`/`toIsoString`); arithmetic (`addDays`/`addHours`/`addMinutes`/…); boundaries (`startOfDay`/`endOfMonth`/…); comparisons (`isPast`/`isToday`/`isSameDay`/…); signed `diffIn{Days,Hours,Minutes,Seconds}` and English `diffForHumans()`. Carbon-style `setTestNow()` / `clearTestNow()` for test isolation. Used automatically by the `datetime` cast`Cloude\EventLog`Fire-and-forget POST to a webhook for usage analytics`Cloude\Format`Yaml / json / xml / markdown encode-decode dispatcher (string ↔ array)`Cloude\Input`Wrapper over `$_GET`, `$_POST`, `$_SERVER`, raw body and JSON`Cloude\JsonFile`Per-request cached, atomic-write helper for JSON files`Cloude\JsonSchema`In-house JSON Schema subset validator (no external deps)`Cloude\Logger`File-backed logger with daily rotation and `debug/info/warn/error``Cloude\TaskRunner`CLI task runner. `prefix:method` dispatch over registered callables or static class methods, with auto `list` / `help``Cloude\Router`Router with `/{param}`, `/{param?}`, `/{param:regex}` patterns, nested route groups, and `get/post/put/patch/delete/any` helpers`Cloude\Session`Static façade over `$_SESSION` with hardened cookie defaults (`httponly`, `samesite=Lax`, `secure` on HTTPS). Typed `get/set/has/forget/all`, flash messages (`flash`/`pullFlash`/`reflash`), CSRF helpers (`csrfToken`/`checkCsrf`), `regenerate()` for login flows`Cloude\Str`String utilities: `upTo`/`truncate`/`truncateMiddle`/`words`/`after`/`afterLast`/`between`/`squish`/`mask`, `slug`/`ascii`, `camel`/`pascal`/`snake`/`kebab`, `random`/`uuid`/`hash``Cloude\View`Plain PHP template rendering with variable extraction and HTML escape### `Cloude\Http\…`

[](#cloudehttp)

ClassResponsibility`Cloude\Http\AssetUrl`Versioned asset URLs (`/{mtime}/assets/...`) for cache-busting`Cloude\Http\Cache`HTTP cache headers (`ok`, `notFound`, `unavailable`) and `conditionalGet()``Cloude\Http\ErrorHandler`Global error handler. Defaults to 503 (`Retry-After: 600`); honors `HttpException::statusCode` when thrown. HTML / JSON / `.md` / CLI / AJAX negotiation, debug mode`Cloude\Http\HttpException`Throwable carrying a status code; caught by `ErrorHandler` to render with that status`Cloude\Http\NotFoundException``HttpException` pinned to 404; renders bundled `404.html.php` (or your override)`Cloude\Http\Response`One-call response helpers: `json`, `html`, `xml`, `markdown`, `redirect`, `notFound`, `noContent`### `Cloude\Data\…`

[](#cloudedata)

ClassResponsibility`Cloude\Data\Repository`Abstract base for directory-of-files repositories. Subclasses implement `find` / `slugs` / `path`; `exists`, `findOr`, `all` and the `transform()` hook come for free`Cloude\Data\JsonRepository`One `.json` per entity. Atomic writes, per-request read cache via `JsonFile``Cloude\Data\MarkdownRepository`One `.md` (or `.md.gz`) per entity. Read returns frontmatter + parsed HTML via `Markdown::parse`### `Cloude\Model\…` and `Cloude\Storage\…`

[](#cloudemodel-and-cloudestorage)

ClassResponsibility`Cloude\Model\Model`Abstract Active Record. Subclass with `protected static string $table` + `$connection` (+ optional `$types`). CRUD via `find` / `findBy` / `create` / `save` / `delete`. Static helpers: `table()`, `field('col')`, `as('alias')`, `ref()`, `query()``Cloude\Model\Cast`Opt-in attribute coercion driven by the `$types` map: `int`, `float`, `string`, `bool`, `decimal[:N]`, `json`/`array`, `datetime[:FMT]`, `date[:FMT]`, `enum:FQCN`. Null passes through`Cloude\Model\Storage\PdoStorage`PDO-backed adapter. Driven by `Cloude\Storage\Connection` named pool`Cloude\Model\Storage\JsonStorage`One JSON file per row (collection mode) or one big array (collection storage)`Cloude\Model\Storage\MarkdownStorage`Markdown body + frontmatter as a row`Cloude\Model\Storage\ArrayStorage`In-memory rows; ideal for tests`Cloude\Storage\Query`Fluent SQL builder. SELECT / INSERT / UPDATE / DELETE + WHERE (with nested AND/OR groups) + INNER/LEFT/RIGHT/CROSS JOIN + ORDER BY + LIMIT/OFFSET + `count()``Cloude\Storage\TableRef`Lightweight `(table, alias)` value object. Pair with `Model::as('u')` for typed joins; `__toString()` yields the quoted FROM/JOIN expression`Cloude\Storage\Identifier`SQL identifier-quoting helper. `quote()` for single names, `qualify()` for `table.column` / `table.*` / `*``Cloude\Storage\Connection`Named PDO pool keyed off `Config::get('storage.{name}')``Cloude\Storage\Factory`Builds a Storage adapter from a config row (dispatches on `driver`)`Cloude\Storage\StorageException`Framework-level wrapper around `\PDOException`. Public readonly `$sqlState`, `$sql`, `$bindings`. Specialised subclasses: `TableNotFoundException` (42S02/42P01), `ColumnNotFoundException` (42S22/42703), `DuplicateKeyException` (23000+1062, 23505), `IntegrityConstraintException` (23xxx), `ConnectionException` (08xxx), `SyntaxErrorException` (42000/42601)`Cloude\Storage\Transaction``begin` / `commit` / `rollback` / `inTransaction` / `depth` + closure-based `run(fn, $connection)`. Real nested transactions via SAVEPOINTs. Wraps PDO errors as `StorageException``Cloude\Storage\Schema`DDL emitter — produces `CREATE TABLE` / `DROP TABLE` SQL from structured arrays. Indexes (`UNIQUE`/`INDEX`), foreign keys (`ON DELETE`/`ON UPDATE`), `DEFAULT NULL`, composite primary keys, MySQL + Postgres dialects. **Not a migration framework** — feed the SQL to your existing tooling### `Cloude\Markdown\…`

[](#cloudemarkdown)

ClassResponsibility`Cloude\Markdown`Frontmatter + body parser. Body rendered via `Markdown\Parser` by default; swappable with `useParser()``Cloude\Markdown\File`Disk I/O for markdown with transparent gzip (`.md` + `.md.gz`)`Cloude\Markdown\Parser`In-house markdown → HTML parser. No external dependency`Cloude\Markdown\Server`Serves a markdown file with 304 / canonical / gzip passthrough### `Cloude\Mcp\…`

[](#cloudemcp)

ClassResponsibility`Cloude\Mcp\Server`MCP server (Model Context Protocol) over HTTP / JSON-RPC 2.0, with auto `inputSchema` validation`Cloude\Mcp\JsonRpc`Constants for JSON-RPC 2.0 + MCP error codes### `Cloude\Testing\…` — built-in test framework (no PHPUnit dependency)

[](#cloudetesting--built-in-test-framework-no-phpunit-dependency)

ClassResponsibility`Cloude\Testing\TestCase`Test base class. Lifecycle (`setUp`/`tearDown`), PHPUnit-compatible assertion surface (`assertSame`/`assertTrue`/`assertInstanceOf`/…), exception expectations, Cloude-specific helpers (`useArrayModel`, `useSqliteModel`, `useMockModel`, `captureHttp`, `freezeTime`, …)`Cloude\Testing\Assert`Static assertion library used by `TestCase`. Each method increments an internal counter shown in the runner's summary`Cloude\Testing\Runner`Discovery + execution + reporting. `bin/cloude-test` calls `Runner::main($argv)`. Supports `--filter=PATTERN` and one or more path arguments`Cloude\Testing\DataProvider``#[DataProvider('cases')]` attribute — name a static method returning an iterable of arg arrays; one test invocation per row`Cloude\Testing\AssertionFailedException`Thrown by `Assert` methods on failure. The runner catches it to flag the test as failed (vs. errored)`Cloude\Testing\MockStorage`Recording wrapper around `ArrayStorage` for behaviour assertions (`$store->received('update', times: 1)`)`Cloude\Mcp\McpException`Structured-error throwable for tool / resource handlersQuick start
-----------

[](#quick-start)

A typical `www/index.php` looks like this:

```
');
Response::markdown("# Title\n\nBody.");
Response::redirect('/login', 302);
Response::notFound('# 404', 'text/markdown');
Response::noContent();                            // 204
```

JSON is encoded with `UNESCAPED_UNICODE | UNESCAPED_SLASHES`. Pass `pretty: true` for indented output. `redirect()` strips CRLF from the URL to prevent header injection.

### `Cloude\Config`

[](#cloudeconfig)

Two responsibilities in one class: **(1)** env-var helpers and the typed bootstrap accessors (`baseUrl`, `debug`, `path`), **(2)** the multi-environment config-file loader (FuelPHP-style — base files + per-env overrides deep-merged).

The framework only ships three directory constants (defined by [`Bootstrap::initPaths()`](#cloudebootstrap) — `DOCROOT`, `APPPATH`, `BASEPATH`); everything else lives in config files and flows through `Config::get()` / `Config::baseUrl()` / `Config::debug()` / `Config::path()`.

```
// Env-var helpers (always available)
Config::env('OPENAI_API_KEY');           // ?string — empty string treated as missing
Config::boolEnv('DEBUG', false);          // bool — 1/true/yes/on (case-insensitive)

// Wire the loader (once, in www/index.php right after initPaths)
Config::configure(APPPATH . '/config');
// optional: ::configure($path, environment: 'prod')
// otherwise APP_ENV / ENVIRONMENT env vars decide

// Read a file/dot-path
Config::get('db.default.dsn');            // any value
Config::load('db');                       // whole merged array
Config::environment();                    // current env name ('dev' default)

// Typed accessors — the recommended way to read framework knobs
Config::baseUrl(['example.com']);         // memoized; reads app.base_url, then env, then auto-detect
Config::debug();                          // bool — reads app.debug, then env DEBUG
Config::path('data');                     // app.paths.data
Config::path('cache', '/tmp/c');          // with fallback

// Legacy global-constants helpers (still supported, back-compat)
Config::defineBaseUrl(['example.com']);   // → define('BASE_URL', ...)
Config::defineDebug();                    // → define('DEBUG', ...)
```

**Directory layout under `APPPATH/config/`:**

```
app/config/
├── app.php          # base — always loaded
├── db.php
├── mail.php
├── dev/             # active when environment is 'dev'
│   └── app.php      # deep-merged onto the base
└── prod/
    ├── app.php
    └── db.php

```

**Conventional `app/config/app.php`:**

```
return [
    'base_url' => Cloude\Config::env('BASE_URL'),    // null → auto-detect
    'debug'    => Cloude\Config::boolEnv('DEBUG'),
    'timezone' => Cloude\Config::env('TZ', 'UTC'),   // Bootstrap::run() applies this
    'paths' => [
        'data'  => BASEPATH . '/data',
        'views' => APPPATH . '/views',
    ],
];
```

The framework ships `config/app.php` with `'timezone' => 'UTC'` as a baseline. Apps that don't override the key inherit `UTC`; otherwise the app's value wins (deep-merge).

`baseUrl()` resolves in this order: `BASE_URL` global constant (if already defined), `app.base_url` config, `BASE_URL` env var, auto-detected scheme + Host (validated against the optional allowlist; non-allowed hosts collapse to `localhost` to prevent header injection). The result is memoized for the request — `Config::reset()` clears it.

### `Cloude\DateTime`

[](#cloudedatetime)

Tiny immutable date helper extending `\DateTimeImmutable`. Drop-in for any `\DateTimeInterface` consumer; adds the shortcuts you'd otherwise copy-paste between projects. The `datetime` cast in `Model::$types`hydrates into this class automatically, so the helpers work on attribute values without any extra wiring.

```
use Cloude\DateTime;

// Static constructors
DateTime::now();                        // current moment
DateTime::today();                      // today at 00:00:00
DateTime::parse('2026-05-18 14:30');    // throws \InvalidArgumentException on bad input
DateTime::fromTimestamp(1716000000);

// Format shortcuts
$d->toDateString();        // 'Y-m-d'         → '2026-05-18'
$d->toTimeString();        // 'H:i:s'         → '14:30:00'
$d->toDateTimeString();    // 'Y-m-d H:i:s'   → '2026-05-18 14:30:00'
$d->toIsoString();         // 'c' / RFC 3339  → '2026-05-18T14:30:00+02:00'
(string) $d;               // 'Y-m-d H:i:s' — MySQL-shaped, drops the offset
                           //   Use toIsoString() when you need timezone in the output.
                           //   Interpolation works: "saved at $d" → "saved at 2026-05-18 …"

// Arithmetic (immutable — always returns a new instance)
$d->addDays(7)->subHours(2)->addMinutes(30);
$d->addWeeks(1); $d->addMonths(1); $d->addYears(1);
$d->subSeconds(10); $d->subDays(3); // ... etc

// Boundaries
$d->startOfDay();          // 00:00:00
$d->endOfDay();            // 23:59:59
$d->startOfMonth();        // 1st of the month at 00:00:00
$d->endOfMonth();          // last of the month at 23:59:59

// Comparisons
$d->isPast();     $d->isFuture();
$d->isToday();    $d->isYesterday();  $d->isTomorrow();
$d->isBefore($other);  $d->isAfter($other);  $d->isSameDay($other);

// Signed diffs — positive when $other is later than $this
$d->diffInSeconds($other);
$d->diffInMinutes($other);
$d->diffInHours($other);
$d->diffInDays($other);

// English "5 minutes ago" / "in 3 days"
$d->diffForHumans();           // vs now()
$d->diffForHumans($reference); // vs explicit reference (testable)
```

**Not in scope** by design: localised relative strings (use `IntlDateFormatter::formatRelative` for multi-language), business-day arithmetic, calendar systems. Standard PHP `format()` / `setTimezone()`/ `setTime()` / `modify()` etc. are inherited untouched.

### `Cloude\Http\ErrorHandler`

[](#cloudehttperrorhandler)

Drop-in global handler. Defaults unhandled errors to **503** (temporary unavailability — crawlers retry instead of deindexing the URL). Throw a `Cloude\Http\HttpException` (or its `NotFoundException` subclass) to override the status — see [`Cloude\Http\HttpException`](#cloudehttphttpexception--notfoundexception)below.

Negotiates the response format from the request context:

Request looks like…Response`PHP_SAPI === 'cli'`plain text on STDERR`Accept: application/json` *or* `Content-Type: application/json`JSON`X-Requested-With: XMLHttpRequest` (classic jQuery AJAX)JSONURL path ends in `.json`JSONURL path ends in `.md`text/plain (markdown)Everything elseHTML view```
ob_start();
\Cloude\Http\ErrorHandler::register(
    debug:    DEBUG,
    viewBase: dirname(__DIR__) . '/app/views', // optional override directory
);
```

In debug mode, HTML responses include source snippet and stack trace (`500-debug.html.php`, shared by every status). In production, the HTML template is chosen by status: 404 → `404.html.php`, anything else → `500.html.php`. If `viewBase` is set and the file exists there, it overrides the framework default — drop a `404.html.php` next to your other views to brand the page.

The content-negotiation logic is exposed as `ErrorHandler::negotiate($server)`(pure function over a `$_SERVER`-shaped array, returns `'json' | 'md' | 'html'`) so you can unit-test consumers without touching the global state.

### `Cloude\Http\HttpException` + `NotFoundException`

[](#cloudehttphttpexception--notfoundexception)

Throw one of these from anywhere in a request handler and `ErrorHandler`renders with the carried status code (instead of falling back to 503). Headers and templates follow the status:

- **404** — bundled `404.html.php` view (no `Retry-After`)
- **other** — falls back to the `500.html.php` template
- **503** — adds `Retry-After: 600` and uses `500.html.php`

```
use Cloude\Http\HttpException;
use Cloude\Http\NotFoundException;

// Idiomatic 404 from a controller:
$book = $repo->find($isbn) ?? throw new NotFoundException("book $isbn");

// Any HTTP status — message goes into the JSON / debug output:
throw new HttpException(403, 'forbidden');
```

`HttpException` is the base class with a `public readonly int $statusCode`. `NotFoundException` is just `HttpException` with the code pinned to 404 and a default message of `'Not found'`. No other subclasses ship — make your own (`class ForbiddenException extends HttpException { public function __construct(string $m = 'Forbidden') { parent::__construct(403, $m); } }`) if a project wants named 401 / 403 / 422 helpers.

JSON responses for `HttpException`s look like:

```
{"error": "not_found", "status": 404}
```

In debug they include `message`, `file`, `line`, `trace`, and `status`. CLI prints `"Not found."` for 404, `"Error: service temporarily unavailable."` otherwise.

### `Cloude\Http\Cache`

[](#cloudehttpcache)

```
Cache::ok();                          // 1d at the CDN, browser revalidates every request
Cache::ok(3600);                      // 1h at the CDN, browser revalidates every request
Cache::ok(86400, 300);                // 1d at the CDN, 5min in the browser
Cache::notFound();                    // short CDN TTL on 404
Cache::unavailable();                 // no-store + Retry-After on 5xx

if (Cache::conditionalGet(filemtime($path))) {
    return; // 304 sent
}
```

`ok($stimeout, $timeout)` splits the TTL between the **shared cache**(`s-maxage` — CDN / reverse proxy) and the **browser** (`max-age`). Default `$timeout = 0` lets the browser hit the CDN on every request and get an instant 304 via `conditionalGet()` when nothing's changed.

### `Cloude\Http\AssetUrl`

[](#cloudehttpasseturl)

```
AssetUrl::configure(BASE_URL, __DIR__ . '/../www/assets');
echo AssetUrl::get('css/styles.css');
// → "{BASE_URL}/{mtime}/assets/css/styles.css"
```

Apache rewrite required:

```
RewriteRule ^[0-9]+/assets/(.*)$ /assets/$1 [L]
```

### `Cloude\Markdown\File`

[](#cloudemarkdownfile)

Disk I/O for markdown with transparent gzip. Pass plain `.md` paths; the class prefers `.md.gz` if it exists.

```
use Cloude\Markdown\File;

File::exists($path);                   // bool — .md or .md.gz
File::read($path);                     // string — auto-decompressed
File::readPrefix($path, 4096);         // first N bytes (for frontmatter)
File::mtime($path);                    // int
File::write($path, $content);          // writes .md.gz, removes .md
```

### `Cloude\Markdown\Server`

[](#cloudemarkdownserver)

Serves a markdown file with proper HTTP semantics (404 / 304 / canonical / gzip passthrough when the client supports it).

```
\Cloude\Markdown\Server::serve($path, BASE_URL . '/articles/foo');
```

### `Cloude\Format`

[](#cloudeformat)

One-stop conversion between strings and PHP arrays. Each top-level method dispatches by input type — string is decoded, array is encoded.

```
use Cloude\Format;

// JSON
Format::json('{"a":1}');                   // ['a' => 1]
Format::json(['a' => 1]);                  // '{"a":1}'
Format::json(['a' => 1], pretty: true);    // pretty-printed

// YAML (flat key:value, frontmatter-compatible)
Format::yaml("title: Hi\nflag: true");     // ['title' => 'Hi', 'flag' => true]
Format::yaml(['title' => 'Hi']);           // "title: Hi\n"

// XML — keys with '@' become attributes, '#text' is text content,
// list arrays repeat the element. See examples/recipes/sitemap.php.
Format::xml(['urlset' => [
    '@xmlns' => 'http://www.sitemaps.org/schemas/sitemap/0.9',
    'url'    => [['loc' => 'a'], ['loc' => 'b']],
]], pretty: true);

// Markdown → HTML
Format::markdown('# Hello **world**');     // "Hello world\n"
```

Explicit helpers when you want a fixed return type or DI-friendly call: `Format::jsonDecode`, `Format::jsonEncode`, `Format::yamlDecode`, `Format::yamlEncode`, `Format::xmlDecode`, `Format::xmlEncode`. JSON helpers throw `\JsonException` on errors. YAML encoding throws `\InvalidArgumentException` for nested arrays or non-identifier keys (YAML support is intentionally minimal — for nested data use JSON or XML).

`Cloude\Markdown::parse` (frontmatter + body) and `Cloude\JsonFile`delegate to `Format` internally, so behaviour stays consistent.

### `Cloude\JsonFile`

[](#cloudejsonfile)

Per-request cached, atomic-write helper for JSON files.

```
use Cloude\JsonFile;

JsonFile::read($path);                 // ?array — cached, null if missing/invalid
JsonFile::readOr($path, []);           // array — never null
JsonFile::write($path, $data);         // atomic (temp + rename); UNESCAPED_UNICODE | UNESCAPED_SLASHES
JsonFile::write($path, $data, true);   // pretty-print
JsonFile::clearCache();                // clear all, or pass a path
```

### `Cloude\EventLog`

[](#cloudeeventlog)

Fire-and-forget webhook POST for usage analytics. Reads from `EVENT_LOG_WEBHOOK`constant if defined, or from `EventLog::configure()`.

```
EventLog::configure('https://webhook.site/');
EventLog::send(['event' => 'page_view', 'path' => '/foo']);
```

Network call is deferred to `register_shutdown_function` and uses `fastcgi_finish_request()` when available — zero latency to the user.

### `Cloude\JsonSchema`

[](#cloudejsonschema)

Pragmatic, dependency-free JSON Schema validator. Covers the subset that matters for MCP tool inputs, REST request bodies, and config validation: `type`, `required`, `properties`, `additionalProperties`, `enum`, `items`, `minItems`/`maxItems`, `minimum`/`maximum`, `minLength`/`maxLength`, `pattern`.

```
$schema = [
    'type' => 'object',
    'properties' => [
        'country' => ['type' => 'string', 'pattern' => '^[a-z]{2}$'],
        'limit'   => ['type' => 'integer', 'minimum' => 1, 'maximum' => 1000],
    ],
    'required' => ['country'],
    'additionalProperties' => false,
];

$errors = \Cloude\JsonSchema::validate($args, $schema);
if ($errors !== []) {
    throw new \InvalidArgumentException(implode('; ', $errors));
}

\Cloude\JsonSchema::isValid($args, $schema);   // bool shortcut
```

Errors are human-readable strings prefixed with the JSON pointer of the offending node — e.g. `$.country: value does not match pattern '^[a-z]{2}$'`.

Out of scope (and probably forever): `$ref`, `allOf`/`oneOf`/`anyOf`, `not`, `if`/`then`/`else`, `format`, `patternProperties`, schema meta-validation. If you need any of those, install `opis/json-schema` and use it directly.

### `Cloude\Mcp\Server`

[](#cloudemcpserver)

A minimal MCP (Model Context Protocol) server: HTTP transport, JSON-RPC 2.0, input validation auto-wired against each tool's `inputSchema` via `Cloude\JsonSchema`.

```
use Cloude\Mcp\JsonRpc;
use Cloude\Mcp\McpException;
use Cloude\Mcp\Server;

$mcp = new Server(
    name:        'my-data',
    version:     '1.0',
    description: 'Public dataset.',
    endpoint:    BASE_URL . '/mcp',
);

$mcp->tool(
    name:        'echo',
    description: 'Echoes the message.',
    inputSchema: [
        'type'       => 'object',
        'properties' => ['message' => ['type' => 'string', 'minLength' => 1]],
        'required'   => ['message'],
    ],
    handler: function (array $args): array {
        if ($args['message'] === 'forbidden') {
            // Structured errors → JSON-RPC error response with the right code.
            throw new McpException(JsonRpc::INVALID_PARAMS, 'forbidden message');
        }
        return ['content' => [['type' => 'text', 'text' => $args['message']]]];
    },
);

// Optional resource provider + reader.
$mcp->resourceProvider(fn() => [['uri' => 'mem://hi', 'name' => 'Hi', 'mimeType' => 'text/plain']]);
$mcp->resourceReader(fn($uri) => $uri === 'mem://hi'
    ? ['uri' => $uri, 'mimeType' => 'text/plain', 'text' => 'world']
    : null);

// Wire up routes.
$router->get('/.well-known/mcp.json', fn () => $mcp->respondManifest());
$router->any(['/mcp', '/mcp-server'], fn () => $mcp->dispatch());
```

What it handles for you:

- CORS headers + `OPTIONS` preflight (204).
- JSON-RPC parse + dispatch with the right error codes (`-32700`, `-32600`, `-32601`, `-32602`, `-32603`, `-32002`).
- Standard methods with sane defaults: `initialize`, `ping`, `notifications/initialized`, `notifications/cancelled`, `prompts/list`, `prompts/get`, `resources/list`, `resources/read`, `resources/templates/list`, `logging/setLevel`, `tools/list`, `tools/call`.
- `tools/call` validates `arguments` against the tool's `inputSchema` before the handler runs — bad input becomes a `-32602` response.
- `/.well-known/mcp.json` discovery manifest auto-generated from registered capabilities.

Out of scope: stdio transport, SSE/streaming, auth (do that in a route middleware before calling `dispatch()`).

See [`examples/recipes/mcp.php`](examples/recipes/mcp.php) for a runnable server.

### `Cloude\Cli`

[](#cloudecli)

Tiny helper for scripts under `app/cli/`: argv parsing + colored output with TTY detection.

```
#!/usr/bin/env php
