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.3.0
- v3.2.0
- v3.1.0
- v3.0.0
- 2.x-dev
- v2.2.0
- v2.1.1
- v2.1.0
- v2.0.0
- 1.0
- dev-JAM-30
- dev-php7
- 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-03 20:49:11 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()
turns 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$orderthrowsLLMExceptionat 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.
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. Turn 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.