# Characterization tests for the web application

## Purpose

This suite pins what the web application does today, including its known bugs and oddities. It was written on Laravel 6 and
PHP 7.4; on this branch it runs on Laravel 13.39 and PHP 8.4 (see "What changed at the PHP 8.0 step", "What changed at the
Laravel 7 step", "What changed at the Laravel 8 step", "What changed at the PHP 8.1 step", "What changed at the Laravel 9
step", "What changed at the PHP 8.2 step", "What changed at the Laravel 10 step", "What changed at the PHPUnit 10 step", "What changed at the Laravel 11 step", "What changed at the PHP 8.3 step", "What changed at the PHPUnit 11 step", "What changed at the Laravel 12 step", "What changed at the PHP 8.4 step" and "What changed at the Laravel 13 step"). It was the safety net for upgrading to Laravel 13 one step at a time; that upgrade path is finished on this branch. A failing test after a change means behaviour changed: decide
consciously whether the change is wanted. Do not edit a test to match new behaviour without understanding why it changed.
Tests that are expected to fail at a particular upgrade step say so in their comments (see "Version-dependent tests").

The application code is not changed by the suite. If something cannot be tested without changing the application, it is
written down as not testable instead.

## How to run

The tests need the Docker environment repository that sits next to this application: MySQL 8.0 loaded with the frozen schema
(`docker/schema.sql`), the php-fpm container named `web`, and the stand-in service `obic-stub` for the outside APIs. From the root
of that repository, with the containers up (`docker compose --profile web up -d web`) and `composer install` done:

```
docker compose --profile web exec -T web vendor/bin/phpunit
```

- The suite uses the `web_test` database (set in `phpunit.xml`). `docker/web-db-reset.sh` in the environment repository recreates it,
  and `web_app` (for manual use), from the schema. The database server is shared with the tracking application: never point the
  tests at another database name; `ExternalHttpGuardTest` checks that the name starts with `web_`.
- Two suites exist, `Unit` and `Feature`. The Dusk browser files under `tests/Browser` and `tests/DuskTestCase.php` are not part of
  either suite and are never run.
- Tests that need a real asset build carry the group `built-assets` and are left out of the default run.
- Run one PHPUnit process at a time against the default database where you can. Two runs at the same time work (every test is
  rolled back, files go to a directory per process). The seeders write fixed keys, so a test that runs one takes a database lock
  by name for its length (`TestData::takeTheSeederLock()`); without it two runs deadlocked on the same unique e-mail address.
  For the same reason a test never deletes, updates or truncates rows by anything but the keys of the rows it created (a
  statement without a key locks every row of the table until the test ends), and a value that is unique in the table (an
  e-mail address, a code, a key) is made with `Str::random()`, not typed as a fixed text; an empty column default is a value
  too, so a raw insert names such a column.
- `phpunit.xml` raises the PHP memory limit to 2G. Every test boots its own application, and PHP 7.4 without OPcache (how the
  Docker environment runs the command line) keeps the compiled closures of every file it includes again, so the memory of a run
  grows with the number of tests, not with their size. The legacy factory registry is therefore loaded once per process
  (`SharesFactoryRegistry`). What remains is the group closures of `routes/web.php`, which the application includes for every
  application it boots (about 0.18 MB per test); it disappears when OPcache is switched on for the command line
  (`php -d zend_extension=opcache -d opcache.enable_cli=1 vendor/bin/phpunit` keeps the whole run flat and about twice as fast).

### One file, one test, random order

```
docker compose --profile web exec -T web vendor/bin/phpunit tests/Feature/HarnessTest.php
docker compose --profile web exec -T web vendor/bin/phpunit --filter testTheApplicationRunsAsLocal
docker compose --profile web exec -T web vendor/bin/phpunit --order-by=random
```

Every test must pass on its own, as one file and in any order. After adding tests, run the whole suite with `--order-by=random`
(PHPUnit prints the seed, and `--random-order-seed=<seed>` repeats a run). A test that only passes after another one has run is a
bug in the test.

### The whole check in one command

`bash docker/web-gate.sh` in the environment repository runs everything that proves the baseline: static checks, the full suite
alone and once in random order, the letter case check, the production asset build with the built-assets and JavaScript tests, the
migration check, a check that the run left `/var/log/laravel`, `web/storage`, `web/public`, `web/bootstrap/cache` and the container's
`/tmp` as they were, and a smoke run against the live stack. `--quick` leaves out the asset build, the built-assets and JavaScript
tests and the random-order run; `--ledger` only prints the route and class ledger. It refuses to start while PHPUnit runs or `web/`
has uncommitted changes. `bash docker/migrate-check.sh` (also part of the gate) runs the application's own migrations on an empty
scratch database and compares the result with the frozen schema, which the suite itself does not do.

### Letter case of file names

The development disk does not tell `Library` from `library`, and neither does an optimised class map, but the Linux server does.
`bash docker/web-case-check.sh` in the environment repository copies the code to the container's own case-sensitive disk, builds an
unoptimised autoloader and loads every route controller, provider, middleware and application class by its path name; add
`--phpunit` to run the suite from that copy as well. `NamespaceCaseTest` checks the same thing statically on every run.

### Built assets and the JavaScript helpers

The portal pages load scripts and stylesheets that an asset build writes into `public/` (not part of the repository). Two things
check them:

- `tests/Feature/BuiltAssetsTest.php` (group `built-assets`, left out of the default run) renders the login page and the top page
  of every portal against the real `public/` directory and checks that each local script and stylesheet is a file there, spelled
  exactly as the page spells it, and that the portal's bundle is loaded. It also scans the Blade files for literal `asset('...')`
  paths and pins the two that point at nothing. Build first with `bash docker/web-assets.sh`, then run
  `docker compose --profile web exec -T web vendor/bin/phpunit --group built-assets --exclude-group mix-only`. Without a build it fails, it does not skip.
- `tests/Feature/NoManifestTest.php` (in the default run) pins what happens without a build: the portal templates throw the Mix
  "manifest does not exist" exception and the application answers 500.

The front-end helper scripts in `resources/js` are tested with Node, not PHPUnit, in the fixed Node container that builds
the assets. `tests/js/run.js` runs every `tests/js/*.test.js` file with Node's own `assert` (no extra package); a test file calls
the global `test(name, fn)`. Run them from the environment repository:

```
docker compose --profile web-assets run --rm -T web-node node tests/js/run.js
docker compose --profile web-assets run --rm -T web-node node tests/js/run.js insert
```

The optional argument runs only the tests whose file or test name contains it. The runner exits with status 1 when a test fails or
when it finds no test at all.

A test that is true only for one Node or tool version is tagged when it is written, with a third argument:
`test(name, fn, { group: 'node-18-only' })`. `tests/js/excluded-groups.txt` lists the groups the runner leaves out (one per line,
`#` starts a comment); the runner counts them in its last line. The change that makes a group false adds the group name to that
file; the tagged test itself is never edited. Tests without a group always run.

`tests/js/node-18.test.js` holds what is true because the assets are built on Node 18 (groups `node-18-only`,
`openssl-legacy-flag-only`, `unchanged-lock-build-output`). The last of its tests compares `public/mix-manifest.json` with the
manifest Node 14 produced for the same lock file, so it needs the production build to be in `public/` (the web gate builds first).
The Node 18 container sets `NODE_OPTIONS=--openssl-legacy-provider` because webpack 4 hashes with MD4, which OpenSSL 3 only
offers with that option.

`tests/js/node-22.test.js` holds what is true because the assets are built on Node 22 (groups `node-22-only`,
`openssl-legacy-flag-only`, `unchanged-lock-build-output`). It compares the SHA-256 of the 19 files the production build writes to
`public/` (manifest, stylesheets, scripts, licence files and source maps) with the values Node 14 and Node 18 produced for the same
lock file. `tests/js/excluded-groups.txt` lists `node-18-only`, so the Node 18 version test is left out; the other two Node 18
tests are still true and still run.

`tests/js/vue-2.7.test.js` holds what is true because the front end runs on Vue 2.7 (groups `vue-2.7-only`, `lockfile-v1-only`,
`vue-loader-15.9-only`, `vue-2.7-build-output`). `package.json` asks for `vue` and `vue-template-compiler` at the same version, the
lock holds both at that version (vue-loader refuses a pair that differs), and the packages Vue 2.7 added (the compiler package with its
own PostCSS 8 and Babel parser) sit inside `@vue/compiler-sfc`, so PostCSS 7, Babel 7.10, Laravel Mix 5 and webpack 4 keep their locked
versions. The tests that need the production build in `public/` check that every script bundle holds Vue 2.7 with the template
compiler (the pages are in-DOM templates), that the stylesheets and the Popper copy are byte for byte what the Vue 2.6.11 build
produced, and the SHA-256 of all 19 build files. `tests/js/excluded-groups.txt` lists `unchanged-lock-build-output`: the scripts and the
manifest are different files from this step on, so the two older tests of that group (Node 18 and Node 22) are left out and never
edited. The browser smoke expects `Vue.version` 2.7.16 (`tests/e2e/expectations.js`) and has one more check in `tests/e2e/checks/vue.js`
that compiles and renders a template string in the page, which only the full Vue build can do.

`tests/js/mix-6.test.js` holds what is true because the assets are built with Laravel Mix 6 on webpack 5 (groups `mix-6-only` and
`mix-6-build-output`). `package.json` and the lock file (lockfile version 3) name Mix 6.0.49, webpack 5.105 (5.106 and later break the
progress bar of Mix 6, 5.107 and later lack a file Mix 6 loads), vue-loader 15, sass-loader 12 and PostCSS 8 next to Vue 2.7.16; the
scripts are `mix` and `mix --production` under the same names as before; the web-node container no longer sets the OpenSSL legacy
provider. `webpack.mix.js` turns on the Vue 2 loaders with `.vue({ version: 2 })` and pins `vue$` to `vue/dist/vue.common.js`: Mix 6
would otherwise alias the ES module build, and the entry files do `window.Vue = require('vue')`. The production build writes the same 19
file names as on Mix 5, plus a source map for each of the three stylesheets (22 files, 16 manifest keys, every value with a 32 digit `?id=`
query). The build-output group pins the SHA-256 of all 22 files and the facts about the stylesheets: the same minified Bootstrap 4.5.0 as
before apart from hsla() colours, no Internet Explorer or old WebKit prefixes and a charset rule (Autoprefixer 10 and cssnano 5).
`tests/js/excluded-groups.txt` lists `openssl-legacy-flag-only`, `vue-2.7-only`, `lockfile-v1-only`, `vue-loader-15.9-only` and
`vue-2.7-build-output`: they describe the Mix 5 build, its version 1 lock file and its bytes, and are never edited; the Vue 2.7.16
facts that are still true are pinned again in the new file. The browser smoke runs unchanged on the Mix 6 build (15 checks).

`tests/js/vite-prep.test.js` holds what is true because the front end sources were made ready for Vite while the build is still Laravel
Mix 6 (groups `vite-prep-source` and `vite-prep-only`). The source group states facts that stay true when the build tool changes: the
application JavaScript does not use `global`, the Sass imports Bootstrap without the webpack tilde, no import names a `.vue` file without
its extension, the entry files load their modules with `import` in the order they were required, `require.context` appears only in
comments, `MIX_APP_URL` is the only build-time constant (read at five places), and exactly the listed files are still CommonJS:
`bootstrap.js` (its requires sit in a try/catch and hold the jQuery decision) and the eight files with `module.exports`, which the Node
helper tests load with `require()`. The `vite-prep-only` group states the facts about the Mix 6 build: `webpack.mix.js` builds the three
portal bundles and nothing else (no `app.js`, no Sass, no Popper copy), the production build writes 10 files and a manifest of 6 keys,
the six Blade `mix()` call sites, and the Mix 6 package facts of `mix-6.test.js` that are still true. That file's groups `mix-6-only`
and `mix-6-build-output` are listed in `tests/js/excluded-groups.txt`: four of its tests name the dropped outputs and the rest share
their group; the file is not edited. In PHP, two tests of `BuiltAssetsTest` (the static asset references, and the other known references)
pinned the built `js/app.js` and `css/app.css`, which the build no longer writes. They carry the group `built-assets-with-dead-outputs`,
which the web gate leaves out (`--group built-assets --exclude-group built-assets-with-dead-outputs`), and
`BuiltAssetsWithoutDeadOutputsTest` is their successor: it pins the 6 key manifest and that only the unused layout still names the
dropped files.

`tests/js/vite.test.js` holds what is true because the assets are built with Vite 7 (group `vite-only`; the tests that read
`public/build` need the production build there). It pins: Vite 7, laravel-vite-plugin 2 and @vitejs/plugin-vue2 2.3 in `package.json`
and the lock file (version 3, about 110 packages instead of 790) and none of the webpack tools; the seven script names, now running
`vite`; the content of `vite.config.js` (the three portal bundles, the full Vue build with the template compiler under the `vue`
alias, the two aliases, `MIX_APP_URL` replaced at build time, published source maps, absolute image paths and white space kept in the
component templates); no CommonJS left in `resources/js`; the 8 former `module.exports` files export their class as default and as
`'module.exports'`, which makes `require()` return the class on Node 22.12 and later, so `api-client.test.js` and `helpers.test.js`
run unedited; the six `@vite` call sites and the three `defer` attributes on the footer scripts of the influencer top page (a module
script is deferred, a plain script is not, so without `defer` slick and tile would attach to the page's first jQuery before the
bundle replaces it); and, from the build, the manifest `public/build/manifest.json` with hashed file names, a source map for every
script, Vue 2.7.16 with the compiler in every portal bundle, `MIX_APP_URL` replaced by its value and no `public/hot`. The groups
`vite-prep-source` and `vite-prep-only` of `vite-prep.test.js` are listed in `tests/js/excluded-groups.txt` (the sass sources, the
`app.js` entry and the CommonJS list it pins are gone, and the rest describes the Mix 6 build); the facts of it that are still true
are pinned again in the new file. In PHP, `Tests\Concerns\FakeViteManifest` writes a fake `build/manifest.json` (and `viteBundleTag()`
returns the tag a page carries); `FakeMixManifest::useFakeMixManifest()` calls it, so the 26 classes that render a portal page get the
Vite manifest from the one place they already use. 16 expectation lines in 7 test files (PublicPagesTest, InfluencerScreensTest,
OperatorScreensTest, MerchantScreensTest, NoManifestTest, ViewRenderTest, Laravel9BehaviourTest; the six rows of one data provider are
among them) that spelled the old tag or the Mix exception, for example `<script src="/js/operatorApp.js?id=test">`, carry an
`UPGRADE STEP (Vite)` comment. `BuiltAssetsTest` and `BuiltAssetsWithoutDeadOutputsTest` have the group `mix-only` (they stay out of the
default run with the whole group `built-assets`); `BuiltViteAssetsTest` (groups `built-assets` and `vite-only`) is the successor against
the real build and the gate runs `--group built-assets --exclude-group mix-only`;
`ViteBehaviourTest` runs in the default suite (stylesheet and preload tags, the hot file, the content security policy). The layout
`layouts/app.blade.php`, which no page extends, stays: `DeadLayoutTest` and the view list of `ViewRenderTest` name it.

`tests/js/node-24.test.js` holds what is true because the assets are built on Node 24 with npm 11 (group `node-24-only`): the Node major
is 24, npm is 11, `package.json` declares no `engines` and Node 24 is inside the `^20.19.0 || >=22.12.0` of Vite 7 and
laravel-vite-plugin, and the OpenSSL legacy provider is not set (MD4 is refused and nothing in the build asks for it). One test (group
`vite-7-build-output`) pins the 19 files of `public/build` byte for byte (SHA-256): the production build on Node 24 is the same as on
Node 22, and nothing else is in the folder. The Node 22 version test (`node-22-only` in `node-22.test.js`) is listed in
`tests/js/excluded-groups.txt`; `api-client.test.js` and `helpers.test.js` pass unedited on Node 24.

### Browser smoke (`tests/e2e`)

Nothing above runs the built JavaScript. `bash docker/web-browser-smoke.sh` in the environment repository builds the production
assets, loads pages of the three portals in headless Chromium (Playwright container, profile `web-browser`) and puts `public/` back
as it was; `--no-build` uses the build that is there (the web gate does), and a name part runs only the checks that contain it, for
example `bash docker/web-browser-smoke.sh --no-build operator`. 15 checks, each in a fresh browser context: the build manifest's
files are served, each with its content hash in the name; the influencer login, sign-up, password reminder and policy pages, the merchant login and
password reminder pages (which load no bundle, as found), the operator login page, and the top page of each portal after a real
login with the smoke users of `docker/fixtures/web-smoke.sql` (plus a second page for merchant and operator). A page check asserts:
status 200; no uncaught script error; no `console.error` other than a blocked external resource or a file in
`expectations.knownMissing`; no same-origin 4xx/5xx; the jQuery CDN URL requested once (answered with the npm package `jquery`,
which must be 3.5.1) and `window.jQuery` 3.5.1; the portal bundle requested exactly as the manifest spells it (the hashed file below
`/build/`) and answered 200, and requested once; `window.Vue` the Vue constructor of the expected version, the `#wrapper` root mounted (`__vue__`), no `influencer-`,
`merchant-` or `operator-` component tag left in the DOM and no `{{ }}` text; slick and tile on the influencer top page. One more
check proves the browser cannot reach an external host name or address when the request interception is off.

The browser never reaches an external host: Chromium maps `localhost:8081` (the address the bundles are built for) to the
`web-browser-origin` container and resolves nothing else, `context.route` answers the jQuery CDN URL from the local npm package and
aborts everything else (an attempted host that is not in `expectations.externalHosts` fails the check), and the container sits on an
internal Docker network without a gateway. Fonts, Tag Manager and the Facebook pixel are blocked and ignored.

What changes with a front-end step goes into `tests/e2e/expectations.js` (Vue and jQuery version, how a bundle is found in the
manifest, the list of external hosts and of known missing files); a step that adds behaviour adds a file to `tests/e2e/checks/`
(each exports `[{ name, run(browser) }]`, like `checks/influencer.js`). The checks are plain Node with `playwright-core` from the
image (`docker/browser`), not part of `package.json`.

## Environment the tests rely on

Everything is pinned in `phpunit.xml` or in `tests/CreatesApplication.php`; nothing depends on the git-ignored `.env`.

- The application can only run as `local`, `staging` or `production`, so the tests run as `local`. The framework checks CSRF tokens
  in tests only for `testing`, so the base class switches `VerifyCsrfToken` off, and `CsrfTest` switches it back on to pin the real behaviour
  (set `protected $withCsrf = true;` in a class that needs it).
- `config/database.php` has a second connection, `mysql_read`. The test transaction covers only the default connection, so the
  base class makes `mysql_read` use the same connection for the length of each test.
- All outbound targets (the OBIC API, the Rakuten endpoints, mail relay, log shipping) point at the stub service or the loopback
  address, mail uses the `array` transport, the queue runs inline, cache and session are `array`. The default log channel is
  `null`. A real host is never called: the Docker environment also resolves the known real host names to the container itself.
  `ExternalHttpGuardTest` fails if any of this is missing, and also if a forbidden host name appears in a test file or in `phpunit.xml`.
- Storage (`storage_path()`, the `local`, `public` and `filestore` disks, compiled views), every log file and every asset directory
  live in `/tmp/web-test-<process id>` and are removed when the process ends; the directory is emptied after every test.
  `/var/log/laravel`, the real `storage/` and `public/` are not written to.
- PHP runs in `Asia/Tokyo` while the MySQL server clock is UTC. Tests set every date they assert, freeze the clock with
  `freezeTime()`, and never rely on a value the database fills in by itself. Log records carry the real clock (not the frozen one).

## Test helpers (`tests/Concerns`, `tests/TestCase.php`, `tests/TemporaryRoot.php`)

- `TestCase`: base class for tests that boot the application. Rolls every test back (`DatabaseTransactions`), shares the read
  connection, turns CSRF off by default, restores the superglobals, empties the temporary directory after each test, and offers
  `actingAsInfluencer()`, `actingAsMerchant()` and `actingAsOperator()` (a model from a factory, or an id), and starts the
  test transaction with the isolation level READ COMMITTED (under the server's default REPEATABLE READ two runs at the same
  time deadlocked on the gap locks of statements such as the password broker's delete). The framework's own
  `actingAs()` also makes that guard the default guard of the test, which the application only does once a login check has
  accepted a request (it starts as `web`); these helpers put `web` back, so a check of the default guard (the `guest`
  middleware, `Auth::user()` below no login check) sees what it sees in the application. A test that calls a middleware, a
  library or a controller method directly, below the login check, chooses the default itself with `useAsDefaultGuard()`.
- `TestData`: `rawInsert()` writes a row with the strict SQL mode and the foreign key checks off, for tables that have no model
  and for rows that are deliberately invalid or legacy; every use carries a comment saying which of the two. `seedMaster()` runs
  named seeder classes inside the test transaction. `knownPassword()` is the plain password behind the hash the factories store.
- `FactoryHelpers`: the model list, the exclusion list, the states of the factory files and the read-back of a created row, shared by
  the factory tests.
- `DeliveredTestRows`: the rows the delivered billing test expects (see "Model factories and seeders").
- `FreezesTime`: `freezeTime()` freezes `Carbon::now()` in Tokyo time.
- `FakeMixManifest`: `useFakeMixManifest()` and `useEmptyPublicDirectory()` point the public path at a temporary directory, so views
  that call `mix()` do not depend on an asset build (and the no-manifest behaviour can be pinned).
- `ExternalStub`: client for `obic-stub`. `stubUrl()` names a scenario of the payment API or a Rakuten endpoint,
  `cannedStubResponse()` stores a reply, `stubRequests()` reads back what the application sent, `newStubToken()` gives every test its
  own token. It fails, never skips, when the stub is not running.
- `RakutenIdTokens`: builds the `id_token` of the Rakuten login with the installed `lcobucci/jwt` 3.x (unsigned, HS256, RS256 or
  with a replaced signature, any claims; `validClaims()` are the claims of a good token, `memberClaims()` a minimal one, and
  `rawIdToken()` writes a token from JSON text for claims the builder cannot make), stores it on the stub and points the token
  endpoint of the first or the omni Rakuten application at it. Used together with `ExternalStub`.
- `SentMail`: reads the messages collected by the `array` mail transport and exposes version-neutral accessors (addresses, subject,
  body, charset, the complete raw text and a check of the plain UTF-8 form). It is the only place that touches the mailer library.
- `LogDirectories`: finds and parses what the log channels wrote (`logFilesOf()`, `useStandardLog()`, `standardLogRecords()`).
  The daily channels of the application, including the command channel that `BaseCommand` points at `/var/log/laravel`, are moved
  into the temporary directory when they are built.
- `TempStorage`: `tempStoragePath()` and `freshScratchDirectory()` for tests that lay out files.
- `MakesUploadedFiles`: `uploadedFileWith($bytes, $clientName, $clientMime)` writes a real file into a scratch directory and wraps it
  in the uploaded-file object the controllers and helpers read (the class also uses `TempStorage`).
- `ValidatesFormRequests`: runs a form request class the way the framework does (`formRequest()`, `errorsOf()` returns field =>
  messages or an empty array, `exceptionOf()` for the requests that end in an exception instead of a refusal), without the
  application's middleware (no trimming, no empty-text-to-null conversion: pass null yourself). `assertRefused()` and
  `assertAccepted()` start from a payload that must pass and change only the named fields (`omitted()` leaves one out).
- `MakesImageBytes`: hand-written PNG, JPEG and GIF headers (the container has no GD) padded to an exact byte size, for the
  `mimes`, `dimensions` and `max` rules; use with `MakesUploadedFiles`.
- `InspectsRenderedPages`: what a portal page sends to the browser, for the tests of the Blade screens that mount Vue
  components: `viewNameOf()` and `viewDataOf()` (the view and the data the controller handed to it), `componentAttributes()`
  (the attributes of the one `<influencer-xxx-screen ...>` tag, HTML-decoded as the browser hands them to Vue; a `:name`
  attribute is a Vue expression) and `csrfTokenOfPage()`. Nothing else of the markup is read.
- `BuildsTrackingData`: hand-countable clicks, displays, purchases with their two items and approved results with literal creation
  times (`clickOn()`, `impressionOn()`, `purchaseAfter()`, `itemsOf()`, `approveItem()`), for the report tests.
- `BuildsOperatorReportData`: one small, hand-countable set of clicks, displays, purchases and approved results in January 2026
  (two merchants, one running and one paused, two influencers; the description at the top of the file adds up every figure) for the
  operator report downloads, with `reportWorld()`, `reportConditions()` (the `requestJson` the screen sends) and `csvLinesOf()` (the
  Shift-JIS file as UTF-8 lines). Use with `BuildsTrackingData`.
- `BuildsMerchantReportWorld`: the same idea for the merchant report endpoints (three controllers read it through different queries,
  so one March 2026 set serves all three and the answers are compared): `reportWorld()`, `reportQuery()` (the request values that
  give a fixed order), `marchFigures()` and `marchCsv()` (the expected report rows and file lines, written out), `csvFileNames()`,
  `csvLinesOf()` and `assertCsvDownload()`. Use with `BuildsTrackingData`.
- `BuildsMerchantCampaignForms`: the form the merchant's campaign screen posts, as text the way the browser sends it
  (`campaignForm()`, `paymentSetting()`), and `encodedForTheUpdate()` for the update endpoint, which wants most values as JSON text.
- `BuildsMoneyWorld`: small rows for the tests of the reward, invoice, payment and billing commands: `moneyWorld()` (a merchant, an
  influencer, a campaign for the whole of 2026 with its link), `moneyCampaign()`, `moneyPayment()` and `limitPaymentTo()` (a payment
  setting that starts on 2026-01-01, not "now"), `uncalculatedItem()` and `calculatedColumnsOf()`. Use with `BuildsTrackingData`.
- `BuildsMailWorld`: the rows and the request object the mail tests build their mailables from (`world()`, `inquiryRequest()`): a
  merchant, a category, an influencer with every social network filled in and a limited campaign, with names written out so a test
  can look for them in a body.
- `RunsConsoleCommands`: `runCommand()` runs an application command through Artisan and returns its exit code, its output object text
  and what it printed with a bare `echo` (the `batch:*` commands do), so a test can assert on all three; `useTemporaryBasePath()`
  points the application at a scratch base path holding copies of the named files, for commands that read the source tree.
- `SetsSuperglobals`: `mirrorQueryToGlobals()` and `actAsClient()` for the controllers that read `$_GET` and `$_SERVER` directly.
- `EnvironmentVariables`: `withEnvironment()` changes process environment variables for a callback and puts them back exactly;
  `loadConfigFile()` evaluates a file of `config/` against the current environment. From Laravel 7 (phpdotenv 4) the environment
  loader keeps a record of the variables it loaded in a static property; the helper empties it when a test ends, so a variable
  that a test removed or changed is not read back from the git-ignored `.env` at the next boot.
- `PinsSessionId`: `pinSessionId()` makes the following requests carry the session id the test has now (the test client sends no
  cookie of its own, so otherwise every request starts a new session id); `sessionIdNow()` then shows whether a request
  regenerated the session.
- `PhpVersionAware`: `phpAware()` returns the expectation written for the running PHP major version and fails if there is none;
  `phpAtLeast('8.1')` says whether the running PHP is at least a version. The base `TestCase` has `thrownClassOf()`, which
  gives the class PHP raised for an exception that left a request (Laravel 6 and 7 wrap a PHP Error in Symfony's
  FatalThrowableError; the helper looks through it).

## Model factories and seeders

Test data comes from the model factories in `database/factories` and the seeders in `database/seeds`. There is one factory file per
model in `app/Model` (`<Model>Factory.php`), written in the style Laravel 6 requires (`$factory->define`, `$factory->state`,
`$factory->afterCreating`). `factory(Model::class)->create()` gives a valid row under the strict server mode with the foreign keys on.

- **Parents.** A foreign key column is a nested factory (`'merchant_id' => factory(Merchant::class)`), never a number. The nested
  factory runs only when the test does not pass the key itself, so `create(['merchant_id' => $merchant->id])` creates no extra merchant.
  `FactoryCompletenessTest` fails when a foreign key points at a row the factory did not create.
- **Defaults.** The default is the simplest valid and ordinary row: an admitted influencer, a running public campaign with a fixed
  reward, a merchant on plan A, a freshly changed password. Set explicitly every value a test asserts; do not rely on a default for
  a status, a date, an amount or an owner. Unique columns (e-mail addresses, names, numbers) get a random part; never use
  `$faker->unique()`, because its state outlives a test and simultaneous runs share the database. Every operator, merchant and
  influencer made by a factory has the password `TestCase::PASSWORD` (the hash is written into the factory).
- **Japanese text.** Names, addresses and company names that the application shows come from a `ja_JP` Faker created inside the
  factory (the configured locale is `en_US` and is not changed). Faker's `realText()` is not used for Japanese: it loads more than 100 MB.
- **States.** A variant a test needs is a named state: `factory(Campaign::class)->states('ended', 'private')->create()`. The
  state names say what they set (`notAdmitted`, `refused`, `passwordExpired`, `paused`, `fromIphone`, `trashed`, and so on); the
  comment above each state names the column and the values. A state sets only the columns it names. When a test needs a variant that is
  missing, add a named state to that model's factory file in the same commit as the test, and add its promised columns to the table in
  `FactoryStatesTest` (the test fails for a state that has no entry). Soft deleted rows come from the `trashed` states.
- **Things a factory does for you.** A campaign gets one link of its own, and a purchase (`TrackingConversion`) gets two items;
  creating a link on its own therefore gives its campaign a second link, and an item on its own is the third of its purchase. Both
  are pinned in `FactoryStatesTest`. The nested factories are told their parent explicitly so that they do not create each other.
- **Models that cannot have a factory.** `Admin`, `FinancialInstitution` and `Inquiry` have no table in the schema, and `User` only
  in a migration that never runs. The list with the reasons is `FactoryHelpers::$modelsWithoutFactory` and is pinned exactly by
  `FactoryCompletenessTest`. The tables `jobs`, `failed_jobs` and `password_resets` have no model; write them with `rawInsert()`.
- **Adding a model.** Add `database/factories/<Model>Factory.php` with a default that creates its parents; run `FactoryCompletenessTest`
  and `SchemaDriftTest` (the second also checks that every `$fillable`, `$casts` and `$dates` entry is a column and that every
  required column is covered). A model whose table does not exist yet goes on the list above with its reason.
- **Models built on `Pivot`** (`CampaignRelatedTag`, `InfluencerFavoriteCategory`, `InfluencerJoinedCampaign`, `InfluencerRelatedTag`,
  `MailedInfluencer`, `CampaignPaymentInfluencer`) do not learn the key of a row they insert, so `->id` is empty after `create()`;
  read such a row back by its columns.

Reference data comes from the seeders through `seedMaster(...)` (`TestData`), inside the test transaction. These seeders run alone on
an empty database and are pinned with the rows they write in `SeederTest`: `BankTableSeeder`, `BankBranchSeeder`,
`BankBranchTableSeeder`, `FavoriteCategoryTableSeeder`, `RelatedTagTableSeeder`, `FaqCategoryTableSeeder` (and `FaqTableSeeder` after it),
`MailForMerchantTableSeeder`, `InfluencerPaymentRecordTableSeeder`, `InfluencerTableSeeder`, `MerchantsTableSeeder`,
`OperatorTableSeeder`. The other seeders were written to run in a fixed order on a database whose counters start at 1 and fail alone
(`SeederTest` lists each with the reason; `TestDataSeeder`, which calls them in order, fails on an empty database). The seeders are
used as delivered and are not edited. `db:seed` runs only `OperatorTableSeeder`; its output wording is framework text and is not asserted.

The delivered `BillingDataUnitTest` is kept as it was delivered. It names the operator 1, the merchants 1 and 2 (charging tax) and the
influencers 1 and 2 by number, so `tests/Concerns/DeliveredTestRows.php` creates those rows for that class and for no other.

**Cost at the Laravel 8 step (not paid here).** The `factory()` helper and the `$factory->define` files stop working. Either add
`laravel/legacy-factories`, which keeps every test line and factory file as they are, or turn the factories into classes under
`Database\Factories` with `HasFactory` on the models. The models live in `App\Model`, not `App\Models`, so the automatic name guess
points at `Database\Factories\Model\<Name>Factory`: each model needs a `newFactory()` or the files need a `Model` sub-folder. The seeders
move from `database/seeds` to `database/seeders` (namespace `Database\Seeders`) with a change of the Composer autoload section.
`FactoryHelpers` (state list, registry access, `factoryFor()`) is the only test code that knows the legacy registry.

## Writing tests

- Test data comes from the model factories and the seeders (see above), not from an external fixture builder. Set explicitly every value a test
  asserts (status, dates, amounts, ownership); a factory default is the simplest valid row and can hide an unrealistic combination.
- Each test method or class carries doc-comment tags for the coverage ledger: `@route GET /exact/uri/{param}` for every route it
  calls (the URI as `artisan route:list` prints it, with a leading slash) and `@subject Fully\Qualified\Class` for the class it pins.
  `LedgerAnnotationsTest` fails on a tag that names nothing.
- Everything PHPUnit reads from a test is an attribute, not a doc-comment tag: `#[Group('laravel-12-only')]`, `#[DataProvider('rows')]`
  (a static method), `#[Test]`, `#[BeforeClass]`, with the `use PHPUnit\Framework\Attributes\...;` line. PHPUnit 11 reports a tag in a doc
  comment as a deprecation, which makes the run not pristine, and PHPUnit 12 ignores it. `@route` and `@subject` are not PHPUnit's: they stay
  doc-comment tags for the ledger. `Phpunit11BehaviourTest` fails on a doc comment that holds a tag PHPUnit reads.
- Name a test after what it asserts. A test that pins a quirk is named after the quirk. Japanese messages are pinned verbatim.
- Output stays clean: no warnings, deprecations, risky or incomplete tests. Laravel 8 drops PHP deprecations while the tests run, so
  `Tests\TestCase` listens for them itself and fails a test during which PHP reported a deprecation for a file of the application, or
  "Passing null to parameter" from anywhere (see "What changed at the PHP 8.1 step"). Do not pin a configuration value that comes from
  `env()` and can differ per developer; force it with `config([...])` inside the test or pin only what `phpunit.xml` sets.
- External HTTP only through the stub, always with a token of its own. Never print or assert the text of the OBIC key.
- Tests that need a PHP-version-specific expectation use `phpAware()`; the `'7'` entry is what PHP 7.4 does, an `'8'` entry written
  before it has been run says so in a comment. Every `'8'` entry in the suite has been run on PHP 8.0, 8.1, 8.2, 8.3 and 8.4 (see below);
  no test needs `phpAtLeast()` any more, because the PHP 8.1 branches that expected an exception are gone.
- Do not write the number of tests or assertions into this file or into comments: they change with every addition.

## Version-dependent tests

A test that is only true for some Laravel or PHPUnit versions carries a group at the time it is written, using the same group names as
the tracking application's suite (for example `laravel-7-only`, `laravel-8-only`, `phpunit-9-only`). The step that makes the group
untrue excludes it in `phpunit.xml`. This baseline uses doc-comment annotations (`@group`, `@dataProvider`) because that is what
PHPUnit 9 reads; later steps convert them where the tooling forces it, in a commit of their own. The PHPUnit 11 step did that for all of
them: on this branch groups, data providers and the like are attributes (see "Writing tests").

- New tests that describe what the application does today go on the earliest branch where they are valid, normally the baseline.
- A change that a PHPUnit deprecation or a library change forces stays in its own commit in the step that needs it.
- A deliberately changed expectation gets a comment starting `Hop note:` that says what changed and why, and the commit message
  names the test. An expectation that a framework upgrade forces (the application behaves differently on the new version) is
  edited in a commit of its own and carries a comment starting `UPGRADE STEP`.

The groups in use, and what `phpunit.xml` does with them on this branch:

| Group | Meaning | On this branch |
| --- | --- | --- |
| `laravel-6-only` | true on Laravel 6, false from Laravel 7 on | excluded (the tests of the same facts on Laravel 7 are in `Laravel7BehaviourTest`) |
| `laravel-6-to-7-only` | true on Laravel 6 and 7, false on 8 | excluded (the tests of the same facts on Laravel 8 are in `Laravel8BehaviourTest`) |
| `laravel-6-to-8-only` | true on Laravel 6 to 8, false on 9 | excluded (the tests of the same facts on Laravel 9 are in `Laravel9BehaviourTest`) |
| `laravel-6-to-9-only` | true on Laravel 6 to 9, false on 10 (the smaller translation tables, a new campaign without files ending on a null object) | excluded (the tests of the same facts on Laravel 10 are in `Laravel10BehaviourTest`) |
| `laravel-6-to-10-only`, `laravel-7-to-10-only` | true up to Laravel 10 | no test carries them any more: all of them were run on Laravel 11 and moved to `-to-11-only` |
| `laravel-8-to-10-only`, `laravel-9-to-10-only` | true up to Laravel 10, false on 11 (one test each: the view data keys of a mail with the hidden mailable variable, the white space that typed text loses) | excluded (the tests of the same facts on Laravel 11 are in `Laravel11BehaviourTest`) |
| `laravel-6-to-11-only`, `laravel-8-to-11-only`, `laravel-10-to-11-only` | true up to Laravel 11, false on 12 (which of two equal route names wins, the exact answer for an SVG icon and the operator create page name; the links list of a paginated answer without a page number; the exact end of the text of a failed query: 5, 1 and 1 tests) | excluded (the tests of the same facts on Laravel 12 are in `Laravel12BehaviourTest`) |
| `laravel-7-to-11-only`, `laravel-9-to-11-only` | true up to Laravel 11 (no test of them fails on Laravel 12; on Laravel 13 one of the second group failed and moved to `laravel-9-to-12-only`, the others still hold) | runs |
| `laravel-6-to-12-only` | true up to Laravel 12, false on 13 (three tests: an authentication exception without a login route ends in a server error, and a page size of zero in the two merchant report tests ends in a server error) | excluded (the tests of the same facts on Laravel 13 are in `Laravel13BehaviourTest`) |
| `laravel-9-to-12-only` | true up to Laravel 12, false on 13 (one test: the size rule throws for a size that is not a number) | excluded (the test of the Laravel 13 answer is in `Laravel13BehaviourTest`) |
| `laravel-6-to-13-only` | true up to Laravel 13 (the 37 baseline tests that were `laravel-6-to-12-only` and still hold on Laravel 13) | runs; no later step is planned |
| `laravel-8-to-12-only`, `laravel-10-to-12-only` | true up to Laravel 12 (12 and 10 tests that were retagged at the Laravel 12 step); all of them still hold on Laravel 13 | runs |
| `laravel-7-only` | true on the Laravel 7 line (tests of `Laravel7BehaviourTest`) | excluded |
| `laravel-8-only` | true on the Laravel 8 line (tests of `Laravel8BehaviourTest`) | excluded (the tests of the same facts on Laravel 9 are in `Laravel9BehaviourTest`) |
| `laravel-9-only` | true on the Laravel 9 line (one test of `Laravel9BehaviourTest`: the framework's middleware priority list) | excluded (the test of the Laravel 10 list is not needed: the application's own order is pinned elsewhere) |
| `laravel-10-only` | true on the Laravel 10 line (three pins of `Laravel10BehaviourTest`: the libraries, the composer constraints and the Doctrine schema manager; the pin of collision 7 in `Phpunit10BehaviourTest`) | excluded (the pins of Laravel 11 are in `Laravel11BehaviourTest`) |
| `laravel-11-only` | true on the Laravel 11 line (three tests of `Laravel11BehaviourTest`: the libraries, the composer constraints of the framework and its neighbours, and the composer constraints of the test runner, which is also `phpunit-10-only`) | excluded (the pins of Laravel 12 are in `Laravel12BehaviourTest`) |
| `laravel-12-only` | true on the Laravel 12 line (two tests of `Laravel12BehaviourTest`: the libraries and the composer constraints) | excluded (the pins of Laravel 13 are in `Laravel13BehaviourTest`) |
| `laravel-12-to-13-only` | true on Laravel 12 and 13 (nine tests of `Laravel12BehaviourTest`: the path encoding middleware, the first-wins route names, the text of a failed query, the table listing, the date trait, the image rule, the page number of the links, the mail address check, the new framework defaults; they were `laravel-12-only` until the Laravel 13 run showed them true) | runs |
| `laravel-13-only` | true on the Laravel 13 line (17 tests of `Laravel13BehaviourTest`: the libraries and constraints, the contract of a user who must verify the address, the CSRF middleware wiring and the same-origin pass, `Request::get()` and the file/text precedence, what the framework's star does for the proxies, the session and cache settings that are not taken, the cache prefix default, the failed-query text, the QUERY verb, the 401 without a body, the page size of zero, the size rule, the JSON 503 while down) | runs; a step that leaves Laravel 13 excludes it |
| `phpunit-9-only` | true while PHPUnit 9.6 and collision 6 are installed (one test of `Laravel10BehaviourTest`) | excluded (the test of the PHPUnit 10 pin is in `Phpunit10BehaviourTest`) |
| `phpunit-10-only` | true while PHPUnit 10.5 is installed (the four tests of `Phpunit10BehaviourTest`; the collision 7 pin is one of them and also carries `laravel-10-only`; and the test of `Laravel11BehaviourTest` that holds the constraints of the runner) | excluded (the pins of PHPUnit 11 are in `Phpunit11BehaviourTest`) |
| `phpunit-11-only` | true while PHPUnit 11.5 and collision 8.9 are installed (the four tests of `Phpunit11BehaviourTest`) | runs; the next PHPUnit step excludes it |
| `php-7-to-8.0-only` | true on PHP 7.4 and 8.0, false from PHP 8.1 (the upload of a link file with a never closed quoted field) | excluded (the test of the PHP 8.1 answer is in `Php81BehaviourTest`) |
| `php-7-to-8.1-only` | true on PHP 7.4 to 8.1, false from PHP 8.2 (a line break inside a quoted CSV field is removed by the readers of the uploads) | excluded (the tests of the PHP 8.2 answer are in `Php82BehaviourTest`) |
| `php-7-to-8.2-only` | true on PHP 7.4 to 8.2, false from PHP 8.3 (bytes that are not valid UTF-8 in a display file name are kept by the multibyte cut; the offset message for content without a header says "on value of type bool") | excluded (the tests of the PHP 8.3 answers are in `Php83BehaviourTest`) |
| `php-8.1-and-later` | true from PHP 8.1 on (tests of `Php81BehaviourTest`) | runs; a step that leaves PHP 8.1 for a version that changes the fact excludes it |
| `php-8.2-and-later` | true from PHP 8.2 on (tests of `Php82BehaviourTest`) | runs; a step that leaves PHP 8.2 for a version that changes the fact excludes it |
| `php-8.3-and-later` | true from PHP 8.3 on (tests of `Php83BehaviourTest`) | runs; a step that leaves PHP 8.3 for a version that changes the fact excludes it |
| `php-8.4-and-later` | true from PHP 8.4 on (tests of `Php84BehaviourTest`; one of them describes the file type detection of the Debian 13 image that PHP 8.4 runs in) | runs; a step that leaves PHP 8.4 for a version that changes the fact excludes it |
| `built-assets` | needs the real asset build | excluded, runs in the gate |

When the run on a new version shows that a tagged test is still true, the tag is widened on the baseline (a new commit there, then
the later branches are rebased); when a test is false, it is tagged on the baseline and the step excludes the group. A test is
never edited to say the new version unless the application itself behaves differently (the `UPGRADE STEP` case).

## Application quirks pinned on purpose

The meta tests pin facts that look like bugs and are part of the delivery: for example a second migration with the same class name
(a plain `migrate` on an empty database stops), the default of `config/app.php` for the OBIC address being the production host,
real recipient addresses written into `config/mail.php`, the validator traits and a provider that are not at their PSR-4 paths, and
references to classes that do not exist. They are listed where they are tested (`MigrationFilesTest`, `ExternalHttpGuardTest`,
`NamespaceCaseTest`).

## What changed at the PHP 8.0 step

PHP moved from 7.4 to 8.0.30 while Laravel stayed at 6.20.45. The suite was run on PHP 8.0 and every difference was settled
one of three ways: an application fix that PHP 8.0 forced, a corrected expectation, or a note.

**Dependencies.** A full `composer update` on PHP 8.0. The one change that reached the code is `lcobucci/jwt` 3.4.6 to 4.3.0: the 3.x
line declares support for PHP 5.6 and 7 only, so Composer refuses to install it on PHP 8. It also brings `lcobucci/clock`, and
`laravel/socialite` 5.30 brings `firebase/php-jwt` and `phpseclib`. The library needs the PHP extension `sodium` (it is in the
Docker image; a server needs it too).

**Application fixes (each in its own commit).**
- The two Rakuten login callbacks read the token with the 4.x parser. The session values are the same as before (the audience is
  still its first character, the expiry and issue times are still Unix times). Two things differ: a token with two or more
  audiences used to end in the failure page on Laravel 6 (a library deprecation notice became an exception) and now signs the visitor up
  with the first character of the first audience; a token whose expiry, issue or not-before time is not a number is now refused
  (`RakutenLoginTest`, the audience-list test and the non-numeric expiry test). The signature, issuer, audience and expiry are
  still not checked.
- The influencer daily report (`POST /api/merchant/report/influencerDaily`) answered 500 on every call, because the page count took
  `list % page size`. The remainder is written so that the page count is what PHP 7 gave (one page too high when the days divide
  evenly; `MerchantReportNewApiTest`).
- The bulk reward import (`batch:rewardBulkRegistration`) divides the amount by the quantity in three places; a quantity of 0 is a
  `DivisionByZeroError` on PHP 8, which the `catch (\Exception)` blocks missed, so the run stopped with its lock file left behind.
  The three blocks catch `\Throwable`; the outcome is the same as on PHP 7 (`RewardBulkRegistrationTest`, three tests).

**Corrected expectations (they had been guessed before PHP 8 was run).** `array_multisort` ties give the same order on 7.4 and 8.0
(`CampaignModelTest`); a `foreach` over a text is a warning, not a TypeError (`CustomValidatorRulesTest`); the `getimagesizefromstring`
read-error message and the undefined array key message are worded differently (`HelpersTest`, `RewardBatchDiffTest`); a TypeError
reaches the test wrapped in Laravel 6's `FatalThrowableError` (`thrownClassOf()`, six login and password tests); `basename()` is no longer
locale dependent, so the Japanese file name is kept under the C locale (`MediaDownloadTest`); a non-numeric number of children and an
empty extension period get a second validation message because the loose comparisons changed (`InfluencerSignUpTest`,
`MerchantCampaignRequestsTest`); `NamespaceCaseTest` reads the single namespace token of PHP 8. All other `'8'` entries were run and are
verified on PHP 8.0.

**New pinning tests.** A result code with a blank after the merchant number is accepted on PHP 8 and refused on PHP 7.4
(`MerchantAchievementApiTest`; only the signed-in merchant's own number passes).

**Silent changes of PHP 8.0 that no test pins, and why.** Loose comparisons on garbage input in the notice navigation, the operator's
SNS and child-count checks and the `'all'` filters: reachable only with a hand-edited URL or request, display or no-effect only.

**Deprecations.** Laravel 6 turns a PHP deprecation into an exception, so a deprecation hit by any test would have failed it; none did.
The PHPCompatibility checker with `testVersion 7.4-8.0` over `app routes config database bootstrap tests` finds only the four notes
of the baseline (`ValueError` in `CsvArrayTest`, `0x1A` in `OperatorRequestsTest`, a mixed line ending, `$this` in `routes/console.php`)
and the guarded `T_NAME_QUALIFIED` constant of `NamespaceCaseTest`. With `testVersion 8.0-` it also lists what later steps will have to
deal with (implicit nullable parameters and `fputcsv`/`str_getcsv` without `$escape` from 8.4, `trim()` defaults and switch-case
semicolons later still); none of that is an error on 8.0.

**Known flake, not caused by PHP.** `MerchantCampaignApiTest::testAnotherMerchantCanOfferAForeignCampaignAndTheNoticeNamesTheCaller`
creates two merchants with random Faker company names and asserts that one name is absent from the other's message; it fails
when both get the same name (it did once in the runs of this step). It belongs to the baseline.

## What changed at the Laravel 7 step

Laravel moved from 6.20.45 to 7.30.7 while PHP stayed at 8.0.30. The suite was run on Laravel 7 before anything was settled, and
every difference was put into one of four boxes.

**Dependencies.** `laravel/framework` 7.30, Symfony 5.4 (event dispatcher and translation 6.0), `vlucas/phpdotenv` 4.3,
`ramsey/uuid` 4.9, `facade/ignition` 2.17, `nunomaduro/collision` 4.3. `laravel/ui` ^2.0 is new: fourteen controllers use the
authentication traits (`AuthenticatesUsers`, `ResetsPasswords`, `SendsPasswordResetEmails`, `RegistersUsers`, ...) that Laravel 7
moved out of the framework into that package. `staudenmeir/laravel-upsert` is gone: its newest release for Laravel 7 allows only PHP
7, and no release allows both Laravel 7 and PHP 8. `fruitcake/laravel-cors` is not added (see below). The audit ignore list has 16
entries (the six Symfony 4 advisories no longer apply).

**Application changes.**
- The exception handler asks for `Throwable` (a fatal error on Laravel 7 otherwise), and so does the `failed()` hook of the
  merchant report job (Laravel 7 hands it a PHP Error as it is; Laravel 6 wrapped it in an exception).
- `Bank` and `BankBranch` no longer load the upsert trait; the application never called `upsert()`.
- Dates in JSON stay as they were: Laravel 7 writes the dates of `toArray()` and `toJson()` as ISO 8601 text in UTC
  (`2024-06-30T15:30:00.000000Z`) where Laravel 6 wrote the database format in the application time zone
  (`2024-07-01 00:30:00`). The Vue screens read the old text, and a date without a time of day, such as the birth date of a
  child, would arrive one day early. The trait `App\Model\Concerns\SerializesDatesWithTheDatabaseFormat` brings back the Laravel 6
  method; every model class uses it. `ModelJsonDatesTest` pins the format for every class of the application and through one JSON
  route. To accept the Laravel 7 format, remove the trait from the models and change the front end together.

**Framework-owned files taken from the Laravel 7 skeleton** (the application's own parts kept): `config/session.php` (SameSite is
`lax` where it was `null`; the secure flag has no default), `config/mail.php` (`default` and `mailers` instead of the Laravel 6
keys; `MAIL_MAILER`, with a fallback to `MAIL_DRIVER` until every environment is renamed; the application's `to` and `error_mail`
blocks kept), the `Http` alias, the `links` entry of `config/filesystems.php`, the cache and queue entries, `TrustHosts` (off),
`VerifyCsrfToken`, `RouteServiceProvider`, and a kernel without a priority list of its own. Not taken:
`resources/lang/en/passwords.php` (only two messages are reworded, and the application keeps its English and Japanese messages
equal) and the CORS package and file (the application has no CORS middleware; `Laravel7BehaviourTest` pins that a cross-origin
request gets no Access-Control header).

**The tests.**
- Application behaviour that changed and was edited with an `UPGRADE STEP` comment: the SameSite value of the session and
  XSRF cookies (`CookieAndProxyMiddlewareTest`); `MAIL_DRIVER` left `phpunit.xml`.
- Facts of Laravel 6 that Laravel 7 made false, tagged `laravel-6-only` on the baseline and excluded here: the stock alias list,
  the Laravel 6 mail and session settings, the plain 405 page, the kernel's priority list, the cached route file name
  (`routes.php`, now `routes-v7.php`), the three upsert tests and the `different` validation rule with a missing field (Laravel 6
  refuses the value, Laravel 7 lets it pass; the five password rules that use `different:email` always have the address in their
  data, see `ValidationDifferentRuleTest`). Their Laravel 7 counterparts are in `Laravel7BehaviourTest`.
- Tests that were tagged `laravel-6-only` and still hold on Laravel 7 now carry `laravel-6-to-7-only`.
- Checks that were only written in Laravel 6 terms were made version-neutral on the baseline: the harness checks of the mail
  transport (`mail.default`, or `mail.driver` on Laravel 6) and the log middleware helpers (the top page was requested as `//`,
  which Symfony 5 refuses).
- The pin of the JSON date format and the other facts of this step are in `ModelJsonDatesTest` and `Laravel7BehaviourTest`.

**For the owner.**
- `php artisan route:cache` fails on Laravel 7: seven route names are carried by more than one route (two names given by hand
  twice, and the group prefixes `api.influencer.`, `api.merchant.` and `api.operator.` that every unnamed route of those groups
  gets). Laravel 6 cached the routes. Nothing else is affected; if a deploy runs `route:cache`, the routes need names of their own
  first (`Laravel7BehaviourTest` lists them).
- The environment variable `MAIL_DRIVER` is now `MAIL_MAILER`. Until it is renamed, `config/mail.php` falls back to the old name;
  with neither set the default is smtp.
- The session cookie now says `SameSite=Lax` (browsers already treated a missing attribute as Lax).
- phpdotenv 4 no longer expands `${NAME}` inside single quotes. The example file has none; check the real `.env`.
- The `array` session driver keeps its data now (the suite uses it); every request still starts a new session id.

## What changed at the Laravel 8 step

Laravel moved from 7.30.7 to 8.83.29 while PHP stayed at 8.0.30. The suite was run on Laravel 8 before anything was settled; the
failures were put into the four boxes of the earlier steps.

**Dependencies.** `laravel/framework` 8.83, `laravel/ui` 3.4, `nunomaduro/collision` 5.11, `vlucas/phpdotenv` 5.8,
`league/commonmark` 2.10, `dragonmantank/cron-expression` 3, `laravel/serializable-closure`, and the new
`laravel/legacy-factories`: the model factories and the seeders keep their Laravel 6 form (`$factory->define`, `factory()`,
seeders without a namespace in `database/seeds`), so no factory file and no test data line changed. The models stay in
`App\Model`. `fideloper/proxy` stays (Laravel 9 needs it replaced). The audit ignore list has 6 entries (the ten of
`league/commonmark` 1.x no longer apply).

**Application changes.** None in `app/`. Framework-owned files taken from the Laravel 8 skeleton: `public/index.php` (loads the
pre-rendered maintenance page), `storage/framework/.gitignore`, `server.php`, and the `Date`, `Js` and `RateLimiter` aliases and the
deprecations log setting. Not taken on purpose: the renamed maintenance middleware (the application class keeps its name), the
Sanctum and throttle entries of the HTTP kernel, `current_password` in the trim and flash lists, `app/Models`, the CORS package,
the `uuid` column for failed jobs and the other config entries that only add defaults.

**The tests.**
- Application behaviour that changed and was edited with an `UPGRADE STEP` comment: a page of a paginated api route now has a
  `links` list (three tests that compare the key list of such a page: the influencer message list, the influencer notice list, the
  influencer campaign search).
- Facts of Laravel 6 and 7 that Laravel 8 made false, tagged `laravel-6-to-7-only` or `laravel-7-only` and excluded here: the
  options `--message` and `--allow` of `down`, the stock error pages for 401, 403 and 429, the page keys of the paginator, the queue
  trait properties of the mails, the bank upsert, the alias count, the priority list, and a PHP deprecation that fails the request.
  Their Laravel 8 counterparts are in `Laravel8BehaviourTest`. The tests that still hold carry `laravel-6-to-8-only` or
  `laravel-7-to-8-only`.
- Checks written in Laravel 6 terms were made version-neutral on the baseline: the casts and dates of soft deleting models (the
  trait casts the delete marker from Laravel 8) and the view data of the mails (the properties of the queue trait are read from the
  trait). `PhpDeprecationInRequestTest` was added on the baseline.

**For the owner.**
- Every paginated api answer has a new `links` key (an array of `url`, `label`, `active`); the other keys are unchanged. The
  application renders no pagination links itself, so the Tailwind default of Laravel 8 is not used anywhere.
- `php artisan down` no longer has `--message` or `--allow`. Use `--secret=<phrase>` (a visit to `/<phrase>` sets a cookie that lets
  the visitor through), `--redirect`, `--render`, `--retry`, `--refresh` and `--status`. A runbook or deploy script that passes
  `--allow` stops working.
- `php artisan db:seed --class=OperatorTableSeeder` fails (a class name without a namespace is looked up in `Database\Seeders`); use
  `--class='\OperatorTableSeeder'`. A plain `db:seed` still works.
- PHP deprecations no longer fail a request. They go to the channel named by `LOG_DEPRECATIONS_CHANNEL` (default: the null
  channel, so nowhere) and are dropped in the test run. Deprecations that PHP 8.1 reports for the application's code will not show up
  in the suite; set a real channel in a development `.env` and use the application before the PHP 8.1 step.
- `php artisan route:cache` still fails for the same reason as on Laravel 7 (routes that share a name); the closures in
  `routes/web.php` are only group callbacks and never were the blocker.

## What changed at the PHP 8.1 step

PHP moved from 8.0.30 to 8.1.34 while Laravel stayed at 8.83.29. The deprecations of PHP 8.1 are the main subject: Laravel 8 no longer
turns them into exceptions and drops them while the tests run, so they were collected with a throwaway error handler that records
every deprecation of a full run (nothing of it is kept in the repository), and every place the application causes one was settled.

**Dependencies.** `composer.json` is unchanged; `composer update` re-resolved the lock under PHP 8.1 (Symfony translation, string,
css-selector and event dispatcher 6.0 to 6.4, `symfony/service-contracts` 3, `ramsey/collection` 2, `doctrine/instantiator` 2,
`lcobucci/clock` 3, `phpseclib/phpseclib` 4, `brick/math` 0.13, `laravel/socialite` 5.31). The lock can no longer be installed on PHP 8.0.
The audit result is the same as before: no open advisory, the same six ignored on purpose.

**The environment.** The PHP 8.1 image already loads OPcache, so loading it a second time from the mounted settings printed
"Cannot load Zend OPcache - it was already loaded" at the start of every command line `php`; the test that starts a child process
(`MerchantReportApiTest`, the time limit of the report controllers) read it as output. The environment repository no longer loads
the extension from the mounted file.

**Deprecations found and settled.** A full run on PHP 8.1 reported the "Passing null to parameter" deprecation (and one fractional
number conversion) at 58 places of the application, all in code that handles an empty field: `explode`, `trim`, `rtrim`,
`json_decode`, `strtotime`, `preg_match`, `preg_replace`, `strpos`, `mb_convert_kana`, `addslashes`, `round`, `number_format`,
`new DateTime`, the password check and the lock-out key of the logins. They are fixed at the place the null is handed over, with
the empty text, `0` or `'now'` that PHP 7 made of the null, so no screen, message, log line or counter changed. Where an array key is
read, the key is still read (a missing key still raises its error); a list given where a text is expected is passed on and fails
as before. A number with a decimal point in the category address (`/influencer/searchcategory/5.5`) is cut to its whole part in the
controller instead of by the parameter. After the fixes a full run reports no deprecation for the application. What is left comes
from libraries: the framework's login timer converts a float to an int (`Support\Timebox`, fixed in a later framework line), and the
factories read Faker properties that Faker deprecates (not related to PHP). A sweep that declares every class of the application
and of the libraries finds none in the application; in the libraries `opis/closure` and Laravel's `SerializableClosure`
(`Serializable` without `__serialize`), and the readline code of `psy/psysh` behind `artisan tinker`, are left for the framework steps.

**Tests.**
- `Tests\TestCase` now fails a test when PHP reports a deprecation for a file of the application (app, routes, config, bootstrap,
  database, compiled views) or "Passing null to parameter" from anywhere. `Php81BehaviourTest` pins that and the other new facts.
  Without it the deprecations of the next PHP versions would stay invisible.
- Application behaviour that PHP 8.1 and Laravel 8 changed together, edited with an `UPGRADE STEP` comment: the `phpAtLeast('8.1')`
  branches that expected an `ErrorException` (the login, password change and reset forms, the operator and merchant form requests,
  the e-mail rule, the purchase list, the category address) had been written for Laravel 6; every case gives the answer of PHP 7.4
  now, so the branches are gone and five test names that promised an exception were changed. Two of those 8.1 expectations were
  also wrong in their reasoning (a null message is never measured by the validator; the null banner name case passed because the
  request had no image).
- Facts of PHP 7.4 and 8.0 that PHP 8.1 made false, tagged on the baseline and excluded here (`php-7-to-8.0-only`): the upload of a
  link file whose quoted name is never closed (a name ending in 表 or 能 followed by a closing quote) gives no record for that row
  on PHP 7.4 and 8.0 and five messages; PHP 8.1 gives the fields read so far and three messages (still refused, nothing written).
- One check was written in terms of the detection library of the older image and was made neutral on the baseline: the files of
  the bank upload size test are lines of text, because the newer file type detection does not call a single line of 64 kilobytes or
  more text.
- All `'8'` entries of `phpAware()` are verified on PHP 8.0 and 8.1. A test double that overrides `SplFileInfo::getSize()` carries
  `#[\ReturnTypeWillChange]` so that the run prints no deprecation.

**For the owner.**
- Deploy needs PHP 8.1 with the same extensions as before; run `composer install` from the new lock.
- Set `LOG_DEPRECATIONS_CHANNEL` to a real log channel in the first days after the deployment: Laravel 8 sends deprecations nowhere
  by default, and the places the suite cannot reach (reports and console commands given unusual input) may still hand a null to a
  function. They only log; nothing fails.
- A damaged link upload file (see above) now lists three validation messages instead of five. It is refused and nothing is written.

## What changed at the Laravel 9 step

Laravel moved from 8.83.29 to 9.52.23 while PHP stayed at 8.1.34.

**Dependencies.** `laravel/framework` 9.52, Symfony 6.4 (the lock needs PHP 8.1), Symfony Mailer in place of Swift Mailer, Flysystem 3 in
place of Flysystem 1, collision 6, `spatie/laravel-ignition` in place of `facade/ignition` (a development package), and `fideloper/proxy`
is gone (the framework has its own trusted proxy middleware). `laravel/ui`, `laravel/dusk`, `laravel/socialite`,
`laravel/legacy-factories`, `doctrine/dbal` 2.13 and `lcobucci/jwt` are unchanged. The audit has no open advisory; six are ignored on
purpose (all `laravel/framework`, one of them new and valid for the whole Laravel 9 line, which has no fix), and the Flysystem and Swift
Mailer entries are gone.

**Test helpers made neutral on the baseline.** `SentMail` asks the mailer which library is installed and returns the same plain
values from Swift Mailer and Symfony Mailer, so no mail test changed. `FreezesTime::freezeTime()` is public and untyped because
Laravel 9 gives the test case a method of that name. The route table checks leave the three `_ignition/...` routes of the error page
package out, as they do the browser test routes.

**Tags.** Tests that name the Laravel 8 truth were tagged on the baseline: the Mix exception wording (three more tests), a sign-up with an
address like `a@localhost`, the trimming of NUL (its own test now) and of the full-width space (it was tagged `laravel-6-to-10-only`).
The tests tagged `laravel-6-to-8-only`, `laravel-7-to-8-only` and `laravel-8-only` were run on Laravel 9: the ones that still pass were
retagged `laravel-6-to-9-only`, `laravel-7-to-9-only` and `laravel-8-to-9-only` (annotation only, on the branches that own them); the
ones that fail stay and are excluded here.

**Application changes (each in its own commit).**
- `TrustProxies` extends the framework class and lists the four headers it trusted (the constant for all of them is gone). The
  framework class would turn that list into one that also believes the forwarded prefix header; the class hands its own four back, so a
  forwarded prefix is still not believed. Every caller is still trusted (`*`).
- The size rule built from the posted number of children is left out when the number is not a number (Laravel 9 compares with exact
  numbers and throws): the sign-up and the profile call give the integer rule's message, as PHP 7 did, and not a server error.
- A file that cannot be read is still an error where it was one: Flysystem 3 answers null instead of throwing, so the screenshot
  download and the two campaign pages throw the same exception as before (a download of a missing file would have answered 200 with an
  empty body).
- The directories that the local and filestore disks create stay 0755 (`directory_visibility`); Flysystem 3 would make them 0700.

**Settled without a change of the application.**
- Two requests name a message for an attribute alone (`csv_array`, `click_urls.*.url`). Laravel 9 uses that message for every rule of the
  attribute, so four expectations (achievements result file, purchases from click addresses) carry the new text with an `UPGRADE STEP`
  comment.
- The default disk is read from `FILESYSTEM_DISK`, with `FILESYSTEM_DRIVER` as the fallback.
- The maintenance middleware keeps its name: Laravel 9 still has the class it extends (Laravel 10 removes it).
- No cross-origin middleware exists in this application and none was added.
- `Symfony Mailer`: no application code touches the library. Sending to an address that is not an address throws; a mail goes out as
  one plain UTF-8 part; port 465 means TLS from the first byte and any other port upgrades by itself when the server offers it.

**New truths.** `Laravel9BehaviourTest` pins them; the facts that only hold on this line carry `laravel-9-only`.

**Left alone.** `route:cache` still fails because seven route names are shared. `Laravel9BehaviourTest` does not pin the message; the
web gate does.

**For the owner.**
- The lock needs PHP 8.1 (Symfony 6.4). Run `composer install` from the new lock, remove `bootstrap/cache/packages.php` and
  `services.php` (they would name removed providers), clear the compiled views and reset OPcache.
- Mail: the relay is reached through Symfony Mailer. Check the production `MAIL_*` values with one real send: a relay on port 587 is
  upgraded with STARTTLS whenever it offers it and its certificate is checked, `MAIL_ENCRYPTION=ssl` only means TLS from the first
  byte on port 465, `auth_mode` is ignored, and sender and recipient must be real addresses.
- Typed text now loses white space of every kind at both ends (full-width and no-break space, zero-width space, byte order mark),
  and NUL at the ends is no longer removed. An address without a dot in the domain is refused by the strict address check.
- A file called `*.php7` is refused by the operator message upload; `*.pht` is still accepted and stored.
- Two operator screens show other texts for an empty file and for a bad click address (see above).
- Laravel 9 gets no more security fixes: one advisory is ignored on purpose until the next step.

## What changed at the PHP 8.2 step

PHP moved from 8.1.34 to 8.2.35 while Laravel stayed at 9.52.23.

**Dependencies.** `composer.json` is unchanged; `composer update` re-resolved the lock under PHP 8.2: `symfony/string`,
`symfony/css-selector` and `symfony/event-dispatcher` went from 6.4 to 7.4 (these need PHP 8.2, so the lock can no longer be installed on
PHP 8.1), `nette/utils` 4.1, `dragonmantank/cron-expression` 3.6 and `lcobucci/clock` 3.3. The audit result is the same as before: no
open advisory, the same six ignored on purpose. The environment needed nothing: the PHP 8.2 image loads OPcache by itself, and the
mounted settings no longer load it a second time.

**Deprecations.** The new report of PHP 8.2 is the creation of a property that the class does not declare. A full run with every
deprecation recorded found none in the application, and neither did a search of the 293 classes of `app/` for properties that a class
assigns to itself without declaring (the models assign through Eloquent's own magic, which is not affected), nor a search for
`${var}` strings, `utf8_encode`, `utf8_decode`, strings that name `self::`/`parent::` as callables, `str_split`, `ksort` flags or
locale settings. The only reports that remain come from the factories reading Faker properties (deprecated by Faker, not related to
PHP), and a sweep that declares every class of the libraries finds the readline code of `psy/psysh` behind `artisan tinker` and a few
Symfony classes that are themselves marked deprecated and that nothing loads. The guard in `Tests\TestCase` already counts the new
report, because PHP names the file that creates the property; `Php82BehaviourTest` pins that.

**What PHP 8.2 changed in the application.** The CSV reader of the uploads (`SplFileObject` with the flag that drops line ends, used by
`getCsvArray()` and `CsvArray`) removed a line break inside a quoted field up to PHP 8.1; PHP 8.2 keeps it. A link name or any other
quoted field with a line feed inside therefore comes back from an upload with the line feed (it now matches what the download wrote).
The four tests that described the old answer carry `php-7-to-8.1-only`, were tagged on the baseline (the link upload test was split so
that the checks for blank lines, Windows line ends and a quoted comma stay on every version) and are excluded here; `Php82BehaviourTest`
pins the new answer in the same places.

**Tests.** All `'8'` entries of `phpAware()` are verified on PHP 8.0, 8.1 and 8.2. No earlier test was edited on this branch except
`phpunit.xml` (the new group) and this file.

**For the owner.**
- Deploy needs PHP 8.2 with the same extensions as before; run `composer install` from the new lock.
- Keep `LOG_DEPRECATIONS_CHANNEL` set to a real log channel in the first days after the deployment: a dynamic property that no test
  reaches would only be logged.
- An uploaded CSV whose quoted field holds a line break now keeps the break (see above).

## What changed at the Laravel 10 step

Laravel moved from 9.52.23 to 10.50.4 while PHP stayed at 8.2.35.

**Dependencies.** `laravel/framework` 10.50, Monolog 3.12, `doctrine/dbal` 3.10 (the migrations still change columns through it and
register the enum type on its schema manager; `doctrine/cache` is gone), `laravel/ui` 4.6, `laravel/dusk` 7.13,
`spatie/laravel-ignition` 2.9 (with `spatie/error-solutions`), `carbonphp/carbon-doctrine-types` 2.1, `brick/math` 0.12, and the new
`laravel/prompts`. PHPUnit stays at 9.6 and collision at 6.4: both move in the step of their own. `composer.json` asks for PHP `^8.1`;
the lock needs PHP 8.2. The audit has no open advisory and no abandoned package; five are ignored on purpose (all `laravel/framework`,
each with its reason, none has a fix on the Laravel 10 line), and the file validation entry is gone because 10.50 is outside its range.

**Test helpers made neutral on the baseline.** `FakeMixManifest` sets the public path with `usePublicPath()` as well as the container
binding (Laravel 10 reads the application's own setting). The password reset tests count the sent notifications through
`Notification::sent()` (`assertTimesSent()` is `assertSentTimes()` from Laravel 10). The handler tests build the database exception
with the number of arguments the installed class wants (it takes the connection name from Laravel 10).

**Tags.** Four tests of the translation tables (the English groups now hold the framework's own texts below the application's files, so
they have lines the application's file lacks) and the test of the null object error of a new campaign without files were tagged
`laravel-6-to-9-only` on the baseline (the login group left the shared data provider for a test of its own) and are excluded here.
Every other test of the earlier range groups was run on Laravel 10 and retagged `laravel-6-to-10-only`, `laravel-7-to-10-only`,
`laravel-8-to-10-only` or `laravel-9-to-10-only` (annotation only, on the branches that own them). The single `laravel-9-only` test left,
the framework's middleware priority list, is excluded.

**Application changes (each in its own commit).**
- The date trait of the models answers `getDates()` the way Laravel 9 did. Laravel 10 no longer reads the `$dates` property, so
  `birth_date`, `entry_date`, `start_at`, `end_at`, `password_changed_at` and the like would have come back as plain text and about
  forty screens, sign-up and password change tests broke on a missing date method. The models keep their lists; Laravel 11 needs the
  columns cast instead.
- The HTTP kernel's `routeMiddleware` is called `middlewareAliases` (same entries, same order).
- Comment only: the proxy middleware's header override returns what the framework class returns now.

**Settled without a change of the application.**
- Monolog 3: the command line logger (`DynamicCommandLogger`) and the standard channels keep their line format; the tests passed unchanged.
- The maintenance middleware keeps its name and its parent: Laravel 10 still has the old class name as an empty subclass of
  `PreventRequestsDuringMaintenance` (Laravel 11 removes it).
- `config/hashing.php` was not taken from the skeleton: its `verify` setting makes a check against a text that is not a bcrypt hash throw
  (some accounts hold a random placeholder as their password), and its default cost of 12 would slow every login.
- `config/logging.php` was not given `replace_placeholders` (it would change the text of existing log lines); `config/mail.php`,
  `config/auth.php` and the exception handler were not touched.
- Added from the skeleton, no effect: `lock_path` of the file cache, `batching` of the queue, `partitioned` of the session cookie, the
  typed `boot()` of the broadcast provider.
- A new campaign without its files still fails and saves nothing; the exception is now the disk's missing file error naming the folder.
- The translator loads the framework's English texts below the application's two language directories: a rule the Japanese file lacks
  now falls back to a real English sentence instead of showing its key.

**New truths.** `Laravel10BehaviourTest` pins them (17 tests); the facts that only hold on this line carry `laravel-10-only`. The version
pins in it fail on purpose at the next step that moves one of them: the Laravel major and the constraints of `composer.json` carry
`laravel-10-only`, the PHPUnit 9.6 and collision 6 pins carry `phpunit-9-only` (the PHPUnit step excludes that group).

**Left alone.** `route:cache` still fails because seven route names are shared; the web gate checks the message.

**For the owner.**
- The lock needs PHP 8.2. Run `composer install` from the new lock, remove `bootstrap/cache/packages.php` and `services.php` (they would
  name the removed error page classes), clear the compiled views and reset OPcache.
- A database error now reads `... (Connection: mysql, SQL: ...)` in the log and in the error mails; check any monitoring rule that
  searches the old wording.
- Laravel 10 gets no more security fixes: five advisories are ignored on purpose. One is the cross-site scripting hole in the debug page,
  so production must run with `APP_DEBUG=false`.

## What changed at the PHPUnit 10 step

Only the test runner moved: PHPUnit 9.6 to 10.5.66 and collision 6.4 to 7.12 (with the packages PHPUnit needs: code coverage 10, the
`sebastian/*` libraries, `phpunit/php-*`; `sebastian/resource-operations` and `doctrine/instantiator` are gone). Laravel stays 10.50, PHP
stays 8.2, the application is not touched, and the audit is the same as before (no open advisory, five ignored on purpose). Both packages are
development tools, so there is nothing to do at a deployment.

**Running.** The commands are the same (`vendor/bin/phpunit`, `--filter`, `--order-by=random`, `--random-order-seed`, `--colors=never`).
The output starts with a `Runtime` and a `Configuration` line, and a clean run ends with `OK (<tests>, <assertions>)` as before. Anything
the runner has to say changes that line to `OK, but there were issues!` (or `WARNINGS!`, `FAILURES!`): a deprecation of the runner itself, a PHP
deprecation, a risky test. The web gate treats all of these as not pristine, as it did on PHPUnit 9. `phpunit.xml` asks for the details of
errors, warnings, notices and deprecations (also those of the runner), so the output says which test it was.

**`phpunit.xml`.** Migrated by hand to the PHPUnit 10 schema: `source` with the application directory replaces the coverage block;
`backupStaticAttributes` and the three `convert...ToExceptions` attributes are gone from the schema. PHPUnit 9 turned PHP warnings and
notices into failing tests; PHPUnit 10 collects them and reports them, so `failOnWarning` and `failOnNotice` make the run fail the same
way. PHP deprecations are not turned into failures by the runner: the base test class does that for the files of the application (below).
The group `phpunit-9-only` is excluded. No cache directory is set, so no cache files are written.

**Changes forced by PHPUnit 10, made on the branches that own the tests (they also run on PHPUnit 9.6).**
- A test case of PHPUnit 10 has a final method `result()`; three test classes had a helper of that name. It is now `makeAchievementResult`
  (the name `createResult` is taken by PHPUnit 9).
- PHPUnit 10 reports a data provider that is not static as a deprecation (PHPUnit 11 refuses it). Every provider is a static method now,
  together with the helpers they call (`omitted()`, `phpAware()`, the model name lists and the like), which the providers reach through
  `self::`. Data sets, names and the number of tests are unchanged.

**The deprecation guard of `Tests\TestCase`.** It works as before: it is an error handler put in place after the application booted, it sees
a PHP deprecation first, records the ones for files of the application (and every "Passing null to parameter" report), and the test fails in
`tearDown()`. Laravel's own handler comes after it and drops the deprecation, so PHPUnit 10 does not see these reports itself and does not
list them. A probe test (not kept) confirmed that the creation of a dynamic property in a compiled view and `strlen(null)` still fail the test,
while a deprecation raised by a test file does not.

**Annotations.** `@group` and `@dataProvider` doc comments are still read by PHPUnit 10.5 without a warning; PHPUnit 11 needs attributes
(a later step converts them).

**New truths.** `Phpunit10BehaviourTest` pins the installed versions and the composer constraints, the form of `phpunit.xml` and that
every data provider is static (group `phpunit-10-only`).

**Known weakness.** The fake company names come from a list of about a hundred, so two merchants made in one test can share a name. A test
that asserts the difference of two names (`MerchantJoinedCampaignApiTest`) failed once in a random-order run for that reason and passed when
repeated; it is not a runner effect. (Fixed at the next step: see "What changed at the Laravel 11 step".)

## What changed at the Laravel 11 step

Laravel moved from 10.50.4 to 11.57.1 while PHP stayed at 8.2.35 and PHPUnit at 10.5.66. Symfony went from 6.4 to 7.4, Carbon from 2.73
to 3.15, collision from 7.12 to 8.5, Dusk from 7 to 8.7, and `doctrine/dbal` left the lock. The application keeps the structure of Laravel
10 (the two kernels, the exception handler, the route service provider, the provider and alias lists, every config file); Laravel 11
runs it unchanged, and `Laravel11BehaviourTest` fails by name if someone starts the move to the slim structure.

**First run.** With only the dependencies changed, 5,858 tests ran and 104 failed: 48 operator screens and 1 download (the character set
in a content type is lower case now), 25 mail tests (a hidden view variable), 19 tests of the guest redirect (`redirectTo()` wants a
request), 3 pins of Laravel 10, 2 factory states (Carbon 3 returns floats), 2 runs of the will-finish mail (Carbon 3 refuses a text as a
number of days), and one each for the order of the log channels, the view data keys of a Laravel 8 test, the white space of a Laravel 9
test and the collision 7 pin.

**Edits to earlier tests, made on the branches that own them.** On the baseline: the harness and the checks that depended on a version
detail (the redirect is read with a request, the character set name is compared in lower case, the hidden mailable variable is left out
of the data keys, the order of the log channels does not matter, a day count is cut to whole days) and six new tests for behaviour that
Laravel 11 or its libraries could change without a test noticing (numbers sent as text to the payment history range and to the age
filters, the clock of the upload history, a login that leaves the stored hash alone, a forwarded prefix that is not believed, an invalid
Host header that is a client error). `MerchantJoinedCampaignApiTest` gives its two merchants names of their own (see the known weakness
of the PHPUnit 10 step). The tests that still hold on Laravel 11 changed their group from `...-to-10-only` to `...-to-11-only` (37 on
the baseline, 6 on Laravel 7, 13 on Laravel 8, 16 on Laravel 9, 10 on Laravel 10); the six that do not keep their tag and
`phpunit.xml` excludes the groups `laravel-8-to-10-only`, `laravel-9-to-10-only` and `laravel-10-only`. The test that named PHPUnit
10.5 and collision 7 together is two tests now; the collision one also carries `laravel-10-only`.

**Changes in the application that Laravel 11 or its libraries forced** (each has an `UPGRADE STEP` comment where the code is):
- Carbon 3 refuses a text as the amount of days or months: the days argument of `batch:mailCampaignWillFinish` and the month range of the
  payment history search are cast to whole numbers.
- Carbon 3 reads a time stamp in UTC unless a zone is named: the three `createFromTimestamp()` calls of the achievement upload history name
  the application's time zone, so the stored start time is still in Tokyo time.
- `hashing.rehash_on_login` (new, on by default) is off in `config/hashing.php`: a login writes nothing, as before.
- The 19 migrations that registered the enum type mapping of Doctrine are changed (the constructors are gone), and the migration that
  widens `average_order_price` repeats the default of 0, because a changed column keeps only the attributes it names.

**Decisions.** The date trait needed no change: Laravel 11 still asks `getDates()` (the property `$dates` is still not read), so the models
keep their lists and no cast was added. The maintenance middleware keeps extending the old class name, which Laravel 11 keeps as an empty
subclass. The proxy middleware and the skeleton's `hashing.php`, `logging.php`, `mail.php`, `auth.php` and `session.php` are not taken.
`route:cache` still fails on the seven shared route names.

**New truths.** `Laravel11BehaviourTest` (13 tests): the libraries and constraints (group `laravel-11-only`), the structure that is kept, the
maintenance class, the date trait without casts, the schema builder without Doctrine, the merged configuration, a hash check against a
random text, the exact 400 for an invalid Host header, the trimming of invisible characters, the hidden mailable variable and the Carbon 3
facts.

**Run.** The default run is 5,872 tests with 62,507 assertions; the six tests above are left out by their groups.

## What changed at the PHP 8.3 step

PHP moved from 8.2.35 to 8.3.36 while Laravel stayed at 11.57.1 and PHPUnit at 10.5.66.

**Dependencies.** `composer.json` is unchanged; `composer update` re-resolved the lock under PHP 8.3 and moved one package, `lcobucci/clock`
from 3.3.1 to 3.5.0 (only `lcobucci/jwt`, which checks the Rakuten id token, asks for it). The new version needs PHP 8.3, so the lock can no
longer be installed on PHP 8.2. The audit result is the same as before: no open advisory, the same five ignored on purpose, no abandoned
package. The environment needed nothing: the PHP 8.3 image loads OPcache by itself and the mounted settings print no warning.

**Deprecations.** A full run with every deprecation recorded found none in the application, and a search of the application, the routes,
the configuration, the database folder and the views for the calls that PHP 8.3 changed (`get_class()` without an argument,
`unserialize()`, `range()`, `array_sum()`, `mb_strimwidth()`, `number_format()` with a negative number of decimals, `mt_srand()`,
`assert` settings, `highlight_string()`, `Closure::fromCallable()`) found none either. What PHP 8.3 reports in the tests is
`ReflectionProperty::setValue()` called with one argument, in the helper of the two guard tests that empty the recorded list
(`Php81BehaviourTest`, `Php82BehaviourTest`): both pass the object (`null` for a static property) now, in a commit on the branch that owns the
test. The other reports come from libraries and are not related to PHP 8.3: the factories and seeders read Faker properties,
the file size rules of Laravel's validator ask `brick/math` for floats, and Symfony 7.4 marks `Request::get()` as deprecated; the
controllers call `$request->get('rows_per_page')` and the like at more than 40 places, and Laravel's `Request::get()` still forwards to the
Symfony method. None of these is raised in a file of the application, so the guard does not count them; they will matter when a Symfony
or Laravel version removes the method. A sweep that declares every class of the libraries finds the readline code of `psy/psysh` behind
`artisan tinker` and two Symfony classes that are themselves marked deprecated and that nothing loads.

**What PHP 8.3 changed in the application.**
- Bytes that are not valid UTF-8 in a display file name of a message attachment: the multibyte cut at the last dot used to hand them back
  unchanged and now replaces each of them with a question mark. A browser sends valid UTF-8, so only a damaged name is affected.
- The warning for reading an offset of `false` or `null` names the value ("on false", "on null") instead of the type ("on value of type
  bool", "on value of type null"): the text that reaches the log and the error mails of the image helper and of the reward batch.
- A time string the parser cannot read is a `DateMalformedStringException` (a subclass of `Exception`) and `modify()` throws it instead of
  raising a warning. Every place in the application that catches or lets through `Exception` or `Throwable` behaves as before; the merchant
  influencer search with a text as the age limit is a 500 before and after (the exception behind it changed).

**Tests.** The test of an invalid file name with an extension and the two data sets of the offset message for content without a header
were tagged `php-7-to-8.2-only` on the baseline (each is a test of its own now) and are excluded here. Two other tests no longer depend on
the wording: the reward batch test asks only for an offset read of null, and the influencer report test counts any exception that is not an
`ErrorException` as the date exception. `Php83BehaviourTest` pins the PHP 8.3 answers. All `'8'` entries of `phpAware()` are verified on PHP
8.0, 8.1, 8.2 and 8.3. No earlier test was edited on this branch except `phpunit.xml` (the new group) and this file.

**Run.** The first run on PHP 8.3 (before any change) was 5,872 tests with 6 failures: one invalid file name, two offset messages for
content without a header, one offset message of the reward batch and two copies of the influencer report. The default run is 5,875 tests
with 62,535 assertions: the three tests tagged `php-7-to-8.2-only` are left out and the six of `Php83BehaviourTest` are added.

**For the owner.**
- Deploy needs PHP 8.3 with the same extensions as before; run `composer install` from the new lock.
- Keep `LOG_DEPRECATIONS_CHANNEL` set to a real log channel in the first days after the deployment: a deprecation on a path that no test
  reaches would only be logged.

## What changed at the PHPUnit 11 step

Only the test runner moved: PHPUnit 10.5.66 to 11.5.57 and collision 8.5 to 8.9.5 (with the packages PHPUnit needs: code coverage 11, the
`sebastian/*` libraries, `phpunit/php-*`; `staabm/side-effects-detector` is new). Laravel stays 11.57, PHP stays 8.3, the application is not
touched, and the audit is the same as before (no open advisory, five ignored on purpose). Both packages are development tools, so there is
nothing to do at a deployment. Collision 8.9 is the release that goes with PHPUnit 11.5 and works with Laravel 11, 12 and 13.

**Running.** The commands are the same (`vendor/bin/phpunit`, `--filter`, `--order-by=random`, `--random-order-seed`, `--colors=never`),
and a clean run still ends with `OK (<tests>, <assertions>)`. A deprecation of the runner shows as `PHPUnit Deprecations: <n>` in the
summary and the run is no longer pristine, which the web gate does not accept. `phpunit.xml` needed no change for the schema (PHPUnit
`--migrate-configuration` answers that the file does not need to be migrated); the only edit is the new excluded group `phpunit-10-only`.

**Attributes instead of doc-comment tags.** The first run on PHPUnit 11.5 (before any change) was 5,875 tests with 539 runner deprecations,
every one of them "Metadata found in doc-comment ... Metadata in doc-comments is deprecated and will no longer be supported in PHPUnit 12",
and nothing else: no configuration warning, no notice about a mock without expectations (the suite uses Mockery and no PHPUnit mock), no
removed method. PHPUnit 11.5 has no setting that hides them, so every tag PHPUnit reads was converted in one commit of this branch: 385
`@dataProvider`, 161 `@group` (two of them new in the split described below), 4 `@test` and 1 `@beforeClass` became `#[DataProvider]`,
`#[Group]`, `#[Test]` and `#[BeforeClass]` in 92 files, with the `use` lines; names, groups and data sets are unchanged. The earlier
branches keep the tags (PHPUnit 9 and 10 read them); only this branch has the attributes, as the version-pinned groups of the earlier steps
are all excluded or still true. `@route` and `@subject` are the ledger's tags, not PHPUnit's, and were not touched.

**Change on the branch that owns the test.** `Laravel11BehaviourTest` had one test that held the constraints of the framework and of the
runner together (collision `^8.1`, PHPUnit `^10.5` and the installed PHPUnit version). It is two tests now: the framework part stays
`laravel-11-only`, and the runner part is `testComposerAsksForCollision8AndKeepsPhpunit10Point5` with the groups `laravel-11-only` and
`phpunit-10-only`, which this branch excludes. Both parts hold on PHPUnit 10.5; the split was made on the Laravel 11 branch and the PHP 8.3
branch was rebased on it.

**The deprecation guard of `Tests\TestCase`.** It works as before. A probe test (not kept) on PHPUnit 11.5 confirmed that the creation of a
dynamic property in a file of the application and `strlen(null)` in a test file each fail the test in `tearDown()`, while a user deprecation
and `utf8_decode()` called by a test file pass without a report (Laravel's handler drops them). `Php81BehaviourTest` and `Php82BehaviourTest`
pin the guard and pass.

**New truths.** `Phpunit11BehaviourTest` (group `phpunit-11-only`) pins the installed versions and the composer constraints, the form of
`phpunit.xml`, that no doc comment under `tests` holds a tag PHPUnit reads, and that every data provider named by an attribute exists and is
static.

**Run.** The default run is 5,876 tests with 62,535 assertions. Compared with the PHP 8.3 step (5,875 tests, 62,535 assertions on PHPUnit
10.5): one test more because of the split above, the four tests of the PHPUnit 10 pins (three of `Phpunit10BehaviourTest` and the split-off
one) are left out and the four of `Phpunit11BehaviourTest` are added; one assertion more in `HarnessTest`, which asserts once per excluded
group. The same tests on PHPUnit 10.5 (a scratch copy of the previous branch) give the same set of tests apart from those pins.

**For the owner.** Nothing to deploy. Run `composer install` from the new lock to get the new test tooling.

## What changed at the Laravel 12 step

Laravel moved from 11.57.1 to 12.70.2 while PHP stayed at 8.3.36 and PHPUnit at 11.5.57 (collision 8.9.5). The only other package that
changed is `symfony/polyfill-php84` (new: Laravel 12 refers to a PHP 8.4 class that the polyfill supplies on PHP 8.3). Symfony stays on 7.4,
Carbon on 3.15. The five advisories that were ignored on purpose on the Laravel 11 line are fixed in 12.69 and earlier, so `composer
audit` is clean without an ignore list and `composer.json` has none. The application keeps the structure of Laravel 10 (the two kernels, the
exception handler, the route service provider, the provider and alias lists, every config file); Laravel 12 still runs it, and
`Laravel11BehaviourTest` still says so.

**First run.** With only the dependencies changed, 5,871 tests ran with 2 errors and 15 failures: the 4 Rakuten login tests (the redirect
address sent to Rakuten changed, see below), 5 tests about which of two equal route names wins, 4 tests of the groups that end at Laravel
11 (the framework and library versions, the composer constraints of the framework, the links list of a paginated answer, the end of the
text of a failed query), the helper that builds a query exception for the exception handler tests (2 errors), and the picture test of the
sign-up (an SVG icon gets a second message).

**Edits to earlier tests, made on the branches that own them.** On the baseline: the helper of the exception handler test chooses the
arguments by the number of required parameters (Laravel 12 added optional ones); the picture test of the sign-up only asks for the type
message of an SVG and the exact one-message list moved into a test of its own tagged `laravel-6-to-11-only`; the Rakuten data set of the
duplicate-name test is a test of its own that holds on every version (`route()` of the Rakuten name must stay
`/influencer/login/rakuten/callback`), the other three data sets and the operator create page test are tagged `laravel-6-to-11-only`, and
the route cache test compares the name lookup of its copies with the live routes. The tests that still hold on Laravel 12 changed their
group from `...-to-11-only` to `...-to-12-only` (37 on the baseline, 12 on Laravel 8, 9 on Laravel 10, 1 on Laravel 11). The conversion of
the doc-comment tags to attributes (PHPUnit 11 step) was run again after these changes, so it converts three tags more than that step
describes. No other earlier test was edited on this branch except `phpunit.xml` and this file.

**Changes in the application that Laravel 12 forced.** `routes/web.php`: two routes carry the name `influencer.login.rakuten.callback`
(`login/callback` and `login/rakuten/callback`). Laravel 11 gave a repeated name to the route registered last, Laravel 12 gives it to the
first, so `route('influencer.login.rakuten.callback')`, which the login pages and the token requests send to Rakuten as the redirect
address, would have changed from `/influencer/login/rakuten/callback` to `/influencer/login/callback`. The two lines changed places. For
every method and address of the route table the route that answers was compared between Laravel 11 and Laravel 12 (705 requests, with
each parameter filled in as 1, create and index): identical. The other six shared names (`operator.operator.create`, the two `index`
names of the operator api and the three bare group prefixes) now lead to their first route as well; nothing in the application uses them,
and putting them back by reordering would let the update route swallow the address of the create route, so they are left and pinned in
`Laravel12BehaviourTest`. `route:cache` still fails on the shared names.

**Decisions.** The date trait needed no change: Laravel 12 still asks `getDates()`. The `Storage` default root changed in the framework's own
config (`storage/app/private`), but the application's `config/filesystems.php` names the `local` disk itself and has no `serve` key, so
nothing moves and no file route is registered. The `image` rule refuses an SVG now; the sign-up icon has `mimes:jpeg,png,jpg,gif` as
well, so an SVG was refused before and is refused now, and the answer lists the message of the image rule first. No other upload uses the
`image` rule. The failed-query text now names the host, the port and the database (not the user or the password): the error mails and logs
that carry exception text show them. A mail address with a line break is refused when it is put on a message. Hashing (`rehash_on_login`
off), the proxy middleware, `bootstrap/app.php` and the skeleton's config files are kept. The skeleton's own changes were not taken.

**New truths.** `Laravel12BehaviourTest` (12 tests, eleven with the group `laravel-12-only`): the versions and constraints, the path encoding
middleware that the application does not get, the first-wins route names with the address that `route()` gives for each of the seven (and
the answers on Laravel 11 in a comment), the rows that share a name still answering their own addresses, the failed-query text, the
schema-qualified table listing, the date trait, the image rule, the page number in each link of a paginated answer, the mail address check
and the new framework defaults next to the application's filesystem entry.

**Run.** The default run is 5,880 tests with 62,600 assertions. Compared with the PHPUnit 11 step (5,876 tests): one test more because of
the split of the SVG test, nine left out by the groups `laravel-11-only` (2 that were not already out), `laravel-6-to-11-only` (5),
`laravel-8-to-11-only` (1) and `laravel-10-to-11-only` (1), and the twelve tests of `Laravel12BehaviourTest` added.

**For the owner.**
- Deploy needs PHP 8.2 or newer with the same extensions as before; run `composer install` from the new lock (it adds
  `symfony/polyfill-php84`). Production has to run Laravel 12.69 or newer (the lock has 12.70.2).
- Error mails and logs of database errors now include the database host, port and name. Check who receives `MAIL_ERROR_NOTICE_ADDRESS`.
- The links list of the paginated api answers has an extra `page` key in each link; the front end ignores keys it does not read.
- Keep `APP_DEBUG=false` in production.

## What changed at the PHP 8.4 step

PHP moved from 8.3.36 to 8.4.27 while Laravel stayed at 12.70.2 and PHPUnit at 11.5.57. The container image is Debian 13 now (the PHP 8.4
image's base), so the system file type detection is newer too. The image loads OPcache by itself and prints no warning.

**Dependencies.** `composer.json` is unchanged; `composer update` re-resolved the lock under PHP 8.4 and moved six packages: `lcobucci/clock`
3.5.0 to 3.7.0 and the five Symfony components that Laravel 12 does not hold at 7.x (clock, css-selector, event-dispatcher, string,
translation, all to 8.1). Symfony 8 needs PHP 8.4.1, so the lock can no longer be installed on PHP 8.3 or earlier. The audit finds no
advisory, before and after, and there is no ignore list.

**Deprecations.** PHP 8.4 reports two kinds of thing in the application. (1) A parameter written `Type $x = null` is reported when the file is
compiled: nine in `app/Model/Campaign.php` and `app/Model/CampaignLinkBanner.php`, fourteen in ten helper methods of nine test files. They
are `?Type $x = null` now; the eight in `Campaign` are followed by required parameters (the three file parameters of the campaign insert and
update, and the category of the two category searches), so they never could be left out and PHP 8.4 would call the default meaningless: they
have no default. Such a report is raised while a class is loaded, and a class that the suite loads before the first test starts is compiled before
the guard of `Tests\TestCase` is in place, so the guard did not see these; the gate's `php -l` over the application and the tests does (it
fails on any output). (2) `fputcsv()` without the escape argument: `createCsvFileResponse()` in `app/helpers.php` called it twice, so every
CSV download reported a deprecation and the guard failed 105 tests on the first run. The calls pass comma, double quote and backslash, the
values PHP always used, so the downloads are byte for byte what they were. The readers (`SplFileObject` with the CSV flag) raise nothing. The
tests write and read CSV text with `fputcsv()` and `str_getcsv()` without the argument at a few places (`MerchantLinkCsvTest`,
`BuildsMerchantReportWorld` and three more files); the report is raised in a test file, the guard does not count it, and the text is the
same, so those calls were left as they are. The remaining reports are libraries and not related to PHP 8.4: the factories and seeders read
Faker properties and Symfony 7.4 marks `Request::get()` as deprecated (the controllers call it at more than 40 places). A full run with every
deprecation recorded found the two `fputcsv()` calls (500 reports) as the only runtime report in the application, and none after the fix. A search for the other changes of
PHP 8.4 (`E_STRICT`, `session.sid_*`, `E_USER_ERROR`, `lcg_value()`, `unserialize()`, the new global function names `array_find`,
`array_any`, `array_all`, `array_find_key`, `mb_trim`, `mb_ucfirst`, `str_increment`, `bcdivmod` and the like, the `_` class name) found none.

**Rounding.** PHP 8.4 no longer rounds a value that lies a hair under a half up to the next whole number. The application computes
`count / count * 100` in PHP at several places (the third generation of the merchant report, the operator reports, the daily influencer report
of the report library), and the CSV downloads of the third generation show `round(rate)` with a percent sign. 23 clicks on 40 displays are 57.49999999999999, shown as 58% on PHP 8.3 and 57% on
PHP 8.4; 49 of the 90,000 ratios of two counts up to 300 differ (about one in two thousand), and all other rounding and `number_format()` of
the money and tax figures that were compared (decimals with two places, halves and quarters, amounts times 8%, 10%, 10.5% and 8.25%) are
identical. The JSON answers carry the unrounded rate. The rate that the SQL computes arrives as text with four decimals and is exact. Pinned in
`Php84BehaviourTest`; the application is not changed.

**What PHP 8.4 (and its image) changed for the tests.** The refusal of an empty path says "Path must not be empty" (earlier "cannot"):
`CsvArrayTest` accepts either wording (a neutral edit on the baseline). The file type detection of the image calls a text of several comma
separated lines `text/csv`, not `text/plain`: the request class for the bank file (`StoreBank`) accepts only `text/plain` by content, so the
accepted example of `OperatorImportRequestsTest` is one line now (the result on every version). The bank import does not use that class (its
controller method takes a plain request), so no upload is refused; `Php84BehaviourTest` pins the new answer. All `'8'` entries of `phpAware()`
are verified on PHP 8.0 to 8.4.

**Tests.** `Php84BehaviourTest` (6 tests, group `php-8.4-and-later`): the guard counts an implicit nullable parameter in a file of the
application, the nullable parameters of the models, the unchanged CSV text, the empty path wording, the text/csv detection with the unused
bank request class, and the rate under a half. The other edits to earlier tests are on the baseline: fourteen nullable parameters and the two
neutral wording edits; no test needed a new tag and `phpunit.xml` is unchanged.

**Run.** The first run on PHP 8.4 (before any change, with the lock of the Laravel 12 step) was 5,880 tests with 107 failures: 105 guard
failures about `fputcsv()`, one empty path wording and one file type. The default run is 5,886 tests: the same 5,880 and the six of
`Php84BehaviourTest`.

**For the owner.**
- Deploy needs PHP 8.4.1 or newer with the same extensions as before; run `composer install` from the new lock.
- Check the CTR and CVR columns of the report downloads: a rate that is exactly on a half (a count ratio such as 23/40 or 57/200) is shown one
  percent lower than before.
- Keep `LOG_DEPRECATIONS_CHANNEL` set to a real log channel in the first days after the deployment.
- `StoreBank` is not used by any controller; if it is wired in, it refuses multi-line CSV text on systems with the newer file type detection.

## What changed at the Laravel 13 step

Laravel moved from 12.70.2 to 13.39.0 while PHP stayed at 8.4.27 and PHPUnit at 11.5.57 (collision 8.9.5). Fourteen packages were updated with it,
one is new and one is gone: `laravel/tinker` 2.11 to 3.0, `brick/math` 0.14 to 1.0, `guzzlehttp/uri-template` 1 to 2, the Symfony components that Laravel 12 still held at 7.4
(console, error-handler, http-foundation, http-kernel, mailer, mime, process, routing, var-dumper, uid) to 8.1, and `symfony/polyfill-php86` is
new where `symfony/polyfill-php83` is gone. The `php` constraint of `composer.json` is `^8.4` now; the lock has needed 8.4.1 since the PHP 8.4
step. The audit finds no advisory, before and after, and there is no ignore list. The application keeps the structure of Laravel 10; Laravel 13
still runs it.

**First run.** With only the dependencies changed, the run stopped with a fatal error: the anonymous class that `ProviderBehaviourTest` uses as a
user who must verify the address implements a contract that gained a method in Laravel 13, and PHP does not allow that class to be declared. With
the method added, 5,886 tests ran with 17 failures: 3 campaign and banner requests (a text and a file under one field name, below), 2 tests of the
trusted proxies (below), 4 tests of the route table (the redirect routes list the new `QUERY` verb), the list of log channels that write a file
(`monthly` is a framework channel now), the authentication exception without a login route (401 instead of a server error), 2 tests of a page size
of zero (a page instead of a server error), the size rule with a size that is not a number (brick/math 1 gives no exception), and the two tests of
`Laravel12BehaviourTest` that name the versions and the composer constraints.

**Edits to earlier tests, made on the branches that own them.** On the baseline: the test double of the verifiable user has the new method; the form
request helper and the three campaign requests send a file or a text for a field name, not both (a browser sends one; with both, Laravel up to 12
returned the file from `all()` and Laravel 13 returns the text); the route table is read without the `QUERY` verb; the list of log channels that
write a file may hold the framework's own `monthly`; the three tests whose answer is different on Laravel 13 carry `laravel-6-to-12-only` (the page
size of zero became a test of its own in each of the two report test files), and the 37 baseline tests that were `laravel-6-to-12-only` and still
hold changed to `laravel-6-to-13-only` (a script changed the annotation lines). On the Laravel 9 branch the size rule test is `laravel-9-to-12-only`
instead of `laravel-9-to-11-only`; on the Laravel 12 branch the nine tests of `Laravel12BehaviourTest` that still hold are `laravel-12-to-13-only`.
The branches after each edit were rebased, and the PHPUnit 11 branch was rebuilt on the new PHP 8.3 branch with the conversion to attributes
run again (557 tags, three more than the 554 of the Laravel 12 step). No test of the Laravel 13 branch was edited except `phpunit.xml` and this file.

**Changes in the application that Laravel 13 forced.** `app/Http/Middleware/TrustProxies.php`: the application sets its trusted proxies to `*`, which
meant "believe the forwarded headers of whoever called", the web server in front of it. Laravel 13 means every address by it, so every entry of an
`X-Forwarded-For` list counted as a proxy and `Request::ip()`, which the request throttle (`throttle:60,1`) reads, became the left-most entry, the
one a visitor writes himself, where it was the entry the web server appended. The class overrides `setTrustedProxyIpAddressesToTheCallingIp()` with
the Laravel 12 code. The answers about the client address, the scheme, the host, the port, the throttle key and the generated addresses (the
redirect address sent to Rakuten) were recorded on Laravel 12 for 81 combinations of caller and headers; they are the same on Laravel 13 with the
override and 18 of them differ without it. The 32 rows of the table that the new test keeps are in `Laravel13BehaviourTest`.

**Decisions.** The date trait needed no change: Laravel 13 still asks `getDates()`. `Request::get()` is called at more than forty places in the
controllers and Symfony 8 removed it from its own class; Laravel 13 declares it on its own request class with the same order as Symfony 7.4 (a
value set by the router, then the query, then the body), so no call changed; the order is pinned. The forgery check is the renamed framework class
(the application's class extends the old name); it passes a request that the browser marks `Sec-Fetch-Site: same-origin` without a token, and still
needs the token for every other request, which the screens send anyway. The skeleton's `serialization => json` for sessions and
`serializable_classes` for the cache are not taken (sessions stay readable, and the Memcached store does not read the cache setting), and the
framework's hyphenated cache prefix is not taken (the cache keys keep their names). Masking of the bound values in failed-query texts is not turned
on. The structure of Laravel 10, `bootstrap/app.php`, the config files, hashing, `Request` and the kernels are unchanged. `route:cache` still fails on
the seven shared route names (the first route of a name wins, as on Laravel 12).

**New truths.** `Laravel13BehaviourTest` (23 tests, 17 with the group `laravel-13-only`): the versions and constraints, the lock floors (Laravel
13.30 and symfony/mime 8.1.10), the PHP constraint against the platform check, the old structure, the contract of the verifiable user, the forgery
middleware wiring and the same-origin pass, `Request::get()` (order, JSON body, no run-time deprecation, declared on Laravel's request class), a text
beating a file of the same name, what the framework's star does and what the application's star does, the table of forwarded-header answers, the
session and cache settings that are not taken, the framework's cache prefix, the failed-query text, the `QUERY` verb, the 401 without a body, the
page size of zero, the size rule, and the JSON 503 while down with `--redirect`.

**Run.** The default run is 5,934 tests with 63,368 assertions. The PHP 8.4 step ended at 5,886 tests (the two data sets of a page size of zero
became two tests of their own, which does not change the count); six tests are left out by the groups `laravel-12-only` (2),
`laravel-6-to-12-only` (3) and `laravel-9-to-12-only` (1), and the 54 runs of `Laravel13BehaviourTest` are added: 5,886 - 6 + 54 = 5,934.

**For the owner.**
- Deploy needs PHP 8.4.1 or newer; run `composer install` from the new lock. Production has to run Laravel 13.30 or newer (the lock has 13.39.0).
- The client address that the request throttle uses is unchanged by the override in `TrustProxies`. Do not remove it unless the web server in front is
  the only caller and the proxy list is set to its address.
- Error mails and logs of database errors still contain the bound values; masking is a configuration change nobody has made.
- A request that expects JSON while the application is down with `--redirect` or `--render` now gets a JSON 503 and is no longer redirected. Check
  what monitors the pages with those options.
- Keep `APP_DEBUG=false` in production.
