Skip to content

21 July 2026

20 changes:

  • Activity log now writes on MySQL (schema-aware table probe)
  • SEO-friendly 404 for unknown controllers
  • Document::getInstance() no longer lets a stray ?format= hijack the response
  • View no longer triggers a PHP 8.5 "null array offset" deprecation
  • Scaffolded DataTables CRUD now works end-to-end
  • Controller scaffolding renders from a stub template
  • DebugBar "Views" panel now lists inserted partials
  • Full controllers always generate from one CRUD path (buggy heredoc removed)
  • Model scaffolding: one schema-first path, rendered from a stub
  • create:controller / create:model always generate full CRUD
  • Scaffolded CRUD now matches the admin look — server-side DataTable per theme
  • init seeds the admin user's name from the developer name
  • create:controller summary prints a ready-to-use test URL
  • API controller generator rendered from stubs (de-heredoc)
  • Generated model/controller tests are now schema-aware (real coverage)
  • Scaffolded auth tests fixed for the current login API + PHP 8.5
  • create:view --full unified onto the admin CRUD views
  • Scaffolding detects Select2 from project config, not the filesystem
  • Generated CRUD foreign keys load options via AJAX (Select2 remote)
  • Pramnos\User\User gains _getApiList() — FK special-case removed

Activity log now writes on MySQL (schema-aware table probe)

Pramnos\Auth\ActivityLog silently recorded nothing on MySQL: its table-existence probe used an unqualified name that never matched the schema-prefixed physical table. The probe is now driver-aware, so the audit trail is written on MySQL exactly as it already was on PostgreSQL.

Fixed

  • ActivityLog::record() was a silent no-op on MySQL. The internal probe called Database::tableExists('user_activity_log'), an exact-name lookup. On MySQL the authserver. schema is emulated as a table prefix, so the physical table is authserver_user_activity_log and the probe never matched — every record() short-circuited and no row was written. On PostgreSQL the real authserver schema meant table_name = 'user_activity_log' matched, so the bug was invisible there (and in the PostgreSQL reference suite).

The probe now goes through the schema builder with the fully-qualified name, Database::schema()->hasTable('authserver.user_activity_log') — the same call the creating migration uses — which resolves correctly on both the real PostgreSQL schema and MySQL's table-prefix emulation.

Why it matters

The authserver dashboard / security pages read back this table. On MySQL installs the login/logout/passkey audit trail was simply empty; it now populates as designed. The change is confined to the table probe — the insert path, the feature gate, the missing-table no-op and the swallow-on-failure guarantees are all unchanged.


SEO-friendly 404 for unknown controllers

A request that resolved to a controller which does not exist returned a plain-text There is no controller to run... body with an HTTP 200 status — useless to visitors and misleading to search engines. The front controller now answers with a proper not-found response.

Changed

  • Application::notFound() (new, public) emits a real HTTP 404 — a minimal styled page with a noindex robots directive and a link home; any caller-supplied message is HTML-escaped.
  • Api::notFound() overrides it with a JSON 404 envelope ({"status":404,"error":"NotFound"}) so API clients get a machine-readable not-found instead of the old string.
  • The three close('There is no controller to run...') call sites (two in Application::exec(), one in Api::exec()) now delegate to notFound().

Why a 404 and not a redirect

A genuine 404 (not a 301 to the home page) is the correct SEO signal: blanket redirecting unknown URLs to / reads as a soft-404 and hurts indexing. The method is public so app controllers can trigger it for their own missing resources.


Document::getInstance() no longer lets a stray ?format= hijack the response

The format query parameter doubles as a document-type selector, but callers also use it for their own purposes (e.g. the DataTables adapter sends format=datatables). An unknown value fell through to a fresh HTML document at render() time — discarding a JSON/raw Response a controller had already prepared. Unknown format values now fall back to the current default document type; known types and the historical HTML default are unchanged.


View no longer triggers a PHP 8.5 "null array offset" deprecation

View::addModel() / getModel() keyed the models array on $model->name. On an unsaved record (e.g. an edit/0 create form) that name is null, and PHP 8.5 deprecates null array offsets — emitting warnings on every such page. The key is now coerced to a string ('' for null); lookup semantics are unchanged.


Scaffolded DataTables CRUD now works end-to-end

Generating a CRUD controller (create:migration wizard → controller) on a project with DataTables installed produced a list page that fatally errored, returned HTML instead of JSON, skipped auth on writes, and rendered unstyled. Fixed across the generators and the plain-css theme.

Fixed

  • pramnos-adapters is auto-included with DataTables and its bundled files register under per-file handles (pramnos-datatable, pramnos-gridjs) instead of colliding on one — fixing the Cannot find script: pramnos-adapters fatal. The controller enqueues pramnos-datatable, guarded by isScriptRegistered().
  • getApiList() returns JSON, not the HTML theme. It reads the adapter's query params (page/perpage/search/order/fields, format) from the request and returns \Pramnos\Http\Response::json(...).
  • All actions are registered so Controller::exec() dispatches them instead of falling back to display(): show/getApiList are public, while the create/edit form and save/delete require login (previously create/edit were reachable without authentication).
  • The DataTables stylesheet is enqueued (guarded) so the list controls are styled.
  • Breadcrumbs render on the generated CRUD views — the controller populated them but nothing displayed them.
  • Forms use the theme's semantic classes on plain-css too (form-control, btn btn-primary/secondary, card) instead of empty class=""; the plain-css .btn gained line-height/vertical-align so <a> and <button> buttons match in height.

Controller scaffolding renders from a stub template

The wizard-generated CRUD controller was built from a large PHP heredoc embedded in MakeCommandBase. It is now rendered from scaffolding/templates/crud-controller.stub via the existing renderStub() mechanism — matching how the middleware / event / migration generators already work — so the generated controller can be customised by editing the stub. Generated output is byte-for-byte unchanged.


DebugBar "Views" panel now lists inserted partials

View::insert() does a plain include and never went through getTpl(), so the DebugBar Views panel listed only the top-level template — not the partials a page actually rendered (breadcrumb, sidebar, …), which are often exactly what a developer needs to edit. insert() now records each partial in the ViewsCollector, so every rendered template file shows up when debugging.


Full controllers always generate from one CRUD path (buggy heredoc removed)

make:controller --full had two divergent generators: the migration-wizard path (schema-first → crud-controller.stub) and a separate DB-introspection path that built the controller from an inline heredoc. The heredoc variant was broken — its generated getApiList() called parent::_getApiList(), which does not exist on a controller. Both full paths now converge on createControllerAndViewsFromWizard(): DB-introspected tables are normalised into the same column/foreign-key shape as the wizard (introspectTableAsWizardColumns()) and rendered from crud-controller.stub. simple vs full remains the only choice; full is a strict superset of simple.


Model scaffolding: one schema-first path, rendered from a stub

The model generator mirrored the controller's old shape: a schema-first builder (buildModelFromWizardColumns()) plus a separate DB-introspection variant built from its own inline heredocs. The builder now renders from scaffolding/templates/crud-model.stub, and createModel()'s introspection path normalises the live table into the wizard column shape (introspectTableAsWizardColumns()) and delegates to that one builder — so a full model is always produced from the same stub. The divergent introspection heredoc (~260 lines) is gone.


create:controller / create:model always generate full CRUD

The "simple skeleton" scaffold mode was removed from the code generators — a single, predictable behaviour per command, eliminating the simple-vs-full duality that caused template drift and bugs.

Changed

  • create:controller always generates a full CRUD controller. The --full (-f) flag is gone — the command always builds the complete artifact (display/show/edit/save/delete + JSON data) from crud-controller.stub, driven by the live table schema.
  • create:model always generates a full model from the table schema (or from wizard columns during create:migration); the bare model.stub skeleton path was removed.
  • Deleted scaffolding/templates/controller.stub and model.stub.

Fixed

  • Both generators now fail loudly when the target table does not exist and no wizard columns are supplied, instead of silently emitting a schema-less stub: Table '<table>' not found for <Name>. Create it first with create:migration.

Notes

  • create:init's schema-less welcome controller (src/Controllers/Home.php) no longer uses the removed stub — it is written from a small inline template.
  • create:view keeps its --full (-f) flag; only the controller flag was removed.

Scaffolded CRUD now matches the admin look — server-side DataTable per theme

The wizard-generated CRUD used a client-side PramnosDataTable list + a web getApiList() endpoint, which looked and behaved differently from the framework's own admin screens (users, applications). Scaffolded CRUD now follows the same established pattern, per theme (plain-css / bootstrap / tailwind):

  • Controller builds a server-side \Pramnos\Html\Datatable ($view->datatable) and exposes a data() action that streams rows via \Pramnos\Html\Datatable\Datasource::getList() — exactly like the admin controllers. The web controller's getApiList() was removed (the REST API controller under src/Api keeps its own).
  • Views are rendered from per-theme stubs (scaffolding/templates/crud-view-{plain,bootstrap,tailwind}-{list,edit,show}.stub) that mirror the admin views: the theme's wrapper, flash blocks, a header with a themed "+ New" button, and $this->datatable->render() for the list. The create/edit forms and detail pages use each theme's own markup.

BC is preserved: \Pramnos\Html\Datatable and Datasource::getList() are unchanged. (A deeper data-layer unification — routing Datasource through the getApiList/_getApiList engine — is deferred to a separate, feature-parity- checked task.)


init seeds the admin user's name from the developer name

The admin account created during create:init now sets firstname/lastname from the "Author Name" captured earlier (first token → firstname, remainder → lastname), instead of leaving them blank.


create:controller summary prints a ready-to-use test URL

After generating a CRUD controller, the command's summary now ends with Test it now: <url> — the actual app URL for the new controller (resolved from sURL when known) so you can open it immediately instead of assembling the path by hand.


API controller generator rendered from stubs (de-heredoc)

The REST API controller generator (create:api) built its controller class and its src/Api/routes.php snippet from PHP heredocs. These now render from scaffolding/templates/api-controller.stub and api-routes.stub via renderStub() (byte-for-byte identical output). Consistent with the controller/model/view generators, which are all stub-driven now.


Generated model/controller tests are now schema-aware (real coverage)

create:model / create:controller / create:crud previously emitted a placeholder test (assertTrue(true)). They now generate meaningful, schema-aware tests from new stubs (crud-model-test.stub, crud-controller-test.stub):

  • Model test (integration, extends the project's BaseTestCase): asserts the model extends \Pramnos\Application\Model, has a typed property per column, and runs a save → reload-by-PK → getData() → delete round-trip with a typed sample per column (FK columns asserted as properties only, so no parent rows needed), plus a getApiList() envelope check.
  • Controller test (feature, uses TestClient): reflects the registered public (show/data) and auth (edit/save/delete) actions, GETs the list route and checks it renders, and asserts the data() endpoint returns a JSON row container.

Scaffolded auth tests fixed for the current login API + PHP 8.5

The tests create:init writes for a new project failed on a fresh scaffold:

  • AuthFlowTest called Login::dologin(), a method that no longer exists — the login submit is handled by Account::login() (CSRF-protected, POST-driven, presentResult() on the flow result). The two controller-level tests are rewritten against the current API: they set $_SERVER['REQUEST_METHOD']='POST', mock the checkCsrf() seam, and assert success → redirect to the site root and wrong-password → the form re-renders without establishing a session.
  • LoginControllerTest called ReflectionProperty::setAccessible(true), deprecated in PHP 8.5 (a no-op since 8.1) — removed.

create:view --full unified onto the admin CRUD views

create:view --full had its own separate inline-heredoc view generator that produced old, non-theme-consistent markup. It now mirrors create:controller/create:model: it introspects the table (introspectTableAsWizardColumns()) and delegates to createViewsFromWizard(), so a full view set is the same admin-style, per-theme (plain/bootstrap/tailwind) output as every other CRUD path — with the same "table not found → run create:migration" error. The plain create:view (no --full) still writes a minimal, table-free placeholder view, now from simple-view.stub. With this, every make/create generator renders from .stub templates — the last generated-code heredoc is gone.


Scaffolding detects Select2 from project config, not the filesystem

detectUiSetup() decided whether generated CRUD forms use Select2 for foreign keys by probing www/assets/vendor/select2 on disk. It now asks the project's own configuration — Factory::getDocument()->isScriptRegistered('select2'), i.e. whether App\Application::registerVendorLibraries() registered Select2 — which is the authoritative signal for whether the app actually opted into it (Select2 is not a framework default).


Generated CRUD foreign keys load options via AJAX (Select2 remote)

FK <select> fields in generated CRUD used to eagerly render an <option> for every related row — a FK to a table with thousands of rows bloated (or broke) the edit page. When Select2 is enabled, FK fields now load options on demand:

  • The generated controller exposes an fkOptions(?field=&q=&page=) action that reuses the related model's _getApiList() (search + pagination) and returns the Select2 JSON envelope {results:[{id,text}], pagination:{more}}. A generated $fkMap maps each FK column to its related model + primary key. Framework-User FKs (which have no _getApiList()) fall back to a direct, still-paged/searched query on the users table.
  • The edit form configures Select2 with an ajax: remote pointing at that action and pre-renders only the currently-selected option (so edit shows the existing value without loading the whole table). The eager full-list load is dropped for Select2-backed FKs.
  • Without Select2, FK fields keep the native eager <select> (fine for small reference tables).

Pramnos\User\User gains _getApiList() — FK special-case removed

The generated CRUD fkOptions() endpoint reuses each related model's _getApiList() to serve Select2 remote options. \Pramnos\User\User extends \Pramnos\Framework\Base (not \Pramnos\Application\Model), so it lacked that method — forcing fkOptions() to carry a bespoke branch that queried the users table directly for User foreign keys. User now implements its own _getApiList(), so User FKs flow through the same generic pipeline as every other model and the special-case is gone.

Added

  • User::_getApiList() — a drop-in match for Model::_getApiList()'s signature, implemented on the users table via the QueryBuilder (mirroring User::getUsers()). It supports the parameters that make sense for users:
  • $fields (array / CSV / JSON) validated against the real users columns; unknown fields are silently dropped. Defaults to userid, username, email; the primary key userid is always included.
  • $search — case-insensitive LIKE across username + email (ILIKE on PostgreSQL, LIKE on MySQL).
  • $order — a validated "field dir" clause (field must be a real column; direction normalised to asc/desc).
  • pagination via $page / $itemsPerPage. $page <= 0 returns all matching rows with pagination => null, exactly like Model.
  • $format'' returns the standard {data, pagination, fields} envelope; 'datatables' returns the DataTables 2.x {draw, data, recordsTotal, recordsFiltered} shape.

Parameters that don't apply to the flat users table ($filter, $join, $group, $table, $key, $debug, $returnAsModels, $useGetData, $customGetListMethod, $addedfields) are accepted-and-ignored purely for signature compatibility — documented in the method's docblock. The addition is purely additive and BC-safe; no existing User method changed.

Changed

  • Generated fkOptions() no longer special-cases the framework User. The if (ltrim($modelClass) === \Pramnos\User\User::class) { …direct users query… } branch was removed from MakeCommandBase::buildFkOptionsMethod(); User FKs now go through the generic $model->_getApiList(...) path (the label fallback still prefers username).