cloude/framework

Minimalist PHP 8.4+ 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.

Maintainers

Package info

github.com/polmartinez/cloude-php-workspace

pkg:composer/cloude/framework

Statistics

Installs: 29

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.29.0 2026-05-11 11:07 UTC

README

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.4+
  • 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

Cloude is shaped while pair-coding with 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 — 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 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.

Installation

composer require cloude/framework

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
      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 500 / 500-debug views (overridable)
  tests/                 # PHPUnit tests
  examples/              # Runnable sample apps (see examples/README.md)
    recipes/             # Cookbook snippets for sitemap, JSON-LD, ...
    contacts/            # Mini address-book: form + live search demo
  composer.json          # Package manifest (name: cloude/framework)
  phpunit.xml.dist
  .php-cs-fixer.dist.php
  LICENSE
  README.md

Components

Core

Class Responsibility
Cloude\Arr Array helpers with dot-notation: get/set/has/forget/pluck/only/except/dot/undot/merge
Cloude\Bootstrap One-call front-controller bootstrap (cli-server passthrough + ob_start + ErrorHandler + view base)
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 Bootstrap helpers: env(), boolEnv(), defineBaseUrl(), defineDebug()
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\Str String utilities: upTo/truncate/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\…

Class Responsibility
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 503 handler with HTML / JSON / .md negotiation, debug mode
Cloude\Http\Response One-call response helpers: json, html, xml, markdown, redirect, notFound, noContent

Cloude\Data\…

Class Responsibility
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\Markdown\…

Class Responsibility
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\…

Class Responsibility
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\Mcp\McpException Structured-error throwable for tool / resource handlers

Quick start

A typical www/index.php looks like this:

<?php

declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/../app/config.php';   // defines BASE_URL, DEBUG, ...

if (\Cloude\Bootstrap::serveStaticIfExists(__DIR__)) {
    return false;   // dev-server static-file passthrough
}

\Cloude\Bootstrap::run(
    debug:    DEBUG,
    viewBase: __DIR__ . '/../app/views',
);

use Cloude\Http\Response;
use Cloude\Input;
use Cloude\Router;
use Cloude\View;

$router = new Router(BASE_URL);

$router->get('/', fn () => View::render('home.php', ['title' => 'Hello']));

$router->get('/users/{id:\d+}', fn (array $p) => Response::json(['id' => $p['id']]));

$router->group('/api/v1', function (Router $r) {
    $r->post('/echo', fn () => Response::json(Input::json() ?? []));
});

$router->setNotFound(fn () => View::render('404.php'));
$router->dispatch();

Bootstrap::run wires up ob_start, the global 503 error handler, and the view base path in one call. Drop in app/config.php and you have a complete front controller in ~15 lines.

Example projects

Ready-to-run sample apps live in examples/. Each one is fully self-contained — no cross-dependencies, no Apache or nginx required, no composer install needed when running from inside this repo. From the repository root:

php -S localhost:8000 -t examples/basic/www       # smallest skeleton
php -S localhost:8001 -t examples/contacts/www    # form + live JSON search
php -S localhost:8002 -t examples/library/www     # DDD layering
Folder What it shows
examples/basic/ Front-controller skeleton: routing, dynamic params, JSON echo, plain-PHP views
examples/contacts/ Form + JsonSchema validation + accent-insensitive search + fetch() from JS
examples/library/ DDD layering: Domain / Application / Infrastructure / Presentation
examples/recipes/ Standalone snippets — sitemap, JSON-LD, MCP, CLI tasks, repos

Run via Docker without installing PHP locally — see DEPLOYMENT.md.

Class reference

Cloude\Router

$router = new Router(basePath: '/api');       // optional, stripped from the URI

$router->get('/users/{id}', $handler);        // GET
$router->post('/users', $handler);            // POST
$router->any('/*', $handler);                 // any method
$router->add(['/foo', '/bar'], $handler);     // same handler, multiple routes

$router->setNotFound(fn() => ...);            // custom 404
$router->dispatch();

{name} segments are extracted into an associative array and passed as the first handler argument.

Pattern syntax:

Form Meaning
/{name} captures any non-slash segment as $params['name']
/{name?} optional — the segment and its leading / can be absent
/{name:regex} constrains the capture to regex (e.g. \d+, [a-z]{2})
/{name?:regex} optional + constrained

Examples:

$router->get('/users/{id:\d+}',     $h);   // matches /users/42, not /users/abc
$router->get('/posts/{slug?}',      $h);   // matches /posts AND /posts/hello
$router->get('/{lang:(es|en)}/...', $h);   // enforces 'es' or 'en' in URL

Route groups stack a URL prefix onto a block of routes. Nestable.

$router->group('/api/v1', function (Router $r) {
    $r->get('/parties',          $list);     // → /api/v1/parties
    $r->get('/parties/{slug}',   $show);     // → /api/v1/parties/{slug}

    $r->group('/admin', function (Router $r) {
        $r->get('/stats', $stats);           // → /api/v1/admin/stats
    });
});

Cloude\Input

Input::method();            // GET, POST, ...
Input::uri();               // path without query string, no double slashes
Input::get('q');            // $_GET['q'] or null
Input::post('name');        // $_POST['name'] or null
Input::json();              // decodes JSON body into an array
Input::body();              // raw request body
Input::header('User-Agent');
Input::ip(trustProxy: false);

Cloude\View

View::setBasePath(__DIR__ . '/views');

View::render('home.php', ['title' => 'Hello']);   // prints
$html = View::capture('home.php', $vars);         // returns a string
echo View::e($text);                               // HTML escape

Cloude\Markdown

$result = Markdown::parse($markdownContent);
// => ['meta' => [...], 'html' => '...', 'description' => '...', ...]

$html = Markdown::toHtml($md);

Supports minimal YAML frontmatter (single-line key: value pairs):

---
title: My article
description: Short summary
---

# Body...

The body is rendered with the in-house Cloude\Markdown\Parser. To swap in a different engine (e.g. Parsedown if you want its full feature set):

\Cloude\Markdown::useParser(fn (string $md) => (new \Parsedown())->text($md));

Cloude\Markdown\Parser

Minimalist Markdown → HTML parser. Covers the editorial subset:

Block Inline
ATX headings (#######) **bold** / __bold__
Paragraphs *italic* / _italic_
Unordered / ordered lists `inline code`
Fenced code blocks (```) [link](url "title")
Blockquotes (>) ![img](src "title")
Horizontal rules (---, ***) Hard line break ( \n or \\\n)
GFM tables with alignment (inline parser runs on cell contents)
| Type | Bore | License |
|---|:---:|---:|
| Shotgun | smooth | E |
| Rifle   | rifled | D |

Alignment colons in the separator row: |:---| left, |:---:| center, |---:| right. Without the separator the rows fall back to a paragraph (matches Parsedown). Inline markdown inside cells works: **bold**, `code`, [link](url), *italic*, images.

Not supported (by design): footnotes, definition lists, reference-style links, setext headings, nested lists. If you need any of these, plug Parsedown in via Markdown::useParser().

$html = \Cloude\Markdown\Parser::toHtml("# Hello\n\nFirst **paragraph**.");

Cloude\Str

// Basic manipulation
Str::upTo('hello world', ' ');           // 'hello'
Str::truncate('long text', 4);           // 'long...'
Str::words('a b c d e', 3);              // 'a b c...'
Str::after('foo.bar.baz', '.');          // 'bar.baz'
Str::afterLast('foo.bar.baz', '.');      // 'baz'
Str::between('hi [there] go', '[', ']'); // 'there'
Str::squish("  a\n  b\t  c ");           // 'a b c'
Str::mask('pedro@example.com', '*', 2);  // 'pe***************'
Str::mask('+34600123456', '*', 4, -3);   // '+346*****456'

// Slugs / transliteration
Str::slug('Hello World');                // 'hello-world'
Str::ascii('Análisis Político');         // 'Analisis Politico'
Str::ascii('Москва');                    // 'Moskva'

// Case conversion
Str::camel('user_profile_id');           // 'userProfileId'
Str::pascal('user_profile_id');          // 'UserProfileId'
Str::snake('userProfileId');             // 'user_profile_id'
Str::kebab('userProfileId');             // 'user-profile-id'

// Random / hash
Str::random();                           // 22-char URL-safe token
Str::random(32);                         // 43-char URL-safe token
Str::uuid();                             // RFC 4122 v4
Str::hash('hola');                       // sha256 hex
Str::hash('hola', 'sha1');               // any hash_algos() entry

ascii() transliterates without lowercasing or stripping punctuation — useful for fuzzy matching and search indexes. For URL slugs use slug(). random() and uuid() use random_bytes (cryptographically strong).

Cloude\Arr

Array helpers with dot-notation access. The handful of methods you actually reach for when working with deeply-nested PHP arrays.

use Cloude\Arr;

Arr::get($cfg, 'db.read.host', 'localhost');     // dot-path with default
Arr::set($cfg, 'db.read.port', 5432);            // creates intermediates
Arr::has($cfg, 'db.read');                       // bool
Arr::forget($cfg, 'db.read.port');               // unset

// Working with row lists
Arr::pluck($users, 'name');                      // [0 => 'Ana', 1 => 'Bea']
Arr::pluck($users, 'name', 'id');                // [42 => 'Ana', 51 => 'Bea']
Arr::pluck($events, 'meta.title');               // dot-path supported

// Subset / inverse
Arr::only($row, ['id', 'name']);
Arr::except($row, ['password', 'salt']);

// Flatten / inflate
Arr::dot(['a' => ['b' => 1]]);                   // ['a.b' => 1]
Arr::undot(['a.b' => 1]);                        // ['a' => ['b' => 1]]

// Recursive merge — later overrides earlier; lists replaced wholesale
Arr::merge(['db' => ['host' => 'a', 'port' => 1]], ['db' => ['port' => 2]]);
// => ['db' => ['host' => 'a', 'port' => 2]]

Convention: associative arrays are walked recursively, list arrays (numeric 0..n keys) are treated as leaves — matching how JSON decodes objects vs arrays.

Cloude\Collection

Fluent, chainable wrapper around an array — the small set of pipeline operations (map, filter, pluck, groupBy, sortBy, …) that turn data shuffling from nested loops into a one-line read.

use Cloude\Collection;

$top = Collection::make($users)
    ->filter(fn ($u) => $u['active'])
    ->sortBy('score', descending: true)
    ->take(3)
    ->pluck('name', 'id')
    ->all();

Terminals (return scalars / arrays):

$c->all();                    // array
$c->count();                  // int
$c->isEmpty(); $c->isNotEmpty();
$c->first(); $c->first(fn ($v) => ...);
$c->last();
$c->contains($value);
$c->every(callable);  $c->some(callable);
$c->reduce(callable, $initial);
$c->sum('column'); $c->avg('column'); $c->min('col'); $c->max('col');

Chainable (return a new Collection):

$c->map(fn ($v, $k) => ...);
$c->filter(?callable);  $c->reject(callable);
$c->each(callable);                        // returns $this; false stops
$c->pluck('column', 'key');                // dot-paths supported
$c->keyBy('column' | callable);
$c->groupBy('column' | callable);
$c->sortBy('column' | callable, descending: false);
$c->sort(?callable);
$c->reverse();
$c->take(int);  $c->slice($offset, $length);
$c->chunk(int);
$c->unique(?'column');
$c->values();  $c->keys();
$c->merge(...$others);

Collection implements ArrayAccess, Countable and is iterable, so $c[0], count($c) and foreach ($c as ...) all work as expected. Use make() to construct from arrays, generators, or other collections.

Cloude\Data\Repository and friends

A "directory of files" data layer for the typical Cloude project: every entity is one file (.json or .md), addressed by slug. The framework ships an abstract base plus two ready-to-use concretes; projects subclass the concrete that fits their format and add domain finders on top.

class PartiesRepo extends \Cloude\Data\JsonRepository
{
    public function __construct(string $country)
    {
        parent::__construct(DATA_DIR . "/scopes/{$country}/parties");
    }

    /** Normalize each row into a stable shape, slug included. */
    protected function transform(array $data, string $slug): array
    {
        return [
            'slug'   => $slug,
            'name'   => $data['nombre']             ?? $slug,
            'family' => $data['familia_ideologica'] ?? null,
        ] + $data;
    }

    public function byFamily(string $family): \Cloude\Collection
    {
        return $this->all()->filter(fn ($p) => $p['family'] === $family);
    }
}

$parties = new PartiesRepo('espana');

$parties->slugs();                     // ['psoe', 'pp', 'vox', ...]
$parties->exists('psoe');              // bool
$parties->find('psoe');                // ?array (null if missing)
$parties->findOr('psoe', []);          // array (default if missing)
$parties->all();                       // Cloude\Collection keyed by slug
$parties->byFamily('left');            // Cloude\Collection
$parties->write('new', [...]);         // atomic write (.json)

Inherited from Repository: exists, findOr, all, and the transform($data, $slug) hook for shape normalization. The default transform() attaches _slug so that pluck/groupBy results keep the slug accessible.

Implementations provided:

Class Path Read Write
JsonRepository {baseDir}/{slug}.json JsonFile::read (per-request cache) JsonFile::write (atomic)
MarkdownRepository {baseDir}/{slug}.md (also reads .md.gz) Markdown::parse (frontmatter + body) Markdown\File::write (gzipped)

MarkdownRepository::raw($slug) returns the unparsed markdown source if you want to serve it verbatim. Both classes accept a custom file extension via the constructor; override path() to change the layout entirely (e.g. sharded sub-directories).

all() returns a Cloude\Collection, so once you have a Repository the chainable pipeline is one method call away — see the recipe for the full pattern.

Cloude\Bootstrap

Folds the canonical front-controller boilerplate (cli-server static-file passthrough + ob_start() + ErrorHandler::register() + View::setBasePath) into one call.

require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/app/config.php';

if (\Cloude\Bootstrap::serveStaticIfExists(__DIR__)) {
    return false;
}

\Cloude\Bootstrap::run(
    debug:    DEBUG,
    viewBase: dirname(__DIR__) . '/app/views',
);

$router = new \Cloude\Router(BASE_URL);
// ...routes...
$router->dispatch();

serveStaticIfExists() returns true only under the PHP built-in dev server when the request hits a real file inside $docroot. The caller MUST return false; from the router script in that case (cli-server's documented convention to delegate to its static handler). Production Apache uses .htaccess, so this is a no-op there.

If $viewBase is omitted, Bootstrap::run() reads View::getBasePath() as a fallback — handy when the project sets the view base in app/config.php.

Cloude\Http\Response

One-call response helpers — set status + Content-Type + body, then return.

use Cloude\Http\Response;

Response::json(['ok' => true]);                  // 200 + application/json
Response::json(['error' => 'nope'], 422);
Response::html('<h1>Hi</h1>');
Response::xml('<?xml version="1.0"?><root/>');
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

Bootstrap helpers for app/config.php. Reads from $_ENV / $_SERVER / getenv().

Config::env('OPENAI_API_KEY');                 // ?string
Config::boolEnv('DEBUG', false);               // bool

Config::defineBaseUrl([                        // → defines BASE_URL
    'www.example.com',
    'example.com',
    'localhost',
]);
Config::defineDebug();                         // → defines DEBUG

defineBaseUrl() validates $_SERVER['HTTP_HOST'] against the allowlist (matching hostname only, port preserved) to prevent host-header injection. A non-allowed host falls back to localhost.

Cloude\Http\ErrorHandler

Drop-in 503 handler. Treats unhandled exceptions as temporary unavailability (crawlers retry instead of deindexing). Negotiates HTML / JSON / .md based on Accept and URL extension.

ob_start();
\Cloude\Http\ErrorHandler::register(
    debug:    DEBUG,
    viewBase: dirname(__DIR__) . '/app/views', // optional override
);

In debug mode, HTML responses include source snippet and stack trace. The HTML response uses 500.html.php (or 500-debug.html.php in debug); if viewBase is set and the file exists there, it overrides the framework default.

Cloude\Http\Cache

Cache::ok();                          // long CDN TTL on 200
Cache::notFound();                    // short CDN TTL on 404
Cache::unavailable();                 // no-store + Retry-After on 5xx

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

Cloude\Http\AssetUrl

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

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

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

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**');     // "<h1>Hello <strong>world</strong></h1>\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

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

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/<uuid>');
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

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

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 for a runnable server.

Cloude\Cli

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

#!/usr/bin/env php
<?php
require __DIR__ . '/../../vendor/autoload.php';

use Cloude\Cli;

$args   = Cli::parseArgs($argv);          // ['_' => [...], 'dry-run' => true, 'limit' => '100', ...]
$dryRun = Cli::flag($args, 'dry-run');    // bool
$limit  = (int) (Cli::option($args, 'limit') ?? 100);
$path   = Cli::positional($args, 0);      // first non-flag argument

Cli::info("processing $limit items" . ($dryRun ? ' (dry run)' : ''));
if ($errors > 0) {
    Cli::abort(1, "$errors items failed");
}
Cli::success('done');

The -- token stops flag parsing — anything after goes to positional. Colors are emitted only when STDOUT is a TTY, so piping to a file produces clean plain text.

Cloude\Logger

File-backed logger with daily rotation.

$log = new \Cloude\Logger('/var/log/myapp.log', minLevel: 'info');
$log->info('http request', ['path' => '/foo']);
$log->error('db unreachable', ['code' => 503]);
// → /var/log/myapp-2026-05-07.log:
//   [2026-05-07T08:30:12Z] [INFO] http request {"path":"/foo"}
//   [2026-05-07T08:30:12Z] [ERROR] db unreachable {"code":503}

Levels: debug < info < warn < error. Messages below minLevel are dropped. Pass rotation: 'none' to disable rotation and always write to the configured path. Context arrays are appended as compact JSON.

Cloude\TaskRunner

Tiny CLI task runner — Artisan / Rake without the framework. Drop a single entry-point script (app/cli/tasks.php), register callables or static class methods, and you get list / help / dispatch for free.

use Cloude\Cli;
use Cloude\TaskRunner;

class ContentTasks
{
    /** Rebuild the search index for $country (default es). */
    public static function rebuildIndex(array $args): int
    {
        $country = Cli::option($args, 'country', 'es');
        $dryRun  = Cli::flag($args, 'dry-run');
        Cli::info("rebuilding index for {$country}" . ($dryRun ? ' (dry run)' : ''));
        return 0;
    }

    /** Drop content older than N days (default 90). */
    public static function purgeOld(array $args): int
    {
        $days = (int) (Cli::option($args, 'days') ?? 90);
        Cli::info("purging content older than {$days} days");
        return 0;
    }
}

$runner = new TaskRunner();
$runner->register('ping', fn () => Cli::out('pong'), 'Connectivity check.');
$runner->registerClass('content', ContentTasks::class);

exit($runner->run($argv));

Then from the shell:

php app/cli/tasks.php                                  # list every task
php app/cli/tasks.php help content:rebuild-index       # describe one
php app/cli/tasks.php content:rebuild-index --country=fr --dry-run
php app/cli/tasks.php content:purge-old --days=30

registerClass() walks the public-static methods of a class. Method names are kebab-cased (rebuildIndexrebuild-index), and the first non-blank line of each method's docblock becomes the description shown in list. Inherited methods are skipped, so you can compose multiple task classes without surprises.

Each handler receives the parsed $args array (Cli::parseArgs($argv)) and may return int (exit code), null/true/void (exit 0), or false (exit 1). Uncaught exceptions are reported via Cli::error() and the runner exits 1.

See examples/recipes/tasks.php for a runnable template.

Recipes (cookbook snippets)

examples/recipes/ ships drop-in snippets for common patterns the framework deliberately doesn't wrap in a class:

Recipe What it does
sitemap.php XML sitemap (and sitemap index) using Format::xml + Http\Response::xml
jsonld.php Schema.org JSON-LD blocks (Article, BreadcrumbList, FAQPage) using Format::json
mcp.php Tiny MCP server with two tools and a resource catalogue using Mcp\Server
tasks.php CLI task runner with one inline task and one task class using TaskRunner
data.php Custom JsonRepository and MarkdownRepository subclasses with transform() and domain finders

Each recipe is a single self-contained file with comments — copy, paste, edit.

Development

composer install
composer test        # phpunit
composer cs-check    # php-cs-fixer in dry-run mode
composer cs-fix      # apply fixes

Publishing to Packagist

  1. Push this repository to GitHub as a public repo.

  2. Submit the repository URL at https://packagist.org/packages/submit.

  3. (Recommended) Configure the GitHub -> Packagist webhook so new tags are picked up automatically.

  4. Tag a release:

    git tag -a v0.15.0 -m "v0.15.0"
    git push origin v0.15.0

After publication, any project can install it with:

composer require cloude/framework

Philosophy

  • No magic: the code you read is the code that runs. No generators, annotations or proxies.
  • Small classes: each class fits in a file you can read in one sitting — and so can Claude Code.
  • No required dependencies: the core pulls nothing in. ext-intl is recommended for slug transliteration but not required.
  • No global state: no container, no singletons. Static classes are just namespaces for functions.
  • AI-readable by design: explicit APIs, no DSL, no inheritance webs. The framework is small on purpose so an agent can edit any piece without missing context.

License

MIT - see LICENSE.