Migrating to Phlo 1.1
1.1 closes gaps that apps worked around for themselves. Most of an upgrade is
deleting those workarounds; the rest is below, in the order an app meets it.
After each step: build::run, build::lint and reflect::audit.
Build
- A modifier (
%class.node) for a class that is not in the build is now a build error. Enable the class's resource, list it underexcludeif it is left out on purpose, or remove the line. Two files of one layer setting the same node are an error too; the app may still override a resource. - A file with modifiers only is applied now, so a placeholder node that existed only to make such a file count can go.
- A function PHP or the engine already has, or two files declaring one, is a build error; it used to stop the first request.
build::lintreports a resource that is used and not enabled. Enable what it names; a call it reports exists only when something reaches that line.DOM/presentationandDOM/recorderlive in phlo-presentation now, under the same names: add that repo'sresources/to the resource paths indata/app.json, as in"paths": {"resources": ["/path/to/phlo-presentation/resources/"]}.
Requests and security
- With
security/CSRFenabled every POST, PUT, PATCH and DELETE from a browser needs the session's token before any route runs; a browser shows by a cookie, HTTP basic auth, anOriginor aSec-Fetch-Siteheader. A request with none of those, such as a payment webhook with a signature, an API call with a token or a call between installations, is no browser and passes withoutexcept. A browser post from outside the app goes inprop %CSRF.except = ['auth/'](prefixes of the path): an OAuth provider posting back (form_post), a widget on another site, a share target the system posts to. A prefix covers what lies under it, so a route there that needs protection checks for itself. A public form starts the session on its GET and carries{{ %CSRF->field }}, which a route's@keysneed not mention. The guard takes that field out of the payload after checking it, so a check of your own reads%CSRF->verify, which is true after the guard validated the token or when the request carries a valid one itself, and false without a token where the guard checked nothing. The%CSRFmeta in the head starts a session on every page view: a cookie and a session file per visitor, crawlers included, and under PHP's default cache limiter pages sent no-store. A public site without forms that change something may leave the resource off. The%CSRF->verifycalls in routes can go. Your ownfetch()to the app becomesapp.fetch(), which carries the token. A test that posts with a cookie or browser headers but without a page first now gets a 403: fetch the page, keep the cookie, send the token. - Read headers with
%req->header('Name')and the raw body with%req->bodyinstead of$_SERVERandphp://input. %security->sources,->whitelist,->sourceListand->frameWhitelistare gone. Sources go per directive inprop %security.allow = ['img-src' => ['https://cdn.example.com'], 'frame-src' => ['https://*.youtube.com']], and an app that wrote its own CSP to add a source moves there too. An uploaded file served from the app uses%security->sandbox().- Sessions live in a directory named after the app's data directory, so a new release keeps people signed in. Everyone signs in once after this upgrade.
Apps that share a sign-in through one cookie domain found each other's sessions in the shared
directory; now each reads its own. Give them one directory with
save_pathinstatic %session.objOptions, beside theircookie_domain. - The session cookie is httponly,
SameSite=Laxand secure on https, where it had no SameSite before. A Lax cookie stays home on a cross-site POST, so a provider that posts its callback (form_post, as Sign in with Apple does) arrives without the session and a state kept there reads back empty. Keep that state in a short-lived cookie of its own withSameSite=Noneand secure, or let the whole session travel withstatic %session.objOptions => ['cookie_samesite' => 'None', 'cookie_secure' => true]. apcu()keys are scoped per app; values cached before the upgrade are not found again, which costs one cache fill.apcu_forget($key)drops a key.
Data
- Named arguments and scopes keep their parentheses. A where with an OR no
longer reaches past a named argument or a model's
static where. Check reads that relied on that, which is to say on a bug. - A model's scope now holds for
change()anddelete()as well as for reads. Code that has to reach every row says so on the model:orders::objUnscoped(fn() => ...). DB/queryandmodel::query()are gone: a value is equality, a list is IN, and a comparison goes inwhere:with?andwhereArgs:. RemoveDB/queryfromdata/app.json.static $joinsis gone: use a parent, child or many field.- A save writes only the columns that changed since the record was read. A save
without changes runs its hooks and no UPDATE, so a column that should change
on every save changes in
beforeSave. - JSONDB refuses a where it cannot read, an OR included, instead of ignoring it.
DB/migrationkeeps a hash per file inschema_migration. A table made before, by an earlier build or a script of your own, gets that column on the nextmigration::runormigration::adopt;adoptrecords the hash of the files that ran without one, and a row your script writes without one is not checked.- Each migration file runs in a transaction with its record. That undoes a failed file on SQLite and PostgreSQL. MySQL and MariaDB commit every CREATE, ALTER and DROP by themselves, so there it undoes only a file without DDL: keep one change per file and take a dump before a run.
- A read with
cacheKey:on a model is named within the model's cache, so a write through the model moves it on and anapcu_forget()of that key finds nothing: drop the call.
Fields and the CMS
- A field's label escapes the stored value. A field whose value is markup is a
virtualfield (which shows its prop as it is) or writes a label of its own. - A record's string form (its
view) is text wherever the engine prints it, in titles, links and options. Markup in a model's view moves to a field. - A select's
optionsare the stored values, as before. To store one thing and show another:labels: ['open' => 'Open', 'paid' => 'Paid']. - A select or multiselect checks a value it saves against its options and fails
the save with the reason when it is not one of them. An empty value on a field
that is not required passes, a field without options checks nothing, and the
CMS checks what a save changes, so an old value outside the options does not
block editing the rest of the record. A select with keyed options
(
'0' => 'none') now offers the labels as values, so it moves tolabels:. - A wysiwyg field's label goes through
sanitize_html(): enablesanitize. - The raw SQL for the SET of
change()isobjUpdates:, no longerupdates:, so a column can be called updates. A column whose name starts withobjand a capital is refused: that is the engine's.reflect::auditasks about anupdates:whose value looks like an assignment. Old and new:updates:isobjUpdates:; a pre-release build'supsert()andrecordPage()areobjUpsert()andobjRecordPage(). - A model refuses a column called
where,joins,group,limit,whereArgs,cache,cacheKeyorignore, as it didtable,columnsandorder: as a named argument such a name became part of the query (group: 'a'wroteGROUP BY a). Rename the column. objUpsert()andobjRecordPage()name their own parameters$objKey,$objPageand$objSize. A call that named them askey:,page:orsize:now passes column data:objRecordPage(page: 2, size: 25)filters on columns called page and size. WriteobjRecordPage(objPage: 2, objSize: 25), or pass them in order:objRecordPage(2, 25).- A number or price field no longer has a length, and
lengthandpatternnow also hold for a value that arrives as a number. dateand mostdatetimefields no longer havehandle: they follow the rules of a form like any other field. Onlycreatedandchangedlook after themselves.has()is gone from objects, so a form field or a column can carry that name.hasData(),hasMethod(),hasProp()andhasClosure()are nowobjHasData(),objHasMethod(),objHasProp()andobjHasClosure(), andstatic optionsonsessionisstatic objOptions, for the same reason.reflect::auditreports what is left.isset(),empty(),??and??=see a key that onlyobjGet()gives, such as a relation:$order->lines ?? []gives the lines, where it used to give the fallback. A relation without records is set (an empty list), and afalse,0,''or[]fromobjGet()is kept by??. Asking whether a relation is set loads it, and a getter without a cache of its own runs twice for??, once to see that it is set and once to read it.- A column that is NULL now reads as null. It used to fall through to a method or prop with the same name, so a NULL
stategave you whateverstate()returned. A prop that served as a default for a NULL column reads the column itself:prop label => $this->objData['label'] ?? 'none'. - A file field's
acceptholds on the server: a type rule (image/*) reads the content. An image field acceptsimage/*unless it says otherwise. - The CMS validates what it writes against each field's rules, and a record can
refuse a field for now with
objRefusal($field)on the model. An app that did this with its own field mode can drop it. CMS.loadingis gone: enableloaders/terminal, which it copied, and any other loader you offer, and name the default withprop loader = 'terminal'; the CMS links it and lets a visitor pick another, as with a theme.- The CMS needs
en(): enablelang, which no longer requires AI. A guarded stub of your own keeps working.
Text, languages and realtime
langno longer requires AI. Enablelang.addressand the address carries the language with a route written once; an app that rewrote prefixes or listed every translated slug in its routes can move to%app->slugs. With the language in the address, code that navigates withapp.get(a select that opens a page) usesapp.navigate, which says it is a navigation;app.getkeeps the language of the page it came from.localewrites numbers, money and dates per language.seoescapes everything it writes, so give it plain text: a description that was passed throughesc()would show its entities.lastmodreads the day of the last commit of a source. Stamp where the git history is: in CI, check out withfetch-depth: 0, or a page gets no date instead of the day of the release. A source changed but not committed keeps the day of its last commit. The dates are inlastmod.jsonbeside the generated PHP: stamp the build you ship, and a deploy that copies files one by one takes that file along.wsCast()returns how many sockets it reached and never throws, so a wrapper that caught its errors can go. Channels (wsJoin(),channel:<name>) need phlo-daemon from the same release.
Last
reflect::audit lists what is left: superglobals, state-changing routes
without CSRF, debug on a public host, and any API named above. An empty list is
a migrated app.
This guide comes from the engine on which this site runs. As markdown: migration.md. The version promise: versioning.md.
Last updated on 8 November 2026