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.
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.
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 LFA 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.
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
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/)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:
/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.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.
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.
No ORDER BY / aggregate support in Framework\Database\QueryBuilder.
Sort/sum in PHP after ->all().
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.
locations, items, stock (one row per item+location, found-or-created
via Stock::forItemAtLocation()), stock_movements (audit ledger:
receive/withdraw/adjustment).if (!session()->has('user_id')) { return redirect(...); }) — the
framework has no middleware concept; don't try to add one.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.