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:modelalways generate full CRUD- Scaffolded CRUD now matches the admin look — server-side DataTable per theme
initseeds the admin user's name from the developer namecreate:controllersummary 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 --fullunified 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\Usergains_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 calledDatabase::tableExists('user_activity_log'), an exact-name lookup. On MySQL theauthserver.schema is emulated as a table prefix, so the physical table isauthserver_user_activity_logand the probe never matched — everyrecord()short-circuited and no row was written. On PostgreSQL the realauthserverschema meanttable_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 anoindexrobots 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 inApplication::exec(), one inApi::exec()) now delegate tonotFound().
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-adaptersis auto-included with DataTables and its bundled files register under per-file handles (pramnos-datatable,pramnos-gridjs) instead of colliding on one — fixing theCannot find script: pramnos-adaptersfatal. The controller enqueuespramnos-datatable, guarded byisScriptRegistered().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 todisplay():show/getApiListare public, while the create/edit form andsave/deleterequire 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 emptyclass=""; the plain-css.btngainedline-height/vertical-alignso<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:controlleralways 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) fromcrud-controller.stub, driven by the live table schema.create:modelalways generates a full model from the table schema (or from wizard columns duringcreate:migration); the baremodel.stubskeleton path was removed.- Deleted
scaffolding/templates/controller.stubandmodel.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:viewkeeps 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 adata()action that streams rows via\Pramnos\Html\Datatable\Datasource::getList()— exactly like the admin controllers. The web controller'sgetApiList()was removed (the REST API controller undersrc/Apikeeps 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 agetApiList()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 thedata()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:
AuthFlowTestcalledLogin::dologin(), a method that no longer exists — the login submit is handled byAccount::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 thecheckCsrf()seam, and assert success → redirect to the site root and wrong-password → the form re-renders without establishing a session.LoginControllerTestcalledReflectionProperty::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$fkMapmaps 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 forModel::_getApiList()'s signature, implemented on theuserstable via the QueryBuilder (mirroringUser::getUsers()). It supports the parameters that make sense for users:$fields(array / CSV / JSON) validated against the realuserscolumns; unknown fields are silently dropped. Defaults touserid, username, email; the primary keyuseridis always included.$search— case-insensitiveLIKEacrossusername+email(ILIKEon PostgreSQL,LIKEon MySQL).$order— a validated"field dir"clause (field must be a real column; direction normalised toasc/desc).- pagination via
$page/$itemsPerPage.$page <= 0returns all matching rows withpagination => 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. Theif (ltrim($modelClass) === \Pramnos\User\User::class) { …direct users query… }branch was removed fromMakeCommandBase::buildFkOptionsMethod(); User FKs now go through the generic$model->_getApiList(...)path (the label fallback still prefersusername).