# web: influencer platform application

Japanese version / 日本語版: [README.ja.md](README.ja.md)

## 1. Overview

This is the `web` application of the affiliate platform: a Laravel application with three portals, one for influencers, one for merchants (advertisers) and one for operators. Each portal has its own login, its own pages and its own JSON API under `/api/influencer/`, `/api/merchant/` and `/api/operator/`. The pages are Blade templates; the interactive parts are Vue 2.7 components that Vite builds into one bundle per portal. The code was written by an outsourcing partner and has been upgraded to Laravel 13.39, PHP 8.4, PHPUnit 11.5, Vite 7 and Vue 2.7.16, with assets built on Node 24. The Docker environment (MySQL, php-fpm, nginx, a stand-in for the outside APIs, the asset build container and the browser smoke container) is a separate repository in the project root, the folder that contains this one. The sister application `tracking` lives there too and is not described here.

## 2. Requirements

| Component | Requirement |
| --- | --- |
| PHP | 8.4.1 or newer (`composer.json` requires `^8.4`; the locked packages need 8.4.1). |
| PHP extensions | `pdo_mysql`, `mbstring`, `fileinfo`, `ctype`, `filter`, `hash`, `session`, `tokenizer`, `openssl`, `zip`, `curl`, `xml`, `sodium`; `memcached` only if sessions or cache use it. |
| Composer | 2.x (the Docker image uses 2.11). |
| Node.js and npm | Node 24 and npm 11 (the Docker image uses Node 24.22.2 with the npm 11 that ships with it). They are needed only to build the assets, not at runtime. |
| Database | MySQL 8.0 with `utf8mb4` and `utf8mb4_unicode_ci`. |
| Web server | Document root `public/`; every request that is not a file goes to `public/index.php`; files whose name starts with a dot are refused (except `.well-known`). `docker/nginx/web.conf` in the environment repository is a working example (upload limit 20 MB, php-fpm behind it). |
| Writable paths | `storage/`, `bootstrap/cache/` and `/var/log/laravel/` (the log paths are written into `config/logging.php`). |

- The Docker image starts from the official `php:8.4-fpm` image and adds `pdo_mysql` and `zip`; the other extensions in the table are part of that official image and must also be present on any other host.
- `lcobucci/jwt` needs `sodium` for the Rakuten sign-in token.
- Sessions and cache: `config/session.php` and `config/cache.php` default to `file`, and so does `.env.example` (`SESSION_DRIVER`, `CACHE_DRIVER`; session lifetime `SESSION_LIFETIME`, 120 minutes). A Memcached store is configured in `config/cache.php` but has never been run in the Docker environment. The login lock-out counter lives in the cache store, so a store that exists once per server multiplies the limit by the number of servers.
- The application runs in the `Asia/Tokyo` time zone and the `ja` locale (both fixed in `config/app.php`).

## 3. Configuration

Copy the example file, then generate the application key:

```shell
cp .env.example .env
php artisan key:generate
```

On an existing installation keep the current `APP_KEY`. `.env` is git-ignored; never commit it, and never put keys, passwords or certificates into the repository. The values in `.env.example` are for the local Docker environment only; production values come from the team.

### 3.1 Variables

| Variable | Meaning |
| --- | --- |
| `APP_NAME`, `APP_URL`, `ASSET_URL` | Application name, public base URL of the application, optional separate base URL for assets. |
| `APP_ENV` | `production`, `staging` or `local`. Any other value stops the application at boot (see 3.2). |
| `APP_KEY` | Encryption key; set by `php artisan key:generate`. |
| `APP_DEBUG` | `true` shows debug pages. Must be `false` in production. |
| `TRACKING_APP_URL` | Base URL of the `tracking` application. |
| `LOG_CHANNEL`, `LOG_DEPRECATIONS_CHANNEL` | Log channel; channel that receives PHP deprecations (they are written nowhere when it is not set). |
| `LOG_SLACK_WEBHOOK_URL`, `LOG_STDERR_FORMATTER` | Settings of the Slack and stderr log channels. |
| `DB_CONNECTION` | Database driver, `mysql`. |
| `DB_MASTER_HOST`, `DB_MASTER_PORT`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` | The write connection. |
| `DB_SLAVE_HOST`, `DB_SLAVE_PORT`, `DB_SLAVE_DATABASE`, `DB_SLAVE_USERNAME`, `DB_SLAVE_PASSWORD` | The read connection (`mysql_read`). It may point to the same server and database as the write connection. |
| `DB_FOREIGN_KEYS`, `MYSQL_ATTR_SSL_CA` | Foreign key checks (read only by the `sqlite` connection, which this application does not use); path of the CA file for TLS to MySQL. |
| `CACHE_DRIVER`, `SESSION_DRIVER`, `SESSION_LIFETIME`, `BROADCAST_DRIVER` | Cache store, session store, session lifetime in minutes, broadcast driver. |
| `QUEUE_CONNECTION` | Queue connection; `sync` runs jobs and queued mail inside the request (see section 5, step 14). |
| `MAIL_MAILER`, `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_ENCRYPTION` | Mail transport. `MAIL_DRIVER` is still read as a fallback of `MAIL_MAILER`; use `MAIL_MAILER`. |
| `MAIL_FROM_ADDRESS`, `MAIL_FROM_NAME`, `MAIL_ERROR_NOTICE_ADDRESS` | Sender; recipient of error notices. |
| `MAIL_DEBUG` | `true` stops a bulk operator message after 10 mails (for staging and local runs). |
| `FILESYSTEM_DRIVER`, `FILESTORE_DRIVER`, `FILESTORE_ROOT`, `FILESTORE_URL` | Default disk and the `filestore` disk used for uploads. |
| `PASSWORD_VALID_DAYS`, `BCRYPT_ROUNDS` | Days a password stays valid (`config/password_policy.php`); bcrypt cost. Set `BCRYPT_ROUNDS` explicitly. |
| `OBIC_API_URL`, `OBIC_API_KEY` | OBIC payment API address and key. The key is read only from the environment. |
| `RAKUTEN_CLIENT_ID`, `RAKUTEN_CLIENT_SECRET`, `RAKUTEN_AUTHORIZATION_ENDPOINT`, `RAKUTEN_TOKEN_ENDPOINT` | Rakuten sign-in. The same four names with the suffix `_OMNI` exist for the second Rakuten client. |
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | Google sign-in. |
| `MIX_APP_URL` | Read by the asset build (not at runtime): the public base URL written into the JavaScript bundles. Details in section 4. |

### 3.2 Values that must be set in production

- `APP_ENV=production`. The application accepts only `production`, `staging` and `local` (`app/Providers/AppServiceProvider.php`); any other value throws `unknown app.env` at boot. With `production` or `staging` every generated URL uses `https`. If `APP_ENV` is unset the value is `production`.
- `APP_DEBUG=false`.
- `APP_URL`, `DB_*` (both the master and the slave names), `MAIL_*` (use `MAIL_MAILER`, not `MAIL_DRIVER`; also `MAIL_FROM_ADDRESS` and `MAIL_ERROR_NOTICE_ADDRESS`) and `LOG_CHANNEL`.
- `OBIC_API_KEY`: must be set in the `.env` of the production server. The key that was once committed in the code has been removed from the code; rotate it. `OBIC_API_URL` defaults to the production OBIC host when it is not set, so set it explicitly on every other environment (staging, local) to a stand-in.
- `LOG_DEPRECATIONS_CHANNEL`: set a real channel for the first days after the deploy. Since Laravel 8 PHP deprecations are not written anywhere by default, and paths that no test reaches can only be checked there.
- `MIX_APP_URL`: set in the environment of the asset build (section 4), not in the running application.
- Review `QUEUE_CONNECTION`, `SESSION_DRIVER`, `CACHE_DRIVER`, `FILESYSTEM_DRIVER` and the `RAKUTEN_*` and `GOOGLE_*` values against the production system. In quoted values, do not rely on `${NAME}` inside single quotes: it is no longer expanded.

### 3.3 Test environment

The PHP tests do not read `.env`. `phpunit.xml` sets every value they need (database `web_test`, `APP_ENV=local`, array cache and session, the stand-in service for outside APIs, dummy keys). Do not run the tests while `bootstrap/cache/config.php` exists: the cached configuration would be used instead of `phpunit.xml`.

## 4. Install and build (development)

```shell
composer install
npm ci
MIX_APP_URL=http://localhost:8081 npm run development
```

The scripts in `package.json`:

| Script | What it does |
| --- | --- |
| `npm run development` (also `npm run dev`) | `vite build --mode development`: one build, not minified, with Vue warnings. |
| `npm run production` (also `npm run prod`) | `vite build`: one minified build without Vue warnings. Use it for releases. |
| `npm run watch` | `vite build --watch --mode development`: rebuilds when a source file changes. |
| `npm run watch-poll` | The same with polling every 1000 ms, for file systems that send no change events (a bind mount). |
| `npm run hot` | `vite`: the Vite development server. The content security policy of the application refuses it, so it needs a relaxed policy that is not part of this delivery; prefer `npm run watch`. |

- The build writes to `public/build/` and a manifest `public/build/manifest.json` (hashed file names under `public/build/assets/`, a source map for every script). `public/build` is generated: it is git-ignored and never committed. The hand-written files in `public/js` and `public/css` are tracked.
- Without a build the portal pages fail with a server error, because the Blade templates read the manifest. `public/hot` must not exist outside a development server run: a leftover makes the pages ask a development server for their scripts.
- `MIX_APP_URL` is required. The name comes from the earlier build tool and was kept. The build stops with the message `MIX_APP_URL is not set` when it is missing. Set it in the environment of the build (as in the example above) or as a `MIX_APP_URL=` line in `.env`. Its value is the public base URL of the application without a trailing slash, for example `https://your-host.example`; it is read at five places in the sources and replaced by its value in the bundles, so every environment needs its own build.
- `npm ci` installs exactly what `package-lock.json` names. The environment repository runs `npm ci --ignore-scripts`.
- The environment repository builds with `bash docker/web-assets.sh production` (or `development`) in the pinned Node 24 container, which sets `MIX_APP_URL=http://localhost:8081`.

## 5. Deploy / release checklist (production)

1. Check out the delivered tag or branch on the build host and on the server. The server needs PHP 8.4.1 or newer (section 2).
2. Install the PHP packages without development packages: `composer install --no-dev --optimize-autoloader`. Never install the development packages on production: `laravel/dusk` registers unauthenticated `_dusk` login routes outside `production`, and Ignition registers `_ignition` routes.
3. Build the assets (this can run in CI, on a host with Node 24): `npm ci`, then `MIX_APP_URL=https://your-host.example npm run production`. Use the real public base URL of the target environment.
4. Upload `public/build/` to the server. Make sure `public/hot` does not exist there. Nothing else from the old build (`mix-manifest.json`, `public/js/influencerApp.js` and the like) is needed.
5. Check the `.env` of the server against section 3.2.
6. Check the migrations before you run them: `php artisan migrate:status`. The delivery contains two migration files that declare the same class name, `AddReplyIdToMessageContentsTable` (`2026_01_23_110844_...` and `2026_03_18_165600_add_reply_id_to_message_contents_table.php`). PHP cannot declare a class twice, so a plain `php artisan migrate` stops with a fatal error as soon as both files are pending, for example on an empty database. The frozen schema of the Docker environment contains the January definition. Which of the two the production database has is not known; find out from the `migrations` table before you migrate, and decide which copy stays.
7. Run the migrations: `php artisan migrate --force`. The database schema is built from the migrations; the tests use a frozen copy of it (`docker/schema.sql` in the environment repository), so a new migration also needs a new frozen schema there (section 6.3).
8. Delete the stale package discovery files: remove the `*.php` files in `bootstrap/cache/` (not `.gitignore`), for example `rm -f bootstrap/cache/*.php`.
9. Clear the compiled views: `php artisan view:clear`.
10. Caching: `php artisan config:cache` is allowed (the gate runs it on every run, and no code outside `config/` calls `env()`). Never run `php artisan route:cache`: it fails, because seven route names are shared by several routes (for example `influencer.login.rakuten.callback` and `operator.operator.create`) and Laravel refuses duplicate names when it caches routes. If you ran it by mistake, run `php artisan route:clear`.
11. Restart php-fpm, or reset OPcache, so that the new code is loaded.
12. Make sure `storage/` and `bootstrap/cache/` are writable by the php-fpm user and by the user that runs the cron job, and that `/var/log/laravel/` exists and is writable by both.
13. Scheduler: `app/Console/Kernel.php` schedules the batch commands (campaign mails, reward and click reward calculation, campaign end, billing and payment data, campaign extension, mails for merchants and influencers, bulk reward registration every 15 minutes, expiry of message media). Add the standard cron line for the user that owns the application, with the path of this folder:

```shell
* * * * * cd /path/to/web && php artisan schedule:run >> /dev/null 2>&1
```

14. Queue: the default `QUEUE_CONNECTION` is `sync`, which needs no worker. The application has one queued job (`CreateMerchantReport`) and many mail and notification classes that implement `ShouldQueue`. If the production `.env` sets `QUEUE_CONNECTION` to `database` or `redis`, a worker (`php artisan queue:work`) must run under a process supervisor, and the queue must be drained before a deploy that changes job or mail classes. Check the production value before you decide. The scheduler does not start a worker (the line for it in the Kernel is commented out).
15. First days: watch the channel named by `LOG_DEPRECATIONS_CHANNEL`, the logs in `/var/log/laravel/`, and the mail sent to `MAIL_ERROR_NOTICE_ADDRESS`. Error mail and log lines contain the database host, port, schema name and bound values; check who receives them. Send one real mail through the production mail relay from a staging host before the deploy.
16. Smoke check after the deploy:
    - `/influencer/login`, `/merchant/login` and `/operator/login` answer with status 200 and show a login form.
    - In the page source of the influencer and operator login pages the bundle is requested from `/build/assets/...` and the request is answered with 200; `/build/manifest.json` is served.
    - After a login, the top page of each portal opens and the Vue root is mounted (no `{{ }}` text and no `influencer-`, `merchant-` or `operator-` tags left on the page).
    - The old paths `/mix-manifest.json` and `/js/influencerApp.js` answer 404, and a dot file such as `/.env` is refused.
    - Do not request the `/test/*` routes (see section 9).

## 6. Running the tests

The tests run in the Docker environment (the root repository, in the folder above this one). Start only the web containers there, as described in section 3 of [WEB-UPGRADE-REPORT.md](../WEB-UPGRADE-REPORT.md), for example `docker compose --profile web up -d --build --no-deps web web-nginx` (do not start the whole compose project without limiting it, the tracking containers share it; `--no-deps` needs `mysql` and `obic-stub` to be running already, and on a first start use `docker compose --profile web up -d --build` as in the README in the project root). The commands below are run inside the `web` container from this folder, for example `docker compose --profile web exec -T web vendor/bin/phpunit`.

### 6.1 PHP test suite

- It needs a real MySQL database named `web_test`. `phpunit.xml` pins the connection (host `mysql`, database `web_test`, the user and password of the Docker MySQL service, both the master and the slave names). The database is created from the frozen schema `docker/schema.sql` of the environment repository by `bash docker/web-db-reset.sh`, which also recreates `web_app` for manual use. A database server that differs from the Docker one needs its own environment variables (`DB_HOST`, `DB_MASTER_HOST`, `DB_DATABASE` and the others): real environment variables win over `phpunit.xml`.
- Use only a database whose name starts with `web_` and that holds no real data. Every test runs inside a transaction that is rolled back at its end, but the seeders and some helpers write fixed rows, so never point the suite at a database that matters. `ExternalHttpGuardTest` refuses any other database name.
- Run one PHPUnit process at a time where you can.

```shell
vendor/bin/phpunit
vendor/bin/phpunit tests/Feature/HarnessTest.php
vendor/bin/phpunit --filter testTheApplicationRunsAsLocal
vendor/bin/phpunit --group laravel-13-only
vendor/bin/phpunit --exclude-group laravel-13-only
vendor/bin/phpunit --order-by=random
vendor/bin/phpunit --order-by=random --random-order-seed=12345
```

- "Green and pristine" is the standard: no failure, error, warning, deprecation, risky, skipped or incomplete test. `phpunit.xml` fails the run on notices and warnings. The base class `Tests\TestCase` also fails a test during which PHP reported a deprecation for a file of the application, or "Passing null to parameter" from anywhere, because Laravel hides PHP deprecations while tests run.
- The default run excludes the groups of versions the application has left (for example `laravel-12-only`) and the group `built-assets`. The excluded groups are listed in `phpunit.xml`.
- The tests that need a real asset build carry the group `built-assets`. After `npm run production` (section 4) run them with `vendor/bin/phpunit --group built-assets --exclude-group mix-only`.
- After adding tests, run the whole suite once with `--order-by=random`; every test must pass alone, as one file and in any order.

### 6.2 Node helper tests

The tests of the front-end helper scripts in `resources/js` use Node's own `assert` and a small runner, no extra package:

```shell
node tests/js/run.js
node tests/js/run.js insert
```

- The optional argument runs only the tests whose file name or test name contains it. The runner exits with status 1 when a test fails or when it finds no test.
- A test that is true for one tool version carries a group, `test(name, fn, { group: 'node-22-only' })`. `tests/js/excluded-groups.txt` lists the groups the runner leaves out (one per line, `#` starts a comment); the last line of the output counts them.
- Some tests read the production build in `public/build`, so build first (section 4). They were written against a build made with `MIX_APP_URL=http://localhost:8081`, the value the Docker build container uses; use the same value when you run them.
- The pinned file hashes in `tests/js/node-24.test.js` assume LF line endings in the source files, because the build copies source text into its output. The repository's `.gitattributes` sets `eol=lf`, so a normal checkout is right. Files converted to CRLF (by an editor, or by a Windows `core.autocrlf` setting) make that one test fail although nothing is wrong with the build.
- In the Docker environment: `docker compose --profile web-assets run --rm -T web-node node tests/js/run.js`.

### 6.3 Browser smoke and the one-command gate

Both live in the environment repository and are run from its root, not from this folder.

- Browser smoke: `bash docker/web-browser-smoke.sh` builds the production assets, loads the pages of the three portals in headless Chromium (Playwright, in a container on an internal Docker network with no way out) and removes the build again; `bash docker/web-browser-smoke.sh --no-build` uses the build that exists, and a name part such as `operator` runs only the checks that contain it. Build the browser image once first: `docker compose --profile web-browser build web-browser`. The checks are in `tests/e2e`; what depends on a library version is in `tests/e2e/expectations.js`.
- Gate: `bash docker/web-gate.sh` runs everything in one command: composer validate and audit, `php -l`, route and command counts, config, route and view caching (`route:cache` is expected to fail), the whole PHP suite alone and in random order, the letter case check, the guard tests, the production asset build, the built-assets tests, the Node tests, the migration check, the live smoke against nginx, the browser smoke, and proof that the run left nothing behind. `--quick` leaves out the asset build, the built-assets and Node tests, the random-order run and the browser smoke; `--ledger` only prints the ledger. The gate refuses to start when `web/` has uncommitted changes, when something else uses `web_test`, or when `bootstrap/cache` holds cached files.
- Single checks: `bash docker/migrate-check.sh` (runs the migrations on an empty scratch database and compares the result with `docker/schema.sql`), `bash docker/web-smoke.sh` (HTTP checks against nginx on port 8081), `bash docker/web-case-check.sh` (file name letter case on a case-sensitive copy; the Linux server is case-sensitive, the development disk is not).

### 6.4 The ledger and tests/README.md

- The ledger is a count of how many routes and application classes are named by a `@route` or `@subject` tag in the doc comment of some test. It is a static signal, not line coverage.
- `bash docker/web-gate.sh --ledger` prints it with the routes and classes that have no tag. `LedgerAnnotationsTest` fails on a tag that names nothing.
- [tests/README.md](tests/README.md) is the detailed reference: helpers, factories, the rules for writing tests, the version groups and what changed at each upgrade.

### 6.5 Duration and counts at hand-over

| Item | Value |
| --- | --- |
| PHP suite | 5,937 tests, 63,402 assertions; about 2 to 3 minutes |
| Node helper tests | 83 passing (44 more are left out by group) |
| Browser smoke | 15 checks |
| Gate | 28 checks (22 or 23 with `--quick`); about 450 seconds |

## 7. Writing and changing code safely

- Tag every new test with its purpose. Doc-comment tags `@route GET /exact/uri/{param}` and `@subject Fully\Qualified\Class` feed the ledger. Everything PHPUnit reads is an attribute: `#[Group('...')]`, `#[DataProvider('...')]`, `#[Test]`. PHPUnit 11 reports a doc-comment tag that it reads as a deprecation, which makes the run not pristine.
- A test that is true only for one version carries its group when it is written: `laravel-N-only`, ranges such as `laravel-6-to-13-only`, `php-8.4-and-later`, `phpunit-11-only`, and for Node `node-N-only`. On the PHP side that is an attribute, for example `#[Group('laravel-13-only')]`. The change that makes the group false lists it in `phpunit.xml` (or `tests/js/excluded-groups.txt`); the old test is not edited to say the new version.
- No test may call an external host. The OBIC API and the Rakuten token endpoint are replaced by the stand-in service `obic-stub` in the Docker environment, and `ExternalHttpGuardTest` fails when an outbound target in `phpunit.xml` is not the stand-in or the loopback address, or when a real host name appears in a test file.
- Never put a real key, password or certificate in the repository, and never print or assert the text of the OBIC key.
- Name a test after what it asserts; a test that pins a quirk is named after the quirk. Set explicitly every value a test asserts (status, dates, amounts, owner). Create rows with the model factories and the seeders.
- The Dusk files (`tests/Browser`, `tests/DuskTestCase.php`) are untouched and not part of either suite. `laravel/dusk` is a development dependency: never install the development packages on production (section 5).
- Do not use `route:cache` (section 5). When you add a route, give it a name of its own.

## 8. Receiving changes back / working with the outsourced code

1. Create a branch from the delivered tag or branch of this version, for example `git switch -c delivery/<name> <delivered-tag>`.
2. Apply the changes of the outsourcing partner on that branch, as commits, a patch series or a merge.
3. Run `composer install`, then the PHP suite (section 6.1), the Node tests (section 6.2) and last the gate (section 6.3).
4. Read every failing test as a change of behaviour. The tests describe what the application does today, including its known quirks, so a failing old test means that behaviour changed, on purpose or by accident. Review the change; do not silently edit the test. If the change is wanted, change the test in a commit of its own that says why (an expectation that a framework upgrade forces carries a comment starting `UPGRADE STEP`), and add a new test for the new behaviour.
5. Check what the delivery changed in the test folder: `git diff --diff-filter=M --name-only <delivered-tag> HEAD -- tests phpunit.xml` lists the old test files that were modified; there should be few and each needs a reason.
6. Check the other files that the deploy depends on: `composer.json` and `composer.lock` (new packages, new `require-dev` entries), `package.json` and `package-lock.json`, new migrations (which also need a new frozen schema), new scheduled commands in `app/Console/Kernel.php`, new `env()` calls (they belong in `config/` files only), new route names (no duplicates).

## 9. Known behaviour and open decisions

- Behaviour that looks like a defect is pinned on purpose, so that the upgrade did not change it: for example the second migration with the same class name, the OBIC address defaulting to the production host, the validator traits and a provider that are not at their PSR-4 paths, and routes that point at methods that do not exist. These are listed in `MigrationFilesTest`, `ExternalHttpGuardTest` and `NamespaceCaseTest` and in section 9 of [WEB-UPGRADE-REPORT.md](../WEB-UPGRADE-REPORT.md).
- Security: 44 findings were recorded at the starting point and are pinned by tests, not fixed. Three unauthenticated `/test/*` routes (`/test/achievement/calculate/{dateStr}`, `/test/mailForInfluencer`, `/test/mailForMerchant`) run reward calculation and bulk mail without a login; restrict them at the web server until they are removed. The Rakuten `id_token` is not validated. See [WEB-SECURITY-FINDINGS.md](../WEB-SECURITY-FINDINGS.md).
- Open decisions for the team: the production PHP version and database, the session and cache store, the production mail relay, publishing the source maps, renaming the shared route names, Vue 2 being at its end of life, and the old libraries listed in the report. Details: sections 5 to 7 of [WEB-UPGRADE-REPORT.md](../WEB-UPGRADE-REPORT.md) and [docs/upgrade/web-upgrade-summary.md](../docs/upgrade/web-upgrade-summary.md).
- These documents are shipped with the package: `WEB-UPGRADE-REPORT.md` and `WEB-SECURITY-FINDINGS.md` in the project root, `web-upgrade-summary.md` in `docs/upgrade/`; each has a `.ja.md` Japanese twin.

## 10. Project layout

| Folder | Contents |
| --- | --- |
| `app/` | Application code: `Http/Controllers` (pages and the three JSON APIs), `Http/Middleware`, `Http/Requests`, `Mail`, `Notifications`, `Jobs`, `Providers`. |
| `app/Model/` | The Eloquent models (62 classes). |
| `app/Console/` | `Kernel.php` (the schedule) and `Commands/` (the `batch:*` commands and a few code generators). |
| `routes/` | `web.php` holds all routes; `api.php` is empty. |
| `resources/js/` | Vue 2.7 sources: the three entry files `influencerApp.js`, `merchantApp.js`, `operatorApp.js`, `components/`, `apiClient/`. |
| `resources/views/` | Blade templates of the three portals. |
| `database/` | `migrations/`, `factories/`, `seeds/`. |
| `config/` | Configuration; the only place that calls `env()`. |
| `tests/Feature`, `tests/Unit` | PHP tests, with helpers in `tests/Concerns` and the base class `tests/TestCase.php`. |
| `tests/js/` | Node helper tests, the runner `run.js` and `excluded-groups.txt`. |
| `tests/e2e/` | Browser smoke checks (run from the environment repository). |
| `public/` | Web root. `public/build/` is the generated asset build (git-ignored); `public/js` and `public/css` are hand-written and tracked. |
| `storage/`, `bootstrap/cache/` | Runtime files; must be writable. |
