prepmock / jambura-core
Lightweight PHP MVC framework
Requires
- php: >=8.1
- prepmock/idiorm: ^2.0
- robmorgan/phinx: ^0.16
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-master
- v3.4.0
- v3.3.0
- v3.2.0
- v3.1.0
- v3.0.0
- 2.x-dev
- v2.5.1
- v2.5.0
- v2.4.0
- v2.3.0
- v2.2.0
- v2.1.1
- v2.1.0
- v2.0.0
- 1.0
- dev-JAM-30
- dev-php7
- dev-JAM-33
- dev-llm/prompt-payload
- dev-JAM-27
- dev-JAM-25
- dev-JAM-23
- dev-docs/readme
- dev-ISMA-50
- dev-feature/JAM-21
- dev-feature/JAM-22
- dev-JAM-6
- dev-feature/JAM-17
- dev-JAM-13
- dev-feature/JAM-14
- dev-feature/JAM-16
- dev-feature/JAM-10
- dev-feature/JAM-11
- dev-JAM-8
- dev-JAM-5
- dev-feature/JAM-12
This package is auto-updated.
Last update: 2026-10-23 05:17:51 UTC
README
A small PHP MVC framework: a query-string router, controllers with layouts and flash messages, a REST controller base, and models built on idiorm. There is no service container, no config files and no code generation. An application defines a handful of constants and hands the request to the router.
Requirements
- PHP 8.1 or later for 3.x. Use 2.x on PHP 7.
- PDO, with the driver for your database.
- For clean URLs, a web server that rewrites them onto
index.php(see Routing).
Installation
composer require prepmock/jambura-core:^3.0
Composer installs idiorm and Phinx along with the framework:
| jambura-core | idiorm | Phinx |
|---|---|---|
| 3.x | prepmock/idiorm ^2.0 |
^0.16 |
| 2.x | prepmock/idiorm v1.0.0 |
0.11.6 |
Composer autoloads everything the framework ships: Jambura\Mvc\* and Jambura\LLM\* by
PSR-4, the Jambura\LLM class by classmap, the global
helpers (jRouter, jController, jModel, jAssets, jFlash, jCache, the jamex*
exceptions) and the Jambura bootstrap class as autoload files, and idiorm's ORM by
classmap. Do not include files from vendor/ by hand. A second include of an
autoloaded file is a fatal "cannot declare class" error. The one exception is the
data-structure classes, which are not autoloaded (see Helpers).
How a request flows
index.php → Router::route() reads ?controller=books&action=show
→ includes JAMBURA_CONTROLLERS/books.php
→ new Controller_books() constructor sets up assets, cache, session, flash,
then calls init()
→ Router::display() calls action_show(), then end()
A missing action means action_index. An unknown controller file throws
jamexBadController, and an unknown action throws jamexBadAction. Both extend
jamexPageNotFound.
Bootstrapping an application
The framework reads its paths and defaults from constants, and your application defines them:
| Constant | Read by | Meaning |
|---|---|---|
JAMBURA_CONTROLLERS |
Router | directory holding controller files |
JAMBURA_VIEWS |
Controller | directory holding view files |
JAMBURA_TEMPLATES |
Controller | directory holding templates and layouts |
DEFAULT_TEMPLATE, DEFAULT_LAYOUT |
Controller | the layout wrapped around every rendered view |
DEFAULT_PAGE |
Router | where a request with no controller is redirected |
ROOT |
jAssets | URL prefix put in front of asset paths ('' at the web root) |
JAMBURA_MOD |
Router::showErrorPage() |
'DEV' shows detailed errors; anything else does not |
A minimal index.php:
<?php require 'vendor/autoload.php'; define('ROOT', ''); define('JAMBURA_CONTROLLERS', 'app/controllers/'); define('JAMBURA_VIEWS', 'app/views/'); define('JAMBURA_TEMPLATES', 'templates/'); define('DEFAULT_TEMPLATE', 'default'); define('DEFAULT_LAYOUT', 'default'); define('DEFAULT_PAGE', 'home'); define('JAMBURA_MOD', 'DEV'); // Models are found by class name, so the application registers the autoloader that // maps Model_books to a file. Controllers need none: the router includes them itself. define('JAMBURA_MODS', 'app/models/'); define('JAMBURA_CLASSES', 'app/classes/'); spl_autoload_register(function ($class) { $file = strpos($class, 'Model_') === 0 ? JAMBURA_MODS . substr($class, strlen('Model_')) . '.php' : JAMBURA_CLASSES . strtolower($class) . '.php'; if (is_file($file)) { require_once $file; } }); ORM::configure('mysql:host=localhost;dbname=app;charset=utf8mb4'); ORM::configure('username', 'app'); ORM::configure('password', 'secret'); // The controller name becomes part of an include() path, and the router does not // check it. Without this, ?controller=../../x includes a file from anywhere. if (isset($_GET['controller']) && !preg_match('/^[A-Za-z0-9_-]+$/', $_GET['controller'])) { $_GET['controller'] = ''; } try { // route() returns null after redirecting a request that names no controller. if ($router = (new Jambura\Mvc\Router())->route()) { $router->display(); } } catch (jamexPageNotFound $e) { http_response_code(404); include '404.html'; }
Jambura::app()->setConfig($array)->routeRequest()->respond() is an alternative that
defines the path and view constants from an array. It does not configure ORM, define
JAMBURA_MOD or register a model autoloader, so you still do those yourself. It also
defines its constants unconditionally, so don't combine it with the define()s above:
defining a constant twice raises a warning.
Routing
The router only reads $_GET['controller'] and $_GET['action']. Clean URLs are a
rewrite onto that form, for example in Apache:
RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^([a-zA-Z0-9_-]+)/?$ index.php?controller=$1 [L,QSA] RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)/?$ index.php?controller=$1&action=$2 [L,QSA]
/books then runs Controller_books::action_index(), and /books/show?id=7 runs
action_show().
Controllers
A controller lives in JAMBURA_CONTROLLERS/{name}.php, is named Controller_{name},
and extends Jambura\Mvc\Controller (or its alias jController). Its actions are public
action_{name}() methods.
<?php // app/controllers/books.php class Controller_books extends Jambura\Mvc\Controller { public function init() { // Runs at the end of the constructor, before the action. The base class's // init() is empty, so this is purely your hook — an auth gate belongs here. } public function action_index() { $this->title = 'Books'; // $title in the view $this->books = ORM::for_table('books')->find_array(); // $books in the view $this->assets->addCSS('templates/default/css/books.css'); $this->render('books'); // app/views/books.php } public function action_show() { $id = (int) $this->__id; // $_REQUEST['id'], or false $book = $id ? Jambura\Mvc\Model::factory('books', $id) : null; if (!$book || !$book->loaded()) { $this->jFlash->error('No such book'); $this->redirect('/books'); // exits } $this->render('book', ['book' => $book]); } }
Request data. Reading any property that starts with __ reads that request
variable: $this->__id is $_REQUEST['id'], and it is false when absent, so check
it before use. get('x'), post('x') and header('Name') do the same for $_GET,
$_POST and request headers.
View data. Assigning a property ($this->title = ...) stores it for the view.
render('name', $extra) extracts those values, plus $extra, into local variables and
includes JAMBURA_VIEWS/name.php between
JAMBURA_TEMPLATES/{template}/layouts/{layout}/header.php and footer.php. Views run
inside the controller, so $this works there too:
<?php $this->assets->loadCSS('default'); ?> in a header prints the stylesheet tags
queued with addCSS().
$this->templateand$this->layoutchange the layout for one controller.$this->loadTemplate = falserenders the bare view.render()callsend()and returns. After the action returns, the router callsend()again, so anend()override runs twice on a rendered page.redirect($url, $permanent = false)sends a 302 (or 301) and exits.refRequest('x')reads the previous request's parameters after a redirect.cacheAndRender('books', ['key' => 'books', 'expiry' => 300])renders and stores the output in$this->cache(ajCache). Check$this->cache->isAvailable('books')and echo$this->cache->get('books')to serve it.
REST controllers
Extend Jambura\Mvc\Rest and implement authenticate(), which is abstract. init()
messages off the layout, records the request method, and calls authenticate(). A false
return, or an exception, answers 401 before any action runs.
<?php // app/controllers/booksapi.php class Controller_booksapi extends Jambura\Mvc\Rest { protected function authenticate() { return isset($_SESSION['user']); } public function action_list() { $this->checkRequestMethod('GET'); // 405 for anything else if (!$this->__shelf) { $this->sendError(400, 'shelf is required'); // sends and exits } $this->response['data'] = ORM::for_table('books') ->where('shelf', $this->__shelf) ->find_array(); } // the router calls end(), which sends $this->response as JSON }
- Put the result in
$this->responseand letend()send it. Don't callrender()in aRestcontroller: it sends headers and exits without a body. $this->respCodesets the status (default 200).setResponseType('text/xml')switches the formatter; JSON, XML and HTML are supported.sendError($code, $message)addserrorto whatever$this->responsealready holds, then exits. Resolve what can fail before you put anything into it.- The request body is read with
parse_str(), so only form encoding is understood. For JSON, decodefile_get_contents('php://input')yourself.
Requests and validation
$this->request() gives a controller one read-only Jambura\Mvc\Request describing the
call it is answering:
$r = $this->request(); $r->method(); // 'POST' $r->isMethod('post', 'put'); // any of them, whatever the casing $r->mime(); // 'application/json' - the body's media type $r->isJson(); $r->accepts(); // ['application/json', '*/*'] $r->header('Authorization'); // one header, any casing $r->bearerToken(); // the token out of it $r->path(); // 'books/show' $r->query('page', 1); // query string, with a default $r->input('isbn'); // the body: form-encoded or JSON, whichever arrived $r->all(); // query + body, the body winning a clash $r->only(['title', 'isbn']); $r->has('shelf'); $r->files(); $r->ip(); $r->roles(); // from the resolver you wire at bootstrap
The body is read for every method, so Rest::put() and Rest::delete() now return values
instead of false, and a JSON body needs no decoding of your own.
$this->request('id') still works as the old shortcut for $_REQUEST['id'].
Validating a request
validate() starts a validator with four links: the HTTP method, the caller's roles, the
shape of the payload, and anything else you write. Each is checked as you call it, and the
first failure ends the request:
public function action_create() { $book = $this->request()->validate() ->method('post') ->roles(['librarian', 'admin']) ->schema([ 'title' => 'required', 'isbn' => ['required', ['regex', '/^[0-9-]{10,17}$/', 'That ISBN does not look right']], 'copies' => ['required', 'int', ['min', 1]], 'shelf' => ['in', ['fiction', 'reference']], ]) ->check([$this, 'withinQuota']) ->validated(); // only the fields the schema named $this->respCode = 201; $this->response['id'] = Jambura\Mvc\Model::factory('books')->add($book); }
Write the links in the order you want them checked. Method, roles, schema, checks is the house order: cheapest first, and a caller without permission never learns whether their payload was valid.
Reusable specs
A validator built with new has no request, so it only records its links. That makes it a
reusable description of what a request must look like:
use Jambura\Mvc\RequestValidator; class BookRequests { public static function create() { return (new RequestValidator()) ->method('post') ->roles(['librarian', 'admin']) ->schema(['title' => 'required', 'isbn' => 'required']); } }
Hand it to validate(), and carry on chaining after it:
$book = $this->request() ->validate(ApiDefaults::jsonOnly(), BookRequests::create()) // run in the order given ->schema(['copies' => ['required', 'int', ['min', 1]]]) // merges with their schema ->check([$this, 'withinQuota']) ->validated();
Running a spec copies its links and leaves the spec alone, so one spec serves every request.
schema() merges and check() appends; method() and roles() replace, since there is
only one answer to each.
Keep spec builders cheap: literals only. Anything needing a query belongs in a function
rule or a check, so it runs only when the link is reached.
->schema(['shelf' => ['in', Shelf::allNames()]]) // queries on every request, even refused ones ->schema(['shelf' => ['function', 'shelfExists']]) // runs only if the schema is reached
Schema rules
The rules are Jambura\Mvc\Validator's, which are Model::validation()'s, so a rule reads
the same in a model and in a controller. Fields are read from the query string and the body
together.
| Rule | Passes when |
|---|---|
required |
the field arrived with something in it; an empty string counts as absent |
int, number, bool, email |
the value is one of those |
['regex', $pattern, $message] |
the pattern matches; the message is optional |
['in', ['a', 'b']] |
the value is one of the listed ones |
['min', 1], ['max', 10], ['between', 13, 120] |
the number is in range |
['length', 2, 60] |
the string's length is in range; the maximum is optional |
['function', 'shelfExists'] |
your method returns true; return a string to use it as the message |
A function rule calls the controller, and may be protected, as a model's validation
callbacks are. It is given the value and every value being checked. Every field is checked,
not just the first to fail.
Validator works on its own too, wherever you have an array to check:
$validator = Jambura\Mvc\Validator::make($payload, ['email' => ['required', 'email']]); if ($validator->fails()) { print_r($validator->errors()); // ['email' => ['email is required']] }
Checks of your own
A check is a controller method, a closure, or a class implementing
Jambura\Mvc\RequestCheck. It gets the Request, and returns true to pass, false for a
plain 422, a string for a 422 with that message, or an array to choose the status:
protected function withinQuota(Jambura\Mvc\Request $request) { return Quota::remaining($request->roles()) > 0 ? true : ['status' => 429, 'error' => 'Monthly quota used up']; } ->check([$this, 'withinQuota']) // protected is fine ->check(new DuringOpeningHours()) // implements RequestCheck ->check(fn (Request $r) => $r->input('to') !== $r->input('from') ?: 'Shelves must differ')
Roles
The framework does not know what a user is, so the application says once, at bootstrap:
// index.php Jambura\Mvc\RequestValidator::resolveRolesUsing(function () { return isset($_SESSION['user']) ? $_SESSION['user']['roles'] : null; // null = nobody signed in });
roles() is authorization; Rest::authenticate() stays authentication. A caller with no
session gets 401, and a signed-in caller without a listed role gets 403.
What a caller sees
| Status | When |
|---|---|
| 405 | the method is not allowed, with an Allow header naming the ones that are |
| 401 | the roles resolver reported nobody signed in |
| 403 | signed in, but holding none of the listed roles |
| 400 | a JSON body that would not decode |
| 422 | a schema failed, with fields naming the messages per field |
| yours | whatever status your own check asked for |
A Rest controller sends the status and stops, the way sendError() does:
{"error": "Validation failed", "fields": {"isbn": ["That ISBN does not look right"]}}
A plain controller has no JSON to send, so the failure throws jamexRequestInvalid, whose
status() and fields() your error page can render. So does a Request built by hand,
which is what makes the whole thing testable without a web server.
Models
A model wraps one table. It is named Model_{name}, lives where your autoloader finds
it, and should always set $tableName.
<?php // app/models/books.php class Model_books extends Jambura\Mvc\Model { protected $tableName = 'books'; protected $relations = [ // c2p: this table holds the foreign key 'author' => ['table' => 'authors', 'column' => 'author_id', 'direction' => 'c2p'], // p2c: another table points at this one; type is 1-1 or 1-M 'reviews' => ['table' => 'reviews', 'column' => 'book_id', 'direction' => 'p2c', 'type' => '1-M'], ]; protected function validation() { return [ 'isbn' => ['regex', '/^[0-9-]{10,17}$/', 'That ISBN does not look right'], 'title' => ['function', 'notBlank'], ]; } protected function notBlank($value) { return trim((string) $value) !== ''; } protected function beforeSave() { // runs before every insert and update } }
$book = Jambura\Mvc\Model::factory('books', 7); // the row, and its relations if ($book->loaded()) { echo $book->title; // false if there is no such column if ($book->author) { // relations are idiorm objects, not models echo $book->author->name; } $book->title = 'Dune'; // validated, and the change is recorded $book->save(); // the id, or false print_r($book->diff()); // ['title' => ['oldValue' => ..., 'newValue' => 'Dune']] } $isbn = Jambura\Mvc\Model::factory('books')->loadBy('isbn', '978-0441013593'); Jambura\Mvc\Model::factory('books')->add([ 'title' => 'Dune', 'created_at' => ['created_at', 'NOW()'], // a two-item array is an SQL expression ]);
factory('books', $id)takes the name afterModel_, not the class name.new Model_books($id)is equivalent.- Check
loaded()only after loading something. A model made without an id (factory('books')) reportsloaded()as true, because it holds a fresh, empty row. loadBy($column, $value)and the magicloadBy{Column}($value)return the model either way, so checkloaded(). The magic form takes the column name verbatim from the method name:loadByIsbnqueriesIsbn.- Inside a model,
$this->tableis the idiorm query, so custom finders chain on it.find_many()gives idiorm objects, not models. - Validation runs on property assignment. A failed rule throws
Jambura\Mvc\JamburaValidationError.add()assigns directly and skips validation. findAll()returns an array.findAll('stack')andfindAll('queue')wrap it injStackorjQueue.isNewRecord(),isChanged(),set($column, $value)(chainable),set_expr()anddelete()round out the API.
Helpers
| Class | What it does |
|---|---|
jAssets |
$this->assets: queue files with in('footer')->addJS(...) / addCSS(...), print them with loadJS('footer') / loadCSS('default'). Adding a file that doesn't exist throws |
jFlash |
$this->jFlash and $jFlash in views: success, error, warning and info set a session message; getMsg(), getType() and clear() read it |
jCache |
$this->cache: a JSON file cache in /tmp/, with store($key, $data, $seconds), get, isAvailable, erase and eraseExpired |
jRouter, jController, jModel |
global aliases for Jambura\Mvc\Router, Controller and Model |
jamex, jamexPageNotFound, jamexBadController, jamexBadAction |
the router's exceptions |
jamexRequestInvalid |
a request a validator refused, outside a REST controller: status() and fields() say why |
jStack, jQueue |
wrappers returned by findAll('stack' / 'queue'). Not autoloaded: require src/data-structure/jdatastructures.php and the class file before using them |
Router::showErrorPage($page, $exception) logs through a \Logger class your
application must provide. With JAMBURA_MOD set to 'DEV' it renders the exception with
filp/whoops, which is not a dependency of this package,
so require it yourself if you use this.
LLM adapters
Jambura\LLM gives every model the same interface. Your code builds one
Jambura\LLM\Prompt and hands it to a model adapter, and only the adapter knows how that
model's API wants the prompt formatted and called. The framework ships no adapters: you
write one class per model you use, and register it.
A prompt keeps its parts separate, so each adapter can format them its own way:
use Jambura\LLM\Prompt; $prompt = Prompt::create() ->setType('voyage-summary') // for routing; adapters don't send it ->setRole('You are a shipping analyst.') ->addContext('static', 'Port rules: ...') ->addContext('retrieved', $eta, $berth) // static, retrieved, dynamic or conversation ->addInstruction('Be brief.', 'Cite the context you use.') ->setTask('Summarize the delay for the charterer.');
getContext() returns only the sections that have items, always in the order listed
above. An unknown section name throws Jambura\LLM\LLMException.
An adapter extends Jambura\LLM and implements send(). The framework formats the
prompt before send() runs, and two optional properties control how:
<?php namespace AIModel; use Jambura\LLM; use Jambura\LLM\Format; use Jambura\LLM\Prompt; class Example extends LLM { protected string $model = 'example-large'; // Format::Xml (the default), Format::Json or Format::Text. protected Format $format = Format::Json; // Sections rendered before the task, in this order. The default is // ['role', 'context', 'instructions']. This API takes the role separately. protected array $order = ['context', 'instructions']; protected function send(string $formattedPrompt, Prompt $prompt): string { // Put $formattedPrompt, and $prompt->getRole() since role isn't in $order, into // the request the model's API expects. Make the call with your HTTP client or the // provider's SDK, and return the reply text. Throw Jambura\LLM\LLMException when // the call fails. } }
The same prompt in each format, with the default order:
<role>Analyst</role> <context> <retrieved> <item>ETA 14:00</item> </retrieved> </context> <instructions> <instruction>Be brief.</instruction> </instructions> <task>Summarize.</task>
{
"role": "Analyst",
"context": {
"retrieved": ["ETA 14:00"]
},
"instructions": ["Be brief."],
"task": "Summarize."
}
Role:
Analyst
Context (retrieved):
- ETA 14:00
Instructions:
- Be brief.
Task:
Summarize.
- The task is always rendered last, whatever
$ordersays.$ordercan reorderrole,contextandinstructions, or leave any of them out. Listingtask, an unknown name or the same section twice throwsLLMExceptionwhen the class is registered. - Empty sections are left out in every format, and the prompt's
typeis never rendered. - XML and JSON escape the prompt's text, so a context item can't close a tag or end a
string early. Text can't escape anything: an item containing
Task:reads like a heading. Use Text only for content you trust. - For a format the list doesn't cover, override
handlePrompt(Prompt $prompt): string. Whatever it returns is passed tosend().
Register adapters once, at bootstrap, then use them anywhere:
use Jambura\LLM; LLM::registerModels([AIModel\Example::class, AIModel\Another::class]); $reply = LLM::use(AIModel\Example::class)->prompt($prompt);
registerModels()checks each class straight away. A class that doesn't exist, doesn't extendJambura\LLM, is abstract, or has an invalid$orderor$keepMessagesthrowsLLMExceptionat bootstrap, not on the first prompt.use()builds an adapter the first time and returns that same instance after. Using a class that was never registered throws.forgetModels()empties the registry.prompt()only accepts aPrompt, throws if it has no task, and returns the reply fromsend().use()builds adapters through a final, protected constructor, sonewcan't be used. Give an adapter its defaults as property values and its settings through setters.setModel()andgetModel()are already there for the model id.
Conversations
prompt() is one shot: it remembers nothing, and two calls know nothing of each other.
chat() is the same call with history either side of it.
$reply = LLM::use(AIModel\Example::class)->chat($prompt, $threadId);
That loads the messages stored under $threadId, puts them on a copy of the prompt, calls
prompt(), then appends the question and the reply to the history. The adapter's
$format, $order and send() are unchanged, and an adapter need not know chat()
exists.
A thread id nothing has been stored under starts a new conversation, so there's no
start-or-resume call to get wrong. The id is yours to choose: a user id, a chat row id, a
uuid you hand the browser. A blank one throws LLMException.
Messages are what a conversation is made of, and they live on the Prompt, apart from
the context sections:
$prompt->addMessage(Prompt::USER, 'What is my leave balance?') ->addMessage(Prompt::ASSISTANT, 'You have 12 days.'); $prompt->getMessages(); // [['role' => 'user', 'text' => '...'], ...], oldest first
chat() adds these for you. Add them by hand to give a one-shot prompt() some history
without storing any. USER and ASSISTANT are the only roles — a system message is what
setRole() is for — and anything else throws. The text isn't checked: an empty reply is
the adapter's to refuse, not the prompt's.
An adapter sees messages twice over, and should use whichever it has a use for:
$prompt->getMessages()insend(), for an API with a real message list. This is the reason messages aren't just text: a chat API wants[{role, content}, ...], and building that from a rendered blob means parsing your own output back.- the
conversationcontext section of the formatted prompt, for an API that takes one block of text.handlePrompt()renders each message asUser: .../Assistant: ...there, in whatever format the adapter uses, so an adapter written before any of this existed still sees the conversation.
An adapter gets both by default, which for the first kind means sending the history twice over — once as messages, once as prose, at twice the tokens. Message the rendering off:
class Example extends LLM { protected bool $renderMessages = false; // send() reads getMessages() and builds messages }
Dropping context from $order would do it too, but it also costs you the static and
retrieved context. $renderMessages only takes out the conversation.
That second path runs through context, so an adapter that renders messages must order
context - otherwise the conversation would go nowhere while the store kept growing.
registerModels() rejects that combination outright, so it fails where the adapter is
defined rather than in production. Leave context out only with $renderMessages = false,
where send() handles the messages itself.
Set both and the history goes to the model twice - once as messages, once as prose, at
twice the tokens. Turn the rendering off whenever send() reads getMessages().
Where history lives is your application's business, so the framework asks for an interface and takes one line at bootstrap:
LLM::storeThreadsIn(new DatabaseThreadStore());
interface ThreadStore { public function read(string $threadId): Thread; // a loaded value object /** @param array $messages list<array{role: string, text: string, author?: string|null}> */ public function append(string $threadId, array $messages): void; public function forget(string $threadId): void; }
Jambura\LLM\Thread is a plain value object - id(), messages(), recent($count),
isEmpty(), count() - and reaches nothing. That is what lets a prompt take a conversation
without depending on where conversations live:
$prompt->loadConversation(LLM::threads()->read($threadId)->recent(10)); $prompt->forgetConversation(); // and send the same prompt without it
chat() does exactly that on a copy of your prompt, so you only reach for these to use a
history with plain prompt(), or to render a thread in a UI.
Leave that line out and chat() falls back on Jambura\LLM\CacheThreadStore, over jCache,
so a conversation works with no wiring at all. Don't leave it in production. jCache
keeps every key in one JSON file and rewrites the whole file on each write, so two requests
appending to the same thread at the same moment don't interleave their messages — the second
write is built from the state the first hadn't finished writing, and a message is lost
outright. It's enough to try chat() out and enough for a single-process script. It is not
enough for two users on one thread id.
A table for a real store:
public function change(): void { $this->table('thread_messages') ->addColumn('thread_id', 'string', ['limit' => 64]) ->addColumn('role', 'string', ['limit' => 16]) ->addColumn('author', 'string', ['limit' => 64, 'null' => true]) ->addColumn('text', 'text') ->addColumn('created_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP']) ->addIndex(['thread_id', 'id']) ->create(); }
read() is then SELECT ... WHERE thread_id = ? ORDER BY id wrapped in a Thread, and
append() is an insert per message. Keep append() append-only — never a read-modify-write of the whole
history — so that two writers interleave rather than clobber.
Who said it is optional, for a conversation with more than one person in it. The wire
format only has user and assistant, so an author is rendered as a prefix - but it is kept
as a field, in the prompt and in the store, so a UI can show it without parsing text apart:
$prompt->addMessage(Prompt::USER, 'what is the ETA?', 'Kamrul'); // rendered as `Kamrul: what is the ETA?`, and as `Assistant: ...` where there is no author
How much history is sent is a property on the adapter, set like the others:
class Example extends LLM { protected int $keepMessages = 10; // most recent messages sent with each prompt; 0 sends none }
Older messages stay in the store; they're simply not sent. Trimming is about what reaches the model, not about what you keep. It counts messages, not tokens — a token budget wants a compressor to fall back on, and that isn't written yet. A negative value throws at registration.
The rest of the behaviour, briefly:
- A failed call stores nothing. If
send()throws, neither the question nor a reply is kept, so a retry on the same thread id doesn't find half an exchange waiting. - History belongs to the thread id, not the adapter. Answer with one model and
escalate the same conversation to another: the store records no adapter class, which is
what a model-agnostic
Promptis for. - Two writers, last one wins. Two browser tabs on one thread id can interleave.
append()being append-only keeps that as harmless as it can be. Locking is yours. - The prompt you hold is never touched. Messages go onto a copy, so one prompt can be chatted twice without collecting history as it goes.
- Only the task and the reply are stored. Messages you added to the prompt by hand are sent, after the stored history, but aren't written to the store — they belong to the one call.
chat()isfinal, asprompt()is, so an adapter can't half-override the history handling.- Reading and dropping a conversation go through the store, which already does both:
LLM::threads()->read($threadId)->messages()andLLM::threads()->forget($threadId).LLM::forgetThreadStore()drops the configured store, which tests want between cases.
LLM pipelines
Jambura\LLM\Pipeline puts a job's steps in order and runs them over one shared
Jambura\LLM\Context. Define a pipeline where the application boots, then run it from a
controller, a command or a queue job.
use Jambura\LLM\Pipeline; Pipeline::make('mates_receipt') ->configure(['currency' => 'CAD']) ->gatekeeper('check_attachment', AttachmentGuard::class) ->preprocessor('save_attachments', FileStorage::class) ->preprocessor('attach_vendor', VendorLookup::class) ->model('process_document', AIModel\Claude::class) ->step('file_receipt', ReceiptFiler::class) ->route('default', ['check_attachment', 'save_attachments', 'attach_vendor', 'process_document', 'file_receipt']) ->route('retry', ['process_document', 'file_receipt']);
$prompt = Prompt::create() ->setType('receipt') ->setTask('Extract the total and the vendor.'); $context = Pipeline::use('mates_receipt')->followRoute('default')->feed($prompt, [ 'attachments' => $request->files('attachments'), 'user_id' => $userId, ]); $context->get('response'); // the model's reply $context->prompt(); // the prompt as the steps left it $context->ranSteps(); // the steps that ran, in order $context->wasStopped(); // true when a gatekeeper ended the run early
The verbs. Each registers one step. The step's name comes first, because that is what routes refer to, and the kind decides both what the step must implement and what the pipeline does with what it returns:
| Verb | The step is | It must | Its return value |
|---|---|---|---|
gatekeeper() |
a check that can stop the run | implement Gatekeeper: allows(Context): bool|array |
false stops the run; an array adds values |
preprocessor() |
preparation before the model | implement Preprocessor: process(Context): array |
the array adds values to the context |
model() |
the model call | be an adapter - a Jambura\LLM subclass |
the reply is stored as response |
step() |
anything else, such as filing the result | implement Step: handle(Context): mixed |
an array adds values |
The method is never named in the definition: it comes from the interface, so a step class has one entry point.
A model step needs no class of your own. It names one of your adapters, and the pipeline sends the run's prompt through it:
->model('process_document', AIModel\Claude::class) // LLM::use(...)->prompt($context->prompt())
The adapter is registered with Jambura\LLM for you, and it decides the format and order
as always. Work around the call belongs in its own step: prepare the prompt in a
preprocessor before it, and parse or validate the reply in a step() after it.
Steps work on the run's prompt. A preprocessor adds what it found to the prompt's own context sections rather than assembling text of its own:
use Jambura\LLM\Context; use Jambura\LLM\Preprocessor; class VendorLookup implements Preprocessor { public function process(Context $context): array { $vendor = Vendor::findBy($context->get('user_id')); $context->prompt()->addContext('retrieved', "Vendor: {$vendor->name}"); return ['vendor_id' => $vendor->id]; } }
A step that would rather replace the prompt than add to it calls
$context->setPrompt($other), which is what a filtering or trimming step does.
Closures stand in for any verb where a class would be too much, and take the same name argument:
->gatekeeper('positive_total', fn (Context $c) => $c->get('total') > 0) ->step('flag_for_human', fn (Context $c) => ['queued' => true])
They follow the same return rules, but can't be reused across pipelines and won't appear as a named class in a stack trace.
Routes. route() names an order, and a pipeline can hold several over the same steps -
a default route and a retry route that skips the expensive parts. followRoute() picks
one and the choice sticks to the pipeline; feed() falls back to default when nothing was
chosen. Steps and routes can be declared in any order, and every name in a route is checked
before the first step runs, so a typo does no work.
feed() takes a Prompt and nothing else, for the same reason LLM::prompt() does: a
run always carries the structured prompt, and no step has to guess what a loose string was
meant to be. Anything that isn't part of the prompt - uploads, ids, records - travels beside
it as the run's values, the second argument. The prompt is copied before the run, so steps
add to the run's own copy and the object you passed stays as it was, ready to feed to
another pipeline.
The context. prompt() and setPrompt() reach the run's prompt; get(), set(),
merge(), has() and all() carry the run's values; setting() reads what configure()
was given. ranSteps(), wasStopped() and stoppedAt() say what happened, which is what
you log or assert on.
Errors. Everything throws Jambura\LLM\LLMException, and every case is a mistake in the
definition or the call: an unknown pipeline or route, a name used twice, a step class that
doesn't implement its verb's interface or a model step that isn't an adapter, or a route
naming a step that was never registered. A gatekeeper stopping a run is not one of them:
feed() returns the context with wasStopped() true, so the caller decides what that
means.
Migrations
robmorgan/phinx is installed with the framework, so vendor/bin/phinx is available
to every application.
Phinx 0.16 changed two defaults: an implicit id is now int unsigned, and columns are
nullable unless told otherwise. Migrations written under Phinx 0.11 (jambura-core 2.x)
replay into a different schema under those defaults. They can even fail outright, once a
signed foreign key points at an unsigned id. Message both off in phinx.php:
'feature_flags' => [ 'unsigned_primary_keys' => false, 'column_null_default' => false, ],
Upgrading from 2.x
- PHP 8.1 is the minimum.
- Remove any loop that includes
src/*.phpby hand.src/jambura.phpis autoloaded now, and including it again is a fatal redeclaration. - idiorm 2 no longer implements
Serializable. Serializing a result set now works: it used to throw aTypeError. - If you have existing migrations, add the Phinx feature flags above.
Model::each()works again. It called PHP's removedeach()and was fatal on PHP 8.
Known issues
Router::route()does not validate the controller name before including it. Sanitize$_GET['controller']first, as the bootstrap example does.
License
MIT © 2023 Prepmock Online Inc.