anode / cast-framework
CastFramework: a small PHP MVC framework with an SPA-ready view layer, built on CastTemplateEngine.
Package info
github.com/arnoldduo2/CastFramework
Type:composer-plugin
pkg:composer/anode/cast-framework
Requires
- php: >=8.1
- composer-plugin-api: ^2.0
- ext-json: *
- ext-mbstring: *
- ext-pdo: *
- anode/cast-template-engine: ^1.0
- anode/error-handler: ^1.0
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A small PHP MVC framework for apps that render pages on the server and enhance them with plain JavaScript and jQuery.
It is built on CastTemplateEngine (component tags in .cast.php views).
- Routing for GET, POST, PUT, PATCH and DELETE, with route groups, middleware and permission checks.
- A
Request/Responsepair, one global CSRF token checked centrally for every state-changing verb. - A validation system (string rules, rule objects, closures) plus the old pipe syntax for legacy code.
- Sessions with flash data, an
Authservice, a query builder that binds every value, a smallModel. - Views with auto-loaded page CSS and JS, error pages, maintenance mode, an update hook, a console (
php cast). - An optional SPA layer: opted-in pages load lazily and swap only their content (a small JS client, no build step);
Viewdecides full page, partial or modal. - A first-class JSON API (
routes/api.php): always-JSON errors, bearer tokens with abilities, rate limiting, CORS, for a front-end framework or other servers. - No ERP or business logic: it holds only what every app needs. See docs/ERP-PORTING.md for what was left out.
Requires PHP 8.1 or newer and ext-pdo, ext-mbstring, ext-json.
Status: version 0.x: the API may still change between minor versions (see the changelog).
Contents
Install · A first app · Configuration · Routing · Request and Response · CSRF · Controllers · Validation · Auth and services · Models and the query builder · Migrations · Views · Editor support · SPA · JSON API · Helpers · Static files · Errors, maintenance and updates · Console · Contracts · Security notes · Testing · Versioning
Install
mkdir my-app && cd my-app composer init --name=me/my-app --no-interaction composer require anode/cast-framework # answer "y" when Composer asks to trust the plugin (see below) php cast init # creates the app files here (add --demo for the full starter app) php cast serve # http://127.0.0.1:8000
cast init writes public/, bootstrap/, config/, routes/, a home page, .env (named after the folder), storage/ and the .gitignore lines, and adds the App\ namespace to
your composer.json (the app works right away: the framework maps App\ to app/ itself; composer dump-autoload -o is for production). It never overwrites a file you already have unless you pass --force (and never .env).
php cast init --demo copies the starter/ app instead: login (admin@example.com / password), an items page with PUT/DELETE and an edit modal, a stats page, and a
JSON API with bearer tokens, on SQLite.
The cast launcher. The package includes a small Composer plugin that puts the console launcher, a file named cast, in your project root after every install or update (it never overwrites
an existing one). That is what makes php cast <command> work from the first command. Composer asks you once to trust it; non-interactively, allow it first:
composer config allow-plugins.anode/cast-framework true. If you decline, php vendor/bin/cast init creates the same file.
A first app
my-app/
.env
bootstrap/app.php returns the Application (used by public/index.php and `cast`)
public/index.php the only file the web server runs
config/ app.php, auth.php, ... (only list what you change)
routes/web.php routes (every *.php in this folder is loaded)
routes/api.php optional: the JSON API, registered under /api
app/ Controllers/, Models/, Services/, Providers/, helpers/
resources/views/ layouts, pages, components/
resources/css, resources/js auto-loaded per page
storage/ logs, compiled views, maintenance state (must be writable)
public/index.php:
<?php require __DIR__ . '/../vendor/autoload.php'; $app = require __DIR__ . '/../bootstrap/app.php'; $app->run();
bootstrap/app.php:
<?php return new Cast\App\Application(dirname(__DIR__)); // An app with a different layout (like the ERP) can move folders: // new Application($base, ['paths' => ['config' => 'src/config', 'routes' => 'src/Routes', 'views' => 'src/resources/views', 'resources' => 'src/resources']]);
routes/web.php:
use Cast\Core\Router; Router::get('/', fn() => 'Hello'); Router::get('/hello/{name}', [App\Controllers\HelloController::class, 'show']);
app/Controllers/HelloController.php (create it with php cast make:controller Hello):
namespace App\Controllers; use Cast\Http\Controller; use Cast\Http\Response; class HelloController extends Controller { public function show(string $name): Response { return $this->view('hello.hello', ['parentName' => 'hello', 'pageName' => 'hello', 'authguard' => 'public', 'name' => $name]); } }
Configuration
.env
Read once per request. Supports KEY=value, export KEY=value, "quoted" and 'literal' values, # comments, and the casts
true, false, null, empty. Keys are case-insensitive. Real environment variables are used when the file has no entry.
env('APP_NAME', 'default'); // any value Env::bool('APP_DEBUG'); // 'false', '0', 'off', 'no', '' are false Env::int('PORT', 8000); Env::set('FEATURE', 'on'); // updates memory and rewrites only that line of the file
| Key | Default | Meaning |
|---|---|---|
APP_NAME |
Cast App |
Name used in pages and logs |
APP_ENV |
production |
development recompiles views when files change |
APP_DEBUG |
false |
Debug output and fresh ?v= asset versions |
APP_VERSION |
none | Asset cache-busting version (production) |
APP_BASE_PATH |
empty | URL folder when not at the web root, e.g. /my-app |
APP_TIMEZONE |
UTC |
Used by getDateTime() |
APP_VIEWS_EXT |
.cast.php |
View file extension |
DB_CONN, DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS |
mysql, 127.0.0.1 | Database (DB_CONN may be mysql, pgsql, sqlite; a trailing : is accepted) |
SESSION_NAME, COOKIE_LIFE, COOKIE_PATH, COOKIE_DOMAIN, COOKIE_SECURE, COOKIE_HTTP_ONLY, COOKIE_SITE |
see Application |
Session cookie |
CORS_ALLOWED_ORIGINS |
none | Comma separated exact origins |
config/*.php
Each file returns an array and becomes a top-level key (config/app.php => config('app.name')). Your files are merged over the framework
defaults: associative arrays merge key by key, lists and scalars replace. List only what you change.
config('app.name'); // dot notation config(['app.debug' => true]); // set Cast\Core\Config::has('auth.login_path');
| Key | Default | Meaning |
|---|---|---|
app.providers |
[] |
Your service providers (the core ones always load) |
app.middleware |
[Maintenance::class] |
Global middleware, run before routing |
app.base_path |
'' |
Stripped from the request path, added by route() |
app.auto_update |
false |
Run a bound updater automatically |
app.error_handler |
true |
Register anode/error-handler on web requests |
app.namespace, app.source_path |
App, app |
Where make:* commands create classes |
request.sanitizer |
null |
Callable that sanitises getPost() data |
auth.session_key, login_path, home_path, permissions_key |
login, /login, /, permissions |
Auth defaults |
view.ext, view.components |
.cast.php, components |
Views |
static |
css, js, styles, fonts, images, public | Static file map |
cors.allowed_origins |
[] |
Exact origins allowed |
spa.enabled, initial, root, view |
true, lazy, body, #cast-view |
SPA |
api.prefix, api.middleware, api.tokens.table |
/api, [], api_tokens |
JSON API |
models.namespace |
App\Models |
Where getModelInstance() looks |
helpers.custom |
null |
Folder of your own helper files |
database.* |
from .env |
driver, host, port, name, user, pass, charset, dsn, connection (a callable returning your own PDO), migrations.table / .path, seeder |
Providers
A provider binds services (register) and runs setup (boot, after every provider registered). List yours in config/app.php:
class AppServiceProvider extends Cast\App\ServiceProvider { public function register(): void { $this->app->singleton('auth', fn() => new Cast\Services\Auth(new UserStore())); } public function boot(): void { $this->app->make('view')->share('appName', config('app.name')); } }
Container: $app->bind($id, fn) (new each time), singleton, set($id, $value), make($id), has($id); app('view') is app()->make('view').
Core bindings: view, guard, maintenance, and error_handler (web only).
Routing
use Cast\Core\Router; use Cast\Http\Middleware\Authenticate; Router::middleware([Authenticate::class, 'private'], function () { // everything inside needs a login Router::group('/items', function () { Router::get('/', ItemsController::class); // class alone calls index() Router::get('/{id}', [ItemsController::class, 'show']); Router::post('/', [ItemsController::class, 'store']); Router::put('/{id}', [ItemsController::class, 'update']); Router::patch('/{id}', [ItemsController::class, 'patch']); Router::delete('/{id}', [ItemsController::class, 'destroy'])->middleware(['delete-items']); // permission slugs }); }); Router::match(['GET', 'POST'], '/search', $handler); Router::any('/ping', fn() => 'pong');
- Handlers: a closure,
[Class::class, 'method'],'Class::method', orClass::class(callsindex). A handler receives the route parameters ({id}), then the merged request input array as its last argument. Controllers are created per request withnew. - Return values: a
Responseis sent; an array becomes JSON; a string becomes HTML; a handler that echoes (and returns nothing) is captured (and recognised as JSON when it echoes JSON). - Matching: a static path beats a parameterised one registered earlier.
HEADuses theGETroute. - 404 and 405: an unknown path is a 404; a known path with another verb is a 405 with an
Allowheader (JSON for ajax/JSON clients, a page otherwise).Router::_404($handler)customises unmatchedGETrequests. - HTML forms and verbs: a
POSTwith_method=PUT|PATCH|DELETE(or theX-HTTP-Method-Overrideheader) is routed as that verb. - Route options:
->middleware(['slug', ...])permission slugs, any one is enough, checked with the boundGuard;->use([Class::class, ...$args])adds a middleware to one route;->withoutCsrf()skips CSRF for one route;->name('x').Router::exemptCsrf('/exact/path')exempts a path. - Middleware: a class implementing
Cast\Contracts\Middleware. The spec[Class::class, ...$args]callshandle(Request $request, ...$args). Returnnullto continue, aResponseto stop, or throwHttpException. Group middleware runs in the order registered, then the CSRF check, then permission slugs, then the handler.
php cast route:list prints the table of routes with handlers and middleware.
Request and Response
Cast\Http\Request is created once per request. In a controller it is $this->request; anywhere else request() or Request::current().
$request->method(); // 'PUT' (honours _method on POST) $request->path(); // '/items/5' without app.base_path $request->query('page', 1); // ?page= $request->input('name'); // body first, then query string $request->all(); only('a', 'b'); except('password'); has('a'); filled('a'); $request->body(); // parsed JSON or form body: works for PUT, PATCH and DELETE too $request->header('X-Foo'); ip(); isAjax(); isJson(); expectsJson(); $request->file('avatar'); // entry of $_FILES $request->route('id'); // route parameter
Input is returned raw. getPost() (the helper) returns the JSON body (or the form body with getPost(true)) passed through the sanitiser in
config('request.sanitizer') (default: trim). Set it to your own callable to apply an app-wide policy.
Cast\Http\Response:
return Response::success('Saved!', ['id' => 5]); // {"status":"success","msg":"Saved!","data":{"id":5}} return Response::error('Not allowed', 403); // {"status":"error","msg":"Not allowed"} return Response::error('Invalid', 422, ['email' => 'Required']); // + "data":{"errors":{...}} return Response::html($html, 200); return Response::redirect('/login'); // 302; second argument changes the status return Response::noContent(); return (new Response($body))->status(201)->header('X-Foo', 'bar');
AJAX endpoints always answer {status, msg, data}.
abort(404, 'Not here') (or throw new HttpException(404)) from anywhere stops with an error page, or JSON for ajax clients.
CSRF
One token per session (Session::csrfToken()), renewed on login/logout (Session::regenerate()).
- Forms:
<?= __csrf() ?>renders<input type="hidden" name="_token" ...>. - Ajax: send the header
X-CSRF-TOKEN(orX-XSRF-TOKEN), or_tokenin the JSON body. Put the token in a<meta name="csrf-token">. - Checked centrally for every POST, PUT, PATCH and DELETE route, with
hash_equals(API requests with a bearer token, or without a session cookie, are the one exception: see CSRF and cookies on the API). A missing or wrong token is a 419 (JSON{status:'error'}for ajax, a page otherwise). GET requests never need one. __verifyCsrf($token)checks a token by hand (throws 419).Csrf::valid($request)returns a bool.
Controllers
Extend Cast\Http\Controller. It holds only what every controller needs:
class ItemsController extends Controller { public function store(): Response { $data = $this->validate(['name' => 'required|min:2', 'qty' => 'required|int']); // throws ValidationException $id = Items::query()->insert($data); return $this->request->expectsJson() ? $this->success('Added', ['id' => $id], 201) : $this->redirect('/items'); } }
| Member | Purpose |
|---|---|
$this->request |
The current Request (works even if your constructor does not call parent::__construct()) |
view($view, $data, $status) |
Render a view as a Response |
success(), error(), redirect() |
Response shortcuts (redirect goes through route()) |
validate($rules, $messages, $labels) |
Validate the request input, return the clean data |
authorize($slug | array) |
403 unless the Guard grants it |
getModelInstance($name) |
Find a model class by name (config('models.namespace')) |
Business logic belongs in services, not in the controller.
Validation
use Cast\Validation\Validator; $v = Validator::make($request->all(), [ 'email' => 'required|email|unique:users,email', 'age' => ['required', 'int', 'between:18,99'], 'code' => ['required', new MyRule(), fn($value, $data, $field) => $value !== 'x'], 're' => ['regex:/^a|b$/'], // use the array form when a rule contains a "|" ]); $v->fails(); $v->errors(); // field => list of messages $v->firstErrors(); // field => first message $data = $v->validate(); // clean data, or throws ValidationException
Fields that are empty and not required skip their other rules. sometimes skips a field that is not in the data. required stops further rules for that field.
| Rule | Meaning |
|---|---|
required, required_if:field,value |
Present and not blank |
string, int / integer, numeric / number, float, bool / boolean, array, json |
Types |
email, url, ip |
Formats |
min:n, max:n, between:a,b, length:n |
Numbers by value, strings by length, arrays by count (length is characters) |
in:a,b, not_in:a,b, regex:/.../, alpha, alpha_num |
Values and patterns |
date, date:d/m/Y, before:date, after:date |
Dates |
same:field, confirmed (field_confirmation) |
Matching fields |
unique:table,column[,ignoreId[,idColumn]], exists:table,column |
Database checks (bound queries) |
file, max_kb:n, mimes:png,jpg |
Uploaded files |
password, password:len=10,uc=2 |
Strength (see PasswordPolicy) |
Messages: defaults like "Email is required."; :field is the label (first_name => "First Name"), :0/:1 are the rule parameters.
Override per rule (['required' => '...']), per field and rule (['email.required' => '...']), and set labels with the fourth argument.
Custom rules: a class implementing Cast\Contracts\Rule (passes(), message()), a closure, or Validator::extend('even', fn($value, $params, $data, $field) => ..., ':field must be even.').
php cast make:rule Uppercase creates one.
What happens on failure: validate() throws ValidationException. The Kernel answers 422 JSON {status:'error', msg, data:{errors:{field:'message'}}} for ajax/JSON
requests, and for browser forms it flashes input_errors and old (without passwords or the token) and redirects back to the referrer.
In views: <?php __invalidFeedback('email'); ?> prints the flashed message once; Session::peekFlash('old') has the previous input.
Legacy syntax: formValidation(['email' => $email . '|required|email', 'qty' => $qty . '|required|number|3']) (value, then required, email or number, then a minimum length)
keeps the old messages and the input_errors key.
Auth and services
Services are plain classes; the controller calls them.
$auth = new Cast\Services\Auth(new UserStore()); // UserStore implements Cast\Contracts\UserProvider if ($auth->attempt($email, $password)) { /* logged in: new session id, new CSRF token, no password hash kept */ } $user = $auth->verify($email, $password); // check credentials only, no session (API token login) $auth->user(); $auth->check(); $auth->logout(); Auth::hash($password); // password_hash; $2a$ hashes from older apps verify too
UserProvider has three methods: findByCredentials($identifier): ?array, onLogin(array $user), passwordKey(): string.
Guard: Cast\Contracts\Guard (check($guard), can($permission), user()) decides who may see what. The default SessionGuard reads the
user array from the session (auth.session_key) and its permissions list. Bind your own as guard when permissions live elsewhere:
$this->app->singleton('guard', fn() => new MyGuard());
Authenticate middleware: [Authenticate::class, 'private'] (logged in, otherwise redirect to auth.login_path, or 401 for ajax),
'auth' (guests only; signed-in users go to auth.home_path), 'public' (anyone).
PasswordPolicy::check($password, ['len' => 8, 'lc' => 1, 'uc' => 1, 'nums' => 1, 'sp' => 0], $enforce = true) returns '' or a sentence ("Password must have at least ...").
ModelPersister saves and patches through models that implement Savable / Patchable (and HasLineItems for header + lines documents), then runs an optional callback:
$id = (new ModelPersister())->save(Invoices::class, $data, fn($data, $id) => audit('saved', $id)); // throws DuplicateEntryException when the model reports a duplicate
Models and the query builder
class JournalEntries extends Cast\Core\Model {} // table: journal_entries (override: protected static ?string $table = 'x';) JournalEntries::getAll(1); // where active = 1, order by id JournalEntries::getOne($id); getOne('a@b.co', 'email'); JournalEntries::exists('email', $email, $ignoreId); JournalEntries::updateColumns(['name' => 'x'], $id); setActive(0, $id); deleteRows($id); JournalEntries::paginate(page: 2, perPage: 15, orderBy: 'id'); // ['data', 'total', 'page', 'per_page', 'last_page'] JournalEntries::raw('SELECT ... WHERE a = :a', ['a' => 1]); // bound parameters
Model::query() / QueryBuilder::table('t'): select, where, and, or, whereIn, orderBy (repeatable), limit(n, offset), get, first, fetch,
count, exists, insert (returns the id), update, delete. Every value is bound and every identifier (table, column, operator, direction) is validated;
anything else throws InvalidArgumentException. Transactions: Model::beginTrans() / commitTrans() / rollbackTrans() / inTransaction().
There are no joins, grouping or sums: use raw() for those.
Migrations
Describe your tables in PHP; the same migration runs on MySQL, PostgreSQL and SQLite.
php cast make:migration create_orders_table # database/migrations/2026_10_07_101500_create_orders_table.php php cast make:migration add_status_to_orders_table # the stub is picked from the name (create_x_table, add_y_to_x_table) php cast migrate # run what is pending (one batch) php cast migrate:status # what ran, in which batch php cast migrate:rollback # undo the last batch (--step=2: the last two migrations) php cast migrate:refresh --seed # undo everything, run everything again, seed php cast migrate:fresh --seed # drop ALL tables, run everything, seed php cast migrate --pretend # print the SQL, change nothing
// database/migrations/2026_10_07_101500_create_orders_table.php use Cast\Database\{Blueprint, Migration, Schema}; return new class extends Migration { public function up(Schema $schema): void { $schema->create('orders', function (Blueprint $table) { $table->id(); // auto-increment primary key $table->foreignId('user_id')->constrained()->cascadeOnDelete(); // references users.id $table->string('number', 30)->unique(); $table->decimal('total', 12, 2)->default(0); $table->enum('status', ['new', 'paid', 'void'])->default('new'); $table->text('note')->nullable(); $table->timestamps(); // created_at, updated_at $table->index(['user_id', 'status']); }); } public function down(Schema $schema): void { $schema->dropIfExists('orders'); } };
| Area | Methods |
|---|---|
| Tables | create, table (change), drop, dropIfExists, rename, hasTable, hasColumn, columns, tables, statement($sql, $bindings) (anything else), pretend(fn) |
| Columns | id, increments, string($n, $len), char, text, mediumText, longText, integer, bigInteger, smallInteger, tinyInteger, boolean, decimal($n, $p, $s), float, double, date, time, dateTime, timestamp, timestamps, softDeletes, json, uuid, binary, enum($n, [...]), foreignId |
| Modifiers | nullable, default($v) (a value or Schema::raw('CURRENT_TIMESTAMP')), useCurrent, unsigned, unique, index, primary, after($col) (MySQL), comment($t), constrained($table = null) |
| Keys | index, unique, primary, foreign($col)->references('id')->on('t')->onDelete('cascade') / cascadeOnDelete / nullOnDelete, dropIndex, dropUnique, dropPrimary, dropForeign, dropColumn, renameColumn |
- Columns are
NOT NULLunless you saynullable(). Every name is checked and quoted for the database, every default is escaped: nothing from a migration is put in SQL unchecked. - PostgreSQL and SQLite run each migration in a transaction, so a failed one leaves nothing behind. MySQL commits every
CREATE/ALTERitself, so a migration that fails half way stays half applied: keep MySQL migrations small. A lock file (storage/framework/migrate.lock) stops two deploys from migrating at the same time. - SQLite cannot add a foreign key or a primary key to an existing table, nor add a
NOT NULLcolumn without a default: the migration says so and points you to$schema->statement(). - Production: every command that changes the database refuses to run when
APP_ENV=productionunless you add--force(migrate:status,make:migrationand--pretendare always allowed). - The migrations table is
migrations(config('database.migrations.table')); the folder isdatabase/migrations(paths.database, ordatabase.migrations.path).
Seeders: php cast make:seeder UserSeeder creates database/seeders/UserSeeder.php (namespace Database\Seeders; class UserSeeder extends Cast\Database\Seeder { public function run(): void {...} }).
php cast db:seed runs DatabaseSeeder (change it with config('database.seeder')), --class=UserSeeder runs one, migrate --seed migrates and seeds. A seeder calls others with $this->call(OtherSeeder::class).
Legacy databases: migrate:sync
A database that already has tables can join the migration system without retyping them. migrate:sync reads the tables (MySQL/MariaDB, PostgreSQL, SQLite) and writes one create-table migration per table, with columns, defaults, indexes, foreign keys and collations,
ordered so that referenced tables come first. The files are recorded as run, because the tables exist: php cast migrate leaves them alone, and a fresh database built from the files gets the same structure.
php cast migrate:sync --init FIRST: test every relationship; PASS / WARN / BROKEN; writes nothing; exit 1 when something is broken
php cast migrate:sync every table that has no migration yet
php cast migrate:sync users one table (also --table=users,orders and --except=logs,cache)
php cast migrate:sync --pretend print the files instead of writing them
php cast migrate:sync --collation=utf8mb4_unicode_ci write every table with this collation (MySQL)
php cast migrate:sync --auto-increment also write each table's next auto-increment value
php cast migrate:sync --no-record write the files but leave them pending (then migrate:baseline records them)
--initchecks each declared foreign key (the target exists, the columns exist, the types can be joined, the columns are indexed, and every row has its parent, with sample values for the orphans) and also thecustomer_id-style columns that have no constraint, reporting where they would point and whether the data agrees. Rows that point to nothing areBROKEN; a missing constraint, a type mismatch or a missing index is aWARN.- Types the migration builder has no equivalent for (
GEOMETRY,YEAR,MEDIUMINT,JSONB...) are written as the closest match and listed asNOTE:comments in the file and on screen: read the files before you commit them. - A table that already has a migration (
->create('table') is skipped. Foreign keys to a table that is not synced yet are flagged. Cycles between tables get a separateadd_foreign_keys_to_...migration (SQLite accepts forward references, so it keeps them in place). - Collations: the builder has
$table->charset('latin1'),$table->collation('utf8mb4_bin')(MySQL, table level) and->collation('...')on a column (MySQL, PostgreSQL, SQLite).migrate:syncreads them and only writes what differs from the default. - Auto-increment:
$schema->autoIncrement('orders', 5000)sets the next value,$schema->nextAutoIncrement('orders')reads it.php cast db:sequencelists every counter with the highest id, flags the ones that are behind the data (the next insert would collide: this happens on PostgreSQL after imports with explicit ids),--syncmoves them toMAX(id)+1, anddb:sequence orders --set=5000sets one. - It needs the built-in migrator; with another ORM use its own "generate from database".
API tokens table: php cast token:schema --migration writes the migration for it.
Using another ORM
Migrations, the connection and the models each sit behind a small contract, so Doctrine, Eloquent, Cycle or Phinx can replace the built-in pieces and php cast migrate keeps working:
bind your adapter as migrator, return the ORM's PDO from config('database.connection'), and implement ModelContract on its models. See docs/ORM-ADAPTERS.md.
Views
Views are .cast.php files under resources/views. They are PHP with the CastTemplateEngine tags added: <Card title="x">...</Card>, value={$expr}, <Slot name="left">.
See its README for the syntax. Compiled copies are cached in storage/framework/views
(recompiled on change when APP_ENV=development or APP_DEBUG=true).
Pages follow a shell + partial layout, with CSS and JS found by name:
resources/views/items/items.cast.php the page shell: header, content partial, footer
resources/views/items/partials/items.cast.php the content
resources/css/items/items.css loaded automatically for parentName=items, pageName=items
resources/js/items/items.module.js
The controller passes parentName, pageName and authguard; the layout asks for the files:
<?= __modules('app', 'css') ?> // resources/css/app.css <?= __modules("$parentName.$pageName", 'css') ?> // resources/css/items/items.css (only if the file exists) <?= __modules("$parentName.$pageName", 'js') ?> // resources/js/items/items.module.js
__includes('layouts.header', $data); // echo a layout or partial Component('btns.add-new', ['link' => 'fleet']); // components/btns/add-new.php (legacy) or .cast.php $this->view('items.items', $data); // in a controller: a Response views('items.items', $data); // legacy helper: echoes the page and returns true app('view')->share('appName', 'My App'); // data every view receives
Inside a view the data is available as variables and as $data. Components: a .cast.php component receives camelCased props and $children;
a legacy .php component receives $data. A view that is not found in your folder falls back to the framework's own (error pages).
Running on Windows / XAMPP, or in a sub-folder
Quick try (no Apache needed): php cast serve serves public/ on http://127.0.0.1:8000.
Under XAMPP (C:\xampp\htdocs\my-app):
- Use PHP 8.1+ and enable
extension=pdo_sqlite,extension=mbstring(andpdo_mysqlfor MySQL) inphp.ini; restart Apache. Install Composer. - In
C:\xampp\htdocs:mkdir my-app,cd my-app,composer init --name=me/my-app --no-interaction,composer require anode/cast-framework(answeryto the plugin question),php cast init --demo. - Either point a virtual host's
DocumentRootatmy-app/public(then nothing else to configure), or browse tohttp://localhost/my-app/public/and setAPP_BASE_PATH=/my-app/publicin.env.public/.htaccesssends every request that is not a real file toindex.php, somod_rewritemust be on andAllowOverride Allset for the folder.storage/must be writable. - The app's URLs, assets and the SPA client all use
APP_BASE_PATH; useroute('/items')in views instead of hand-written paths.
SPA: pages without full reloads
Opt a page in with 'spa' => true. The first visit loads the layout and a placeholder; a small JavaScript client then fetches the content, and
later links swap only what changed. Pages that do not opt in load normally, and views() / $this->view() keep working as they are:
View is the one place that decides full page, partial or modal; controllers do not change.
return $this->view('items.items', ['parentName' => 'items', 'pageName' => 'items', 'authguard' => 'private', 'spa' => true, 'items' => $items]);
Layout (resources/views/layouts/header.cast.php): add the client once in <head>, with the page's authguard:
<meta name="csrf-token" content="<?= htchars(\Cast\Core\Session::csrfToken()) ?>"> <?= __cast($data['authguard'] ?? '') ?> <!-- /cast/cast.css and /cast/cast.module.js, served by the framework --> <?= __modules('app', 'css') ?> <?= __modules("$parentName.$pageName", 'css') ?>
The page file stays the shell (header, content partial, footer). The partial named {parentName}.partials.{pageName} is the content area:
it is what gets fetched and swapped, so keep everything that changes from page to page in it.
What the server does
| Request | Response |
|---|---|
Browser, page without spa |
The full page, as before |
Browser, spa page |
The full layout; the content area is <div id="cast-view" data-cast-page="items.items" data-cast-lazy> with a skeleton (spa.initial = lazy, default), or the real content (inline, no extra request) |
Cast request (X-Cast-Request: 1) for a spa page |
JSON envelope with the content (below) |
Cast request for a page that is not spa |
{type: 'reload'}: the client does a normal page load |
| Cast request to a redirect | {type: 'redirect', url} (an XHR cannot follow a login redirect cleanly) |
| Cast request that fails | HTTP status + {status: 'error', msg, data: {type: 'error', code}}; a 503 (maintenance) is type: 'reload' |
The envelope follows the app-wide {status, msg, data} shape:
{ "status": "success", "msg": "",
"data": { "type": "partial", "target": "#cast-view", "title": "App | Items", "html": "<section>...</section>",
"css": ["/css/items/items.css?v=..."], "js": ["/js/items/items.module.js?v=..."], "own": ["...the page's own files..."],
"guard": "private", "url": "/items", "page": "items.items", "modalClass": null, "form": null, "csrf": "..." } }
typeis chosen byView:partialfills a container (target, default#cast-view);pagereplaces the whole body (used when the page'sauthguardis not the one the client sent inX-Cast-Guard, e.g. after login, so the layout can change, or when the client asks forX-Cast-Type: page);modalopens a modal.- A fragment is a view that is only a piece of page. Return it with
'fragment' => true(any container,X-Cast-Target), or with'type' => 'modal'plusmodalClass/form; these do not needspa. A browser that requests such a URL directly gets the bare view. - Assets:
css/jsare the files__modules()would link for the page (resources/css/{parent}/{page}.css,resources/js/{parent}/{page}.module.js, and the same fortabName), so a swap loads what a full load would.ownmarks the page's own files; the client removes them when it leaves the page. On a full-body swap the lists come from the rendered layout. titleis$data['title'], else the rendered<title>, else "App | Page name".csrfis the current token (it changes at login).
The client (Cast)
Plain JavaScript, no dependencies. Links and forms are picked up automatically.
<a href="/items">Items</a> <!-- same-origin link on a Cast page: swaps the content, updates the URL and title --> <a href="/report.pdf" download>PDF</a> <!-- download, target=_blank, modifier keys, other origins: normal --> <a href="/legacy" data-cast="off">Old page</a> <!-- opt out: a full load --> <a href="/items/5/edit" data-cast="modal">Edit</a> <!-- open the response in a modal; data-cast="page" swaps the whole body --> <a href="/items/top" data-cast-target="#side">Top</a> <!-- fill another container --> <form method="post" action="/items" data-cast-form> <!-- submitted with fetch; 422 errors appear under the fields --> <?= __csrf() ?> <input name="name"><p data-cast-message role="alert"></p> <!-- other errors (e.g. a wrong password) go here --> </form>
Cast.load("/items?page=2"); // load into the content area (type: "partial" | "page" | "modal", target, push, replace) const res = await Cast.http({ url: "/items/5", type: "PUT", data: { qty: 3 } }); // JSON, CSRF header, resolves with {status, msg, data} if (res.status === "success") Cast.load(location.pathname, { push: false }); // refresh the current page // resources/js/items/items.module.js: loaded once, mount() runs on every visit, destroy() when the page is left Cast.page({ mount(ctx) { ctx.on("click", ".js-delete", async (event, button) => { /* delegated, removed automatically on leave */ }); // ctx.el = the container, ctx.signal aborts on leave (pass it to fetch) }, destroy(ctx) {}, });
Cast.httpacceptsurl, data, type|method, isform, busy, follow, headers; it always resolves (network and server errors become{status: 'error', msg}), follows redirect envelopes, and updates the CSRF token. Replace it withCast.configure({ http: yourAxiosWrapper }), and open modals your own way withCast.configure({ modal: (envelope) => ... }).- Under a sub-folder (
APP_BASE_PATH=/my-app/public) root-relative URLs given toCast.httpandCast.loadget the folder put in front automatically;Cast.url("/items")does the same by hand (forfetch,<img src>). - Accessibility: after each navigation the client moves focus to the first heading of the new content and announces the page title in a polite live region (
#cast-announcer); a failed load shows an alert block with a Try again button. - Events (bubbling, native
CustomEvents;event.detailhas the data, in jQuery useevent.originalEvent.detail):cast:mounted(every container that was filled, also modals),cast:destroy,cast:navigate,cast:saved(adata-cast-formsucceeded),cast:invalid(422),cast:error. Initialise widgets (date pickers, selects) oncast:mountedinstead of onDOM ready. - History:
pushState/popstatewith scroll restore, one request in flight at a time (a new click cancels the old one), a normal page load as the fallback for anything that is not a Cast answer. - Rules for page scripts: use
Cast.page()instead of$(document).ready(it would run only on the first visit); inline<script>blocks inside swapped HTML are not run (page code belongs in the page module, data in attributes); same-origin assets only. - Without JavaScript the shell shows a
<noscript>note; set'initial' => 'inline'inconfig/spa.phpto render the first page's content on the server.
config/spa.php: enabled (true; false turns the whole layer off), initial (lazy | inline), root (body), view (#cast-view). Override the skeleton with resources/views/spa/skeleton.cast.php,
and the colours with the --cast-* CSS variables.
JSON API
For a front-end framework (Next.js, Vue, React, a mobile app) or any other server. Put the routes in routes/api.php: they are registered under /api (config('api.prefix')), so
Router::get('/items', ...) there answers GET /api/items. Everything under the prefix is JSON in and out, with the same {status, msg, data} shape; there is no HTML and no redirect, whatever the client sends.
// routes/api.php use Cast\Core\Router; use Cast\Http\Middleware\{ApiAuth, Throttle}; Router::post('/auth/token', [TokenController::class, 'issue'])->use([Throttle::class, 10, 1]); // email + password => token Router::middleware([ApiAuth::class], function () { // a valid token (or a login session) Router::get('/me', [TokenController::class, 'me']); Router::middleware([ApiAuth::class, 'items:write'], function () { // the token must have this ability Router::post('/items', [ItemsController::class, 'store']); Router::delete('/items/{id}', [ItemsController::class, 'destroy'])->middleware(['manage-items']); // + a permission of the user }); });
| Status | When |
|---|---|
| 200, 201 | Response::success($msg, $data, $status) (a returned array is also sent as JSON) |
| 401 | No credentials, or a bad / revoked / expired token (WWW-Authenticate: Bearer) |
| 403 | A missing ability or permission |
| 404, 405 | Unknown path, wrong verb (Allow header) |
| 419 | A cookie-authenticated write without the CSRF token (see below) |
| 422 | Validation: {status: 'error', msg, data: {errors: {field: 'message'}}} |
| 429 | Rate limit (Retry-After, X-RateLimit-*) |
| 500 | {status: 'error', msg}; the real message only when APP_DEBUG=true (it is always logged) |
Tokens
A token is {id}|{secret}: the id finds the record, and only the SHA-256 of the secret is stored, so a leaked table does not leak usable tokens (compared with hash_equals).
The plain token is shown once, when it is created. Each token has a name, abilities, an optional expiry and a last-used time, and can be revoked.
$issued = app('tokens')->issue($user['id'], 'mobile app', ['items:read', 'items:write'], ttl: 30 * 86400); $issued['token']; // "9f3c1a2b4d5e6f70|k3J..." : give this to the client, once app('tokens')->revoke($issued['id']); // or ->revokeAllFor($user['id'])
Abilities are strings you choose: * (everything), items:* (a group) or items:read (one). A token can only do what its user may do and what its abilities allow.
app('auth')->verify($email, $password) checks credentials without starting a session, for the login route.
Setup: create the table once (php cast token:schema --migration then php cast migrate, or --run, or ApiTokenSchema::sql($driver)), and make your user store also implement
Cast\Contracts\FindsUsersById (findById($id): ?array), because a token only holds the user's id. Keep tokens elsewhere by binding your own Cast\Contracts\TokenStore as token_store.
Console: token:create <login> [--name=] [--abilities=a,b] [--days=N], token:revoke <id>, token:schema [--run].
TOKEN=$(curl -s -X POST http://localhost:8000/api/auth/token -H 'Content-Type: application/json' \ -d '{"email":"admin@example.com","password":"password"}' | jq -r .data.token) curl -s http://localhost:8000/api/items -H "Authorization: Bearer $TOKEN" curl -s -X POST http://localhost:8000/api/items -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"name":"Bolt","qty":10,"price":1.5}'
// a front end (Next.js, Vue, ...) : no cookies, no CSRF token, the token in a header const api = (path, init = {}) => fetch(`${API}/api${path}`, { ...init, headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}`, ...init.headers } }).then((r) => r.json()); const { data } = await api("/items?page=1&per_page=20");
CSRF and cookies on the API
CSRF protects requests that carry ambient credentials (the session cookie). So on API routes the token is not required for a request that is authenticated by a bearer token, or that has no
session cookie at all; it is required (419) for writes from a browser that sends the session cookie. A front end on another origin that uses cookies instead of tokens needs
CORS_ALLOWED_ORIGINS=https://app.example.com, fetch(..., { credentials: "include" }), COOKIE_SITE=None with COOKIE_SECURE=true, and the X-CSRF-TOKEN header; bearer tokens avoid all of that.
Rate limiting and CORS
[Throttle::class, 60, 1] allows 60 requests per minute per caller (the token, otherwise the IP address), counted across every route that uses the same limit (add a third argument, [Throttle::class, 5, 1, 'login'], for a separate counter; counters are files in storage/framework/throttle), adds X-RateLimit-Limit/Remaining/Reset, and answers 429 with Retry-After.
Apply it to a route with ->use([...]), to a group with Router::middleware([...]), or to the whole API with 'middleware' => [[Throttle::class, 60, 1]] in config/api.php. Behind a proxy make sure REMOTE_ADDR is the client address.
CORS: only exact origins in CORS_ALLOWED_ORIGINS get headers (Authorization, X-CSRF-TOKEN and the verbs including PATCH are allowed; the rate-limit headers are exposed); OPTIONS preflight is answered before authentication.
Editor support
The package ships a VS Code extension (editor/vscode) for .cast.php views. Install it from your project:
php cast editor:install # VS Code, Insiders, VSCodium, Cursor, Antigravity, Windsurf: every one it finds
Then reload the editor window. It gives you:
- Colours for component tags (
<Card>,<Btns.Button />),<Slot name="...">, props (title="Hi <?= $name ?>",total={$qty + 1},{...$data}) and components inside{ }values. The file stays a normal PHP file, so PHP IntelliSense, Emmet and the HTML features keep working. - Ctrl+click (Cmd+click on macOS), F12 and hover on a component tag, a view name (
__includes('layouts.header'),views('home.home')), a legacyComponent('btns.add')or a module (__modules('app.app', 'js')): the file opens, using the same rules as the engine (<Form.TextInput>isform/text-input,form/text_input,form/TextInputorform/textInput). - Snippets:
ccomp,cslot,cprop,cfor,cif,cpage,cinc,cmod, andcpagejsin JavaScript.
--editor=code|insiders|vscodium|cursor|antigravity|windsurf picks one editor, --dir=PATH installs into any extensions folder, --uninstall removes it. The folders it searches for components
come from the cast.componentsPath, cast.viewsPath and cast.resourcesPath settings (and config/view.php). Details: editor/vscode/README.md.
A Marketplace extension (with prop completion, diagnostics and rename) will follow as the framework grows.
Component docs: props, types, agents
Write a docblock in a component file (@var string|null $label The text to show) and the extension shows it on hover, completes props and values, and warns about unknown or missing props. php cast components --json gives the same information to AI tools, and php cast make:component starts new components documented. See docs/COMPONENTS.md.
Helpers
Installing the package loads these global functions (each wrapped in function_exists, so you can define your own first). Names are unchanged from the apps they came from.
| File | Contents |
|---|---|
core.php |
app, env, config, base_path, storage_path, resource_path, public_path, useConfig, __getConfig, views, Component, __includes, __modules, __cast, render404, route, route_to, abort, _access, file_control, app_version, sendAlert, __getAlerts, __busyLoader, clearState, __getSess, dd, dump, vd, __prev |
request.php |
request, response, getPost |
security.php |
__csrf, __verifyCsrf, hashPassword, verifyPassword, __randStr, tokenGen, __getUser, _checkAccess |
strings.php |
__ucwords, __ucfirst, str_capitalize, snakeCase, htchars, str_escape, htmlNewLine, strReplace, str_addHyphen, __getSplitStr |
arrays.php |
arrayToList, __implode, arraySearch, searchMultiArray, arrayUnique, arrayRand, arrayMultiToSingle, arrayReducer, add2dArray, sortArray, sortMultiArray, paginateArray, parseArray, decodeJsonInArray |
dates.php |
getDateTime, __fixDate, dateDiff, modifyDate, getMonthLastDay, getFirstLast_monthDate, getMonthsInRange, getYearMonths, __useMonth, __dueIn |
math.php |
__round, __floats, __compare, isMultiple, getFloat, __money, __symbolsCurr (formats via config('money.formats')) |
html.php |
jsonQuotes, jsonValidate, __attr, __requiredAttr, __selectedValue, __getImg, __invalidFeedback, __textAlign |
validation.php |
validate, formValidation, validateParam, checkRouteParams, checkPostParams, checkValidity |
Your own helpers (business logic): put *.php files in a folder and point config/helpers.php at it: return ['custom' => 'app/helpers'];.
They load at boot, after the framework's.
Static files
Before routing, /css/..., /js/..., /styles/..., /fonts/..., /images/... and /public/... are served from the folders in config('static'):
'css' => ['dir' => 'resources', 'keep_prefix' => true], // /css/app.css => resources/css/app.css 'public' => ['dir' => 'public/assets/vendor', 'keep_prefix' => false], // /public/lib/x.js => public/assets/vendor/lib/x.js
Only known file types are served (never .php), the resolved path must stay inside its folder (realpath check, so ../ tricks return 404), the Content-Type comes from the
extension, ?v= URLs are cached for a year, others revalidate (If-Modified-Since gives 304). Static files are answered before the application boots.
Every response (static or not) carries X-Content-Type-Options, X-Frame-Options, Referrer-Policy and Permissions-Policy. HSTS is left to the web server.
CORS headers are sent only for exact origins in CORS_ALLOWED_ORIGINS, never *, and preflight OPTIONS requests get a 204.
Errors, maintenance and updates
Error pages. abort(404), a missing route, a wrong verb, a bad CSRF token and a permission failure all end in an error page (HTML) or {status:'error', msg} (JSON clients).
The default is one small, self-contained page (no external files, dark-mode aware) in Views/errors/error.cast.php. Override it with resources/views/errors/error.cast.php,
or per code with errors/404.cast.php, errors/419.cast.php, ... The view gets $code, $title, $message, $appName, $homeUrl. Uncaught exceptions go to
anode/error-handler (logging and its error screen) on web requests; set 'error_handler' => false to turn it off.
Maintenance mode.
php cast down --message="Back at noon" --secret=letmein --retry=120 # 503 page for everyone except... php cast down --in=60 # or schedule: down automatically in 60 seconds php cast up
State is a JSON file (storage/framework/maintenance.json). A request passes with the header X-Maintenance-Secret, the query ?maintenance_secret=, or the
cast_maintenance cookie (the SHA-256 of the secret). The secret is stored hashed. config('maintenance.bypass') can be a callable fn(Request $r): bool (e.g. allow admins).
Override views/maintenance.cast.php for your own page. Static files are still served. Keep the state somewhere else (a database table) by binding your own
Cast\Contracts\MaintenanceStore into a MaintenanceManager.
Updates. Bind an updater (Cast\Contracts\Updater: currentVersion(), latestVersion(), needsUpdate(), run(); CallbackUpdater builds one from closures). When it
reports a pending update the app shows views/updating.cast.php (503, Retry-After: 30), or with 'auto_update' => true runs run() first and carries on if it succeeds.
Console
php cast # list commands
| Command | Purpose |
|---|---|
serve [--host=127.0.0.1] [--port=8000] [--dry] |
PHP's built-in server for public/ |
route:list |
Every route, handler and middleware |
views:clear |
Delete compiled views |
down [--message=] [--secret=] [--retry=] [--in=] / up |
Maintenance mode |
env:check |
Checks PHP, extensions, .env, debug in production, writable storage/, folders |
make:controller, make:model, make:middleware, make:command, make:rule <Name> [--force] |
Class from a stub in app/ (Admin/User makes a sub-folder) |
token:create <login> [--name=] [--abilities=] [--days=], token:revoke <id>, `token:schema [--migration |
--run]` |
init [--demo] [--no-migrate] [--force] |
Create a new app's files in the current folder (--demo: the starter, migrated and seeded) |
make:migration, migrate [--seed --pretend --step --force], migrate:baseline, migrate:sync, db:sequence, components, make:component, migrate:rollback [--step=N], migrate:reset, migrate:refresh, migrate:fresh, migrate:status |
Migrations |
make:seeder, db:seed [--class=] |
Seeders |
editor:install [--editor=] [--dir=] [--uninstall] |
Install the VS Code extension for .cast.php views |
version |
Framework and PHP versions |
Your own commands extend Cast\Console\Command and are listed in config/console.php: return ['commands' => [App\Console\Commands\SyncStockCommand::class]];.
bin/cast uses bootstrap/app.php when it exists.
Contracts
Interfaces in Cast\Contracts where an app plugs in its own behaviour:
| Contract | Implemented by | Used for |
|---|---|---|
ModelContract |
Core\Model |
The CRUD surface services rely on |
Savable, Patchable, HasLineItems |
your models | ModelPersister |
Middleware |
your middleware, Csrf, Authenticate, Maintenance |
Route middleware |
Guard |
SessionGuard (default) or your own |
Who is logged in and what they may do |
UserProvider |
your user store | Auth |
FindsUsersById |
your user store (next to UserProvider) |
Turning an API token back into a user |
TokenStore |
DatabaseTokenStore (default) or your own |
Where API tokens are kept |
Rule |
your validation rules | Validator |
ViewRenderer |
Core\View |
The template layer |
MaintenanceStore |
FileMaintenanceStore (default) |
Where maintenance state lives |
Updater |
CallbackUpdater or your own |
Pending updates |
Command |
Console\Command |
Console commands |
Security notes
- Every value in the query builder is bound; identifiers are validated. Raw SQL goes through
raw()with bound parameters. - Escape output:
<?= htchars($value) ?>.<?= ?>prints raw, andRequestinput is raw too. - Compiled views are PHP code: keep
storage/outside the web root, or deny web access to it. Only render templates you trust. - Set
APP_DEBUG=falsein production (php cast env:checkwarns when it is not). - Passwords:
Auth::hash()usespassword_hash; the session never stores the hash; a new session id and CSRF token are issued at login. - Keep
.envout of git. The starter ships.env.exampleonly. - API tokens: only a SHA-256 of the secret is stored; give each client its own token with the fewest abilities it needs, set an expiry, and revoke tokens that leak. Send them over HTTPS only. Rate-limit the login route.
- The SPA client inserts server-rendered HTML (the same trust as a normal page); it sets titles and error text with
textContentand loads same-origin scripts and styles only.
Testing
composer install composer test # PHPUnit: every case, named "<file>: <case>" composer test -- --filter migrations # only the cases whose name contains "migrations" composer test -- --testdox # one readable line per case composer test:plain # the same cases with the dependency-free runner (no PHPUnit needed): php tests/run.php [filter]
Both runners execute the same cases (tests/cases/*.php, written as test('name', fn) with small helpers). A failing case is reported by name in both. tests/phpunit/ is the bridge that hands each case to PHPUnit.
Against a real database server (the suite creates and drops tables in that database, so use a scratch one):
CAST_TEST_DB=mysql CAST_TEST_DB_HOST=127.0.0.1 CAST_TEST_DB_NAME=cast_test CAST_TEST_DB_USER=root CAST_TEST_DB_PASS=secret composer test CAST_TEST_DB=pgsql CAST_TEST_DB_USER=postgres CAST_TEST_DB_PASS=secret composer test # CAST_TEST_DB_PORT is optional
The suites cover each subsystem and the starter app end to end (login, CSRF, JSON verbs, validation, maintenance, error pages, the SPA envelopes, API tokens, rate limits, CORS).
Browser checks for the SPA client run in Chromium with Playwright against the starter app (Playwright is not a dependency of the package):
cd starter && composer install && rm -f storage/database.sqlite && php cast migrate --seed php -S 127.0.0.1:8099 -t public public/index.php & cd .. && npm i playwright && npx playwright install chromium BASE=http://127.0.0.1:8099 node tests/e2e/spa.e2e.js
Upgrading
docs/UPGRADING.md: composer require anode/cast-framework:^0.3 (on 0.x, ^0.2 stays on 0.2.x), php cast init --demo --force --no-migrate to refresh the demo files, php cast migrate:baseline to adopt migrations on an existing database.
Versioning
Semantic Versioning. The public API is what this README documents: the helper names, the config keys, the contracts, the route/middleware spec, the response shapes and the console commands. See CHANGELOG.md. Migrating an existing app: docs/ERP-PORTING.md.
License
MIT