| @@ -0,0 +1,154 @@ | |||||
| # Agent notes for this repo | |||||
| > Kept in sync with `CLAUDE.md` (same content, duplicated on purpose so both | |||||
| > Claude Code and other agent tooling pick it up automatically — update both | |||||
| > files together). | |||||
| Operational gotchas discovered while building the warehouse app on top of the | |||||
| hand-rolled framework in `framework/`. Read this before re-deriving any of it | |||||
| from scratch — most of it was expensive to find the first time. See | |||||
| `docs/framework.md` for how the framework itself works (routing, models, | |||||
| views, etc.) — this file is about the *environment* and *bugs*, not the | |||||
| framework's intended API. | |||||
| ## No local PHP/composer — everything runs through Docker | |||||
| There is no PHP or Composer on the host. Use: | |||||
| - `docker compose run --rm app <cmd>` for one-off commands (migrations, tests, scratch scripts) | |||||
| - `docker compose exec app <cmd>` against the running stack (`docker compose up -d`) | |||||
| `.env` is required and gitignored — copy it from `.env.example` before first run. | |||||
| `composer install` needs `--ignore-platform-reqs`. The committed `composer.lock` | |||||
| predates the container's PHP 8.2 image: `phpspec/prophecy` requires PHP `<8.1`, | |||||
| and `ext-zip` (needed by `dbrekelmans/bdi`/`php-webdriver`) isn't installed. | |||||
| This is a pre-existing gap, not something to "fix" by upgrading deps unless | |||||
| asked. | |||||
| ## Ports are a Windows-specific minefield | |||||
| Windows reserves TCP ports dynamically for Hyper-V/WSL2 NAT. A reserved port | |||||
| fails to bind with `ports are not available: ... forbidden by its access | |||||
| permissions` — this looks like "something else is using it" but isn't; it's | |||||
| an OS-level exclusion, not a process conflict. Check before picking a port: | |||||
| ``` | |||||
| netsh interface ipv4 show excludedportrange protocol=tcp | |||||
| ``` | |||||
| `8081`, `9090`, and others have been seen reserved on this machine already. | |||||
| Current mapping in `docker-compose.yml`: app → host `9092` (container `80`), | |||||
| phpLiteAdmin → host `9091` (container `8081`). The **container-side** ports | |||||
| must not change (`80` = nginx, `8081` = phpliteadmin's nginx vhost) — only | |||||
| remap the host side, and check the exclusion list first. | |||||
| ## `docker/start-container.sh` must stay LF | |||||
| A Windows checkout previously gave it CRLF line endings, which breaks its | |||||
| shebang inside the Linux container (`start-container: not found` in | |||||
| `docker logs pro-php-mvc-app`, crash-looping). `.gitattributes` now forces | |||||
| `*.sh` to LF on checkout. If this error reappears, run `file | |||||
| docker/start-container.sh` to confirm, then `sed -i 's/\r$//' | |||||
| docker/start-container.sh`. | |||||
| ## Stale root-owned cache files break the real app | |||||
| `docker compose run` (one-off containers) executes as root. The real `app` | |||||
| service's php-fpm workers run as `www-data`. If you compile views or write | |||||
| files via `docker compose run` and then the running app can't overwrite them | |||||
| (`Permission denied` in `AdvancedEngine::render()`, `storage/framework/views/`), | |||||
| it's stale root-owned `.php` cache files. Fix: delete them (gitignored, | |||||
| regenerable) and let `www-data` recreate them: | |||||
| ``` | |||||
| docker exec pro-php-mvc-app find /var/www/html/storage/framework/views -maxdepth 1 -name '*.php' -delete | |||||
| ``` | |||||
| ## Testing gotchas | |||||
| `phpunit.xml` registers `ServerExtension`, which **always** tries to | |||||
| auto-start `php command.php serve` before the suite (even for non-browser | |||||
| tests) and stop it after. This container has no `pcntl` extension, so both | |||||
| the start (`ServeCommand::handleSignals()` → `pcntl_async_signals()`) and the | |||||
| stop (`SIGTERM` constant) crash with a fatal error *after* the tests | |||||
| themselves have already run — the crash happens in teardown, so a `phpunit` | |||||
| run always exits non-zero even when everything passed. | |||||
| Workaround: bind a dummy TCP listener on the target `APP_HOST:APP_PORT` | |||||
| (check `docker exec <app> php -r "echo getenv('APP_HOST').':'.getenv('APP_PORT');"` | |||||
| — currently `0.0.0.0:80`) before running phpunit, so | |||||
| `ServerExtension::serverIsRunning()` sees it as already running and skips | |||||
| starting/stopping the real (broken) one: | |||||
| ``` | |||||
| docker compose run --rm app sh -c "php -r 'stream_socket_server(\"tcp://0.0.0.0:80\"); sleep(120);' & sleep 1 && vendor/bin/phpunit; kill %1 2>/dev/null; true" | |||||
| ``` | |||||
| `BrowserTest.php` (Symfony Panther/Firefox) will still error — there's no | |||||
| `geckodriver`/Firefox in this image. That's a pre-existing gap, not a | |||||
| regression to chase. | |||||
| `php command.php serve` is itself broken in this container for the same | |||||
| `pcntl` reason. Don't use it to "prove the app works" — hit a real running | |||||
| `docker compose up` stack over HTTP instead (curl, or a browser). | |||||
| ## Framework bugs found (work around at the app level — don't patch `framework/`) | |||||
| 1. **Router doesn't disambiguate by HTTP method for dynamic paths, and | |||||
| matches unanchored.** `Framework\Routing\Route::matches()` — once past the | |||||
| literal exact-match check, the regex built for a `{param}` route is | |||||
| checked with `preg_match_all` with no `^`/`$` anchors, and without | |||||
| checking `$method` at all. Consequences: | |||||
| - A shorter dynamic route (`/items/view/{item}`) matches as a *prefix* of | |||||
| any longer path (`/items/view/{item}/receive`) — whichever route is | |||||
| registered first wins, regardless of specificity or method. | |||||
| - Two routes on the *identical* dynamic path with different methods (GET | |||||
| show-form / POST submit) collide the same way — first-registered wins | |||||
| for both methods, so the POST silently runs the GET handler. | |||||
| **Rule of thumb:** every dynamic (`{param}`) route in `app/routes.php` | |||||
| needs a fully unique literal path. Never nest one dynamic route's path | |||||
| under another's, and never pair GET+POST on the same dynamic path. Static | |||||
| (no `{}`) paths are unaffected — they only ever use the exact-match | |||||
| branch. See the stock-action routes in `app/routes.php` (and the comment | |||||
| above them) for the working pattern: | |||||
| `/items/receive-form/{item}` (GET) vs `/items/receive/{item}` (POST), etc. | |||||
| 2. **PHP's `empty("0")` is `true`.** The framework's `required` rule | |||||
| (`RequiredRule::validate`) is `!empty($data[$field])`, so a legitimate | |||||
| `"0"` value fails `required`. Don't use `required` on a field where `0` is | |||||
| valid (e.g. "set stock to zero") — validate presence manually instead. See | |||||
| `AdjustStockController::handle()` for the pattern. | |||||
| 3. **No `ORDER BY` / aggregate support** in `Framework\Database\QueryBuilder`. | |||||
| Sort/sum in PHP after `->all()`. | |||||
| 4. **No unique constraints** in the migration DSL. Uniqueness (e.g. | |||||
| `items.sku`, `locations.code`) is not enforced anywhere — enforce at the | |||||
| app level if it starts to matter. | |||||
| Two drivers: `postmark` (API-based) and `smtp` (generic | |||||
| `Swift_SmtpTransport` — no new dependency, ships with `swiftmailer/swiftmailer` | |||||
| already). Switch via `EMAIL_DRIVER` in `.env`; `config/email.php` defaults to | |||||
| `smtp`. | |||||
| Emails are **always queued** (`app('queue')->push(...)`), never sent | |||||
| synchronously. Nothing processes the queue automatically — run | |||||
| `docker compose exec app php command.php queue:work` (blocks, polls every | |||||
| second) or jobs just sit in the `jobs` table forever. | |||||
| ## Domain model | |||||
| - `locations`, `items`, `stock` (one row per item+location, found-or-created | |||||
| via `Stock::forItemAtLocation()`), `stock_movements` (audit ledger: | |||||
| receive/withdraw/adjustment). | |||||
| - Auth gating is inline per-controller | |||||
| (`if (!session()->has('user_id')) { return redirect(...); }`) — the | |||||
| framework has no middleware concept; don't try to add one. | |||||
| - Low-stock alerts fire from `WithdrawStockController`/`AdjustStockController` | |||||
| after a stock change, when `item.reorder_level > 0 && total <= | |||||
| reorder_level`. Not de-duplicated — repeated withdrawals under threshold | |||||
| requeue repeated alerts. | |||||
Powered by TurnKey Linux.