# 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 under `exclude` if 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::lint` reports a resource that is used and not enabled. Enable what it
  names; a call it reports exists only when something reaches that line.
- `DOM/presentation` and `DOM/recorder` live in phlo-presentation now, under the
  same names: add that repo's `resources/` to the resource paths in
  `data/app.json`, as in `"paths": {"resources": ["/path/to/phlo-presentation/resources/"]}`.

## Requests and security

- With `security/CSRF` enabled 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, an `Origin` or a `Sec-Fetch-Site` header. 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 without
  `except`. A browser post from outside the app goes in
  `prop %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 `@keys` need 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 `%CSRF` meta 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->verify` calls in routes can go. Your own `fetch()` to the app
  becomes `app.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->body`
  instead of `$_SERVER` and `php://input`.
- `%security->sources`, `->whitelist`, `->sourceList` and `->frameWhitelist` are
  gone. Sources go per directive in `prop %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_path` in
  `static %session.objOptions`, beside their `cookie_domain`.
- The session cookie is httponly, `SameSite=Lax` and 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 with `SameSite=None` and
  secure, or let the whole session travel with
  `static %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()` and `delete()` as well as for reads.
  Code that has to reach every row says so on the model:
  `orders::objUnscoped(fn() => ...)`.
- `DB/query` and `model::query()` are gone: a value is equality, a list is IN, and
  a comparison goes in `where:` with `?` and `whereArgs:`. Remove `DB/query` from
  `data/app.json`.
- `static $joins` is 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/migration` keeps a hash per file in `schema_migration`. A table made before,
  by an earlier build or a script of your own, gets that column on the next
  `migration::run` or `migration::adopt`; `adopt` records 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 an `apcu_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
  `virtual` field (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 `options` are 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 to `labels:`.
- A wysiwyg field's label goes through `sanitize_html()`: enable `sanitize`.
- The raw SQL for the SET of `change()` is `objUpdates:`, no longer `updates:`, so a
  column can be called updates. A column whose name starts with `obj` and a capital is
  refused: that is the engine's. `reflect::audit` asks about an `updates:` whose value
  looks like an assignment. Old and new: `updates:` is `objUpdates:`; a pre-release
  build's `upsert()` and `recordPage()` are `objUpsert()` and `objRecordPage()`.
- A model refuses a column called `where`, `joins`, `group`, `limit`, `whereArgs`, `cache`,
  `cacheKey` or `ignore`, as it did `table`, `columns` and `order`: as a named argument such a
  name became part of the query (`group: 'a'` wrote `GROUP BY a`). Rename the column.
- `objUpsert()` and `objRecordPage()` name their own parameters `$objKey`, `$objPage` and
  `$objSize`. A call that named them as `key:`, `page:` or `size:` now passes column data:
  `objRecordPage(page: 2, size: 25)` filters on columns called page and size. Write
  `objRecordPage(objPage: 2, objSize: 25)`, or pass them in order: `objRecordPage(2, 25)`.
- A number or price field no longer has a length, and `length` and `pattern` now also hold for a value that arrives as a number.
- `date` and most `datetime` fields no longer have `handle`: they follow the rules of a form like any other field. Only `created` and `changed` look after themselves.
- `has()` is gone from objects, so a form field or a column can carry that name.
- `hasData()`, `hasMethod()`, `hasProp()` and `hasClosure()` are now `objHasData()`, `objHasMethod()`, `objHasProp()` and `objHasClosure()`, and `static options` on `session` is `static objOptions`, for the same reason. `reflect::audit` reports what is left.
- `isset()`, `empty()`, `??` and `??=` see a key that only `objGet()` 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 a `false`, `0`, `''` or `[]` from `objGet()` 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 `state` gave you whatever `state()` 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 `accept` holds on the server: a type rule (`image/*`) reads the
  content. An image field accepts `image/*` 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.loading` is gone: enable `loaders/terminal`, which it copied, and any other loader you offer, and name the
  default with `prop loader = 'terminal'`; the CMS links it and lets a visitor pick another, as with a theme.
- The CMS needs `en()`: enable `lang`, which no longer requires AI. A guarded stub
  of your own keeps working.

## Text, languages and realtime

- `lang` no longer requires AI. Enable `lang.address` and 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 with `app.get` (a select that opens a page)
  uses `app.navigate`, which says it is a navigation; `app.get` keeps the
  language of the page it came from. `locale` writes numbers,
  money and dates per language.
- `seo` escapes everything it writes, so give it plain text: a description that
  was passed through `esc()` would show its entities.
- `lastmod` reads the day of the last commit of a source. Stamp where the git history is: in CI, check out
  with `fetch-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 in `lastmod.json` beside 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.
