Search by

anode / cast-framework

arnoldduo2

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

Statistics

Installs: 10

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.3.2 2026-10-07 08:17 UTC

This package is auto-updated.

Last update: 2026-10-07 08:53:41 UTC


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 / Response pair, 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 Auth service, a query builder that binds every value, a small Model.
  • 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); View decides 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', or Class::class (calls index). A handler receives the route parameters ({id}), then the merged request input array as its last argument. Controllers are created per request with new.
  • Return values: a Response is 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. HEAD uses the GET route.
  • 404 and 405: an unknown path is a 404; a known path with another verb is a 405 with an Allow header (JSON for ajax/JSON clients, a page otherwise). Router::_404($handler) customises unmatched GET requests.
  • HTML forms and verbs: a POST with _method=PUT|PATCH|DELETE (or the X-HTTP-Method-Override header) is routed as that verb.
  • Route options: ->middleware(['slug', ...]) permission slugs, any one is enough, checked with the bound Guard; ->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] calls handle(Request $request, ...$args). Return null to continue, a Response to stop, or throw HttpException. 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 (or X-XSRF-TOKEN), or _token in 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 NULL unless you say nullable(). 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/ALTER itself, 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 NULL column 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=production unless you add --force (migrate:status, make:migration and --pretend are always allowed).
  • The migrations table is migrations (config('database.migrations.table')); the folder is database/migrations (paths.database, or database.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)
  • --init checks 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 the customer_id-style columns that have no constraint, reporting where they would point and whether the data agrees. Rows that point to nothing are BROKEN; a missing constraint, a type mismatch or a missing index is a WARN.
  • Types the migration builder has no equivalent for (GEOMETRY, YEAR, MEDIUMINT, JSONB...) are written as the closest match and listed as NOTE: 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 separate add_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:sync reads 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:sequence lists 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), --sync moves them to MAX(id)+1, and db:sequence orders --set=5000 sets 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):

  1. Use PHP 8.1+ and enable extension=pdo_sqlite, extension=mbstring (and pdo_mysql for MySQL) in php.ini; restart Apache. Install Composer.
  2. In C:\xampp\htdocs: mkdir my-app, cd my-app, composer init --name=me/my-app --no-interaction, composer require anode/cast-framework (answer y to the plugin question), php cast init --demo.
  3. Either point a virtual host's DocumentRoot at my-app/public (then nothing else to configure), or browse to http://localhost/my-app/public/ and set APP_BASE_PATH=/my-app/public in .env. public/.htaccess sends every request that is not a real file to index.php, so mod_rewrite must be on and AllowOverride All set for the folder. storage/ must be writable.
  4. The app's URLs, assets and the SPA client all use APP_BASE_PATH; use route('/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": "..." } }
  • type is chosen by View: partial fills a container (target, default #cast-view); page replaces the whole body (used when the page's authguard is not the one the client sent in X-Cast-Guard, e.g. after login, so the layout can change, or when the client asks for X-Cast-Type: page); modal opens 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' plus modalClass / form; these do not need spa. A browser that requests such a URL directly gets the bare view.
  • Assets: css / js are the files __modules() would link for the page (resources/css/{parent}/{page}.css, resources/js/{parent}/{page}.module.js, and the same for tabName), so a swap loads what a full load would. own marks 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.
  • title is $data['title'], else the rendered <title>, else "App | Page name". csrf is 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.http accepts url, 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 with Cast.configure({ http: yourAxiosWrapper }), and open modals your own way with Cast.configure({ modal: (envelope) => ... }).
  • Under a sub-folder (APP_BASE_PATH=/my-app/public) root-relative URLs given to Cast.http and Cast.load get the folder put in front automatically; Cast.url("/items") does the same by hand (for fetch, <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.detail has the data, in jQuery use event.originalEvent.detail): cast:mounted (every container that was filled, also modals), cast:destroy, cast:navigate, cast:saved (a data-cast-form succeeded), cast:invalid (422), cast:error. Initialise widgets (date pickers, selects) on cast:mounted instead of on DOM ready.
  • History: pushState / popstate with 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' in config/spa.php to 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 legacy Component('btns.add') or a module (__modules('app.app', 'js')): the file opens, using the same rules as the engine (<Form.TextInput> is form/text-input, form/text_input, form/TextInput or form/textInput).
  • Snippets: ccomp, cslot, cprop, cfor, cif, cpage, cinc, cmod, and cpagejs in 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, and Request input 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=false in production (php cast env:check warns when it is not).
  • Passwords: Auth::hash() uses password_hash; the session never stores the hash; a new session id and CSRF token are issued at login.
  • Keep .env out of git. The starter ships .env.example only.
  • 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 textContent and 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