Laravel project agent — notes toward a generic toolkit
Purpose. A running notebook for a future generic "Laravel project agent" — a
reusable planning/build toolkit (in the Odin-Codin' family of execution partners) for
spinning up Laravel apps like Kingdom.md. We are not doing the generic walkthrough
yet; this file just harvests the reusable, domain-neutral bits as they surface while
planning a concrete project (first instance: Kingdom.md, /var/projects/kingdom.md).
The discipline: when a decision we make for Kingdom.md is really a Laravel-shaped decision that would recur on the next Laravel app, note it here — separated from what's Kingdom-specific. Later, this becomes the seed for the generic agent's conventions / template / planning checklist.
Status: started 2026-06-11 during
/planningfor Kingdom.md. Accretive — add to it, don't polish it. Generic extraction is a later, deliberate pass.
How to read this file
- Reusable pattern — goes in a thematic section below, phrased generically.
- Kingdom-specific — stays in the Kingdom.md plan/design, not here; mentioned here only as the example that surfaced a generic pattern.
- Each note dated so the provenance (which project/session surfaced it) stays visible.
Stack & scaffolding decisions (generic)
(Reusable choices about how to stand up a Laravel app. Filled as we plan Kingdom.md.)
Project structure & conventions (generic)
(Service-class layout, config-driven design, naming — the reusable skeleton.)
Rendering / Markdown (generic)
(league/commonmark setup, Tailwind Typography "beautiful for free" baseline — reusable for any markdown-rendering Laravel app.)
league/commonmark hard-requires ext-mbstring — it's a render-time red (2026-06-21)
The gotcha. league/commonmark calls mb_strcut() deep in its parser
(Util/RegexHelper.php → every block-start scan), so rendering any markdown document
500s if the mbstring extension isn't loaded in the PHP that serves web requests
(php-fpm):
Call to undefined function mb_strcut()
vendor/league/commonmark/src/Util/RegexHelper.php:122
← app/Services/MarkdownRenderer.php (->convert) ← KingdomController->showFile()
Fix: apt install php8.4-mbstring then reload php-fpm (systemctl reload php8.4-fpm)
so the SAPI picks up the new .ini.
The sharp lesson — mbstring is NOT safely "deferrable" for a markdown app. It sat on
the deferred extension list (see the php-fpm install note below) on the theory of "add it
when the app needs it" — but the app needs it the instant it renders, and nothing earlier
catches the gap: the rest of the app (routing, the kingdom tree, static assets) works, so
it boots green and only the content pages 500. This is the same family as the
"green-tests-don't-prove-it-serves" lesson (2026-06-17), one level deeper: even serving the
homepage doesn't prove a content page renders. For any commonmark-backed app, treat
mbstring as a boot requirement, not a deferral — install it in the same breath as
xml/zip/sqlite3.
Why composer install didn't red on it (the wrinkle): commonmark declares
ext-mbstring in its composer.json, so a platform check should have caught it at install
— it slipped through (likely a CLI-vs-FPM SAPI split, or a platform-req that wasn't enforced
on this run). Don't trust "composer installed clean" to mean every required extension is
loaded in the FPM SAPI; a mb_strcut()-style undefined-function fatal at runtime is the tell.
Security patterns (generic)
(Path-safety / traversal guards, and other security-critical patterns that recur.)
Testing (generic)
(Pest patterns, fixture strategy, the "tests double as plan Confirm-by anchors" idea.)
Auth & RBAC roadmap shape (generic)
(The Fortify-headless → Socialite → OIDC retrofit path, and why clean-install beats the fresh-only starter kits — a reusable decision record for any Laravel app that wants add-later auth.)
Serving environment — Ubuntu / nginx / php-fpm (generic)
(How Brad likes to stand a Laravel app up behind nginx on an Ubuntu host. Being taught
session-by-session; accretive. First host: the DO droplet at /var/projects, Ubuntu 24.04.)
nginx — install from Ubuntu apt, not nginx.org/source (2026-06-19)
Decision: install nginx via Ubuntu's default apt repo (apt-get install nginx), not
from the official nginx.org apt repo or a source build.
Why (Brad's reasoning, research-confirmed): the Debian/Ubuntu package ships the
sites-available/ + sites-enabled/ layout and a fuller set of sensible defaults
(snippets/, modules-available/enabled/, conf.d/, the default vhost). That
sites-* split is a Debian/Ubuntu convention inherited from the apache2 package — it
is not an nginx feature. Upstream nginx (source builds and the official nginx.org
apt repo) ships conf.d/*.conf only — the "stripped down, nothing's set up" state Brad
hits on manual installs. The payoff of the Debian layout is the enable/disable-by-symlink
workflow: author a vhost in sites-available/, ln -s it into sites-enabled/ to turn
it on, rm the symlink to turn it off (the file survives). Debian's nginx.conf includes
both sites-enabled/* and conf.d/*.conf, so nothing is lost by using apt.
Tradeoff (accepted): apt's nginx lags upstream mainline (Ubuntu 24.04 → 1.24.0), but
gets security backports. conf.d/ is the cross-distro-portable alternative the "best
practice" crowd pushes — but that's a works-anywhere argument, not works-better; the
symlink workflow wins for Brad and apt gives both trees anyway.
What apt install nginx lays down (/etc/nginx/): sites-available/ (with default),
sites-enabled/ (default → symlink to it), conf.d/, snippets/, modules-available/,
modules-enabled/, plus nginx.conf, fastcgi.conf/fastcgi_params, mime.types. Service
is auto-enabled (boots on start) and serves the default welcome vhost on :80 (HTTP 200).
Standing gotcha: the bundled default site occupies :80. Before enabling a real app
vhost, disable it — rm /etc/nginx/sites-enabled/default (keep the file in sites-available/),
then nginx -t && systemctl reload nginx.
Laravel server-block best practices to apply when we write the vhost (research-confirmed,
unchanged for 2026): root → the app's public/ dir (keeps .env/vendor/storage
out of the web root); try_files $uri $uri/ /index.php?$query_string; hand .php to php-fpm
over a Unix socket (or TCP bound to 127.0.0.1 only — never expose :9000); deny PHP
execution under upload paths (rule placed before the general PHP location).
Smoke-test static vhost first — the "It works!" step (2026-06-19)
A step Brad likes to do when he can: before wiring up any PHP/Laravel, stand up a
throwaway static vhost that serves a trivial index.html ("It works!"). It proves the
whole serving path end-to-end — nginx running, server-block syntax, the symlink-enable
workflow, and (when DNS is pointed) name resolution — before app complexity can muddy a
failure. "Prove the pipe before you fill it." Example FQDN: claude.hugin.gg.
Recipe (generic — swap <fqdn>):
- Docroot + page:
mkdir -p /var/www/<fqdn>and a minimalindex.htmlwith<h1>It works!</h1>. - Server block at
/etc/nginx/sites-available/<fqdn>:server { listen 80; listen [::]:80; server_name <fqdn>; root /var/www/<fqdn>; index index.html; location / { try_files $uri $uri/ =404; } } - Enable by symlink (the workflow Brad likes):
ln -s /etc/nginx/sites-available/<fqdn> /etc/nginx/sites-enabled/. - Validate + reload:
nginx -t && systemctl reload nginx. - Verify locally (no DNS needed):
curl -H "Host: <fqdn>" http://127.0.0.1/orcurl --resolve <fqdn>:80:127.0.0.1 http://<fqdn>/→ expect "It works!".
Default-host handling (Brad's preference — see house conventions below): nginx routes by
Host header, so an exact server_name match always wins. But rather than let an unmatched
host fall through to whichever real vhost loads first, Brad 404s it — disable the bundled
default (unsymlink) and give the default_server slot to an explicit catch-all that
return 404;.
Two gotchas seen live:
- Reload race. A request fired in the same breath as
systemctl reload nginxcan still hit an old worker on the previous config (graceful reload overlaps workers briefly) — it briefly served thedefaultpage, then routed correctly on retry. If scripting a reload-then-check, add a tiny retry/sleep rather than trusting the first hit. - DNS is separate. Local
Host-header /--resolvetests prove nginx; browser access from outside still needs an A/AAAA record for<fqdn>pointing at the host. Don't read a green local curl as "it's live on the internet."
Brad's nginx house conventions (2026-06-19)
Standing preferences confirmed while building the claude.hugin.gg smoke-test host:
- Always
nginx -tbeforesystemctl reload. A deliberate validation gate Brad likes — never reload an unparsed config. Run it as one breath:nginx -t && systemctl reload nginx. - Keep the bundled
defaultsite, but unsymlinked. Leave/etc/nginx/sites-available/defaultin place as a reference, but remove itssites-enabledsymlink so it never serves. - 404 unmatched hosts via an explicit catch-all
default_server. Don't let a stray / raw-IP / bot request fall through to whichever real vhost loads first — give it a clean 404. A dedicatedsites-available/catch-all, symlinked in, owns the slot:
(Verified live: correct host → 200, wrong host → 404, raw IP → 404.) Ordering gotcha: with the bundledserver { listen 80 default_server; listen [::]:80 default_server; server_name _; return 404; }defaultalso enabled you hitduplicate default serveronnginx -t— unsymlinkdefaultbefore enabling the catch-all. - Run web services as
www-data:www-data, and match it on/var/www/*. nginx workers already run aswww-data(theuser www-data;directive innginx.conf); php-fpm pools and latersupervisorctl-managed workers get the same uid/gid; and each host's/var/www/*trees arechown -R www-data:www-data. One consistent service identity owns the web root — no permission friction between the server, the PHP runtime, and the files. (Apply php-fpm'suser/group+listen.owner/listen.grouptowww-datawhen it's installed.)
php-fpm — Ondřej's PPA, and how Brad picks the version (2026-06-19)
Brad runs PHP from Ondřej Surý's PPA (ppa:ondrej/php), not Ubuntu's stock archive — it
carries current, co-installable PHP versions (5.6 / 7.x / 8.x) + the commonly-needed extensions.
The flow (a step Brad likes):
sudo add-apt-repository ppa:ondrej/php # (-y to skip the interactive ENTER prompt)
sudo apt update
apt list 2>/dev/null | grep php8.4-fpm
Version-selection rule: read the candidate's version string and confirm it's coming from
the Ondřej repo before committing to it — the tell is the +deb.sury.org marker:
php8.4-fpm/noble 8.4.22-1+ubuntu24.04.1+deb.sury.org+1 amd64
^^^^^^^^^^^^^^ <- from Ondřej, good to go
As long as that marker is present, that's the version Brad goes with. (2026-06-19 pick: php8.4-fpm 8.4.22 on Ubuntu 24.04 "noble".)
Installed 2026-06-19: php8.4-fpm + php8.4-cli only (8.4.22; fpm service auto-active/
enabled). The Laravel extension set (mbstring/xml/curl/zip/bcmath/intl/gd) +
a DB driver (sqlite3 for Kingdom.md) are deferred — add when the app actually needs them.
⚠️ Caveat (learned 2026-06-21): mbstring is NOT safely deferrable for a markdown-rendering
app — league/commonmark 500s every content page without it (see the Rendering section).
Add php8.4-mbstring up front alongside xml/zip/sqlite3; only the truly-unused ones
(bcmath/intl/gd/curl) are genuine deferrals.
Still to do: set the pool user/group and listen.owner/listen.group to www-data
per the house convention above, then point the nginx vhost's PHP location at php-fpm's socket.
Composer — official getcomposer.org installer, global (2026-06-19)
Brad installs Composer with the official 4-step installer from https://getcomposer.org/download/,
moving the result to /usr/local/bin/composer for a system-wide composer command:
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php -r "if (hash_file('sha384', 'composer-setup.php') === '<HASH>') { echo 'Installer verified'... } else { ... unlink(...); exit(1); }"
php composer-setup.php
php -r "unlink('composer-setup.php');"
mv composer.phar /usr/local/bin/composer
Always pull the commands and the SHA-384 fresh from that download page — getcomposer.org rotates the installer (and its hash) regularly, so a hash pinned in notes will eventually fail the verify step as "Installer corrupt." (This run's fresh hash verified clean → Composer 2.10.1.)
Red-green lesson (devops the way Brad likes — run it, let it fail, fix forward): the very
first attempt failed php: command not found (exit 127). Root cause: Composer's installer is
itself a PHP script, so php8.4-cli must be installed before it — a bare php8.4-fpm install
does not provide a CLI php. Order is: PHP CLI → Composer. (Running Composer as root
non-interactively prints a benign "plugins disabled for safety" notice — not an error.)
App location by environment + native (no-Docker) serving (2026-06-19)
App docroot location is environment-dependent (Brad's convention — record which env you're on):
- Production / normal hosts: apps live under
/var/www/<app>— the conventional web root. - This dev box (the DO droplet): apps live under
/var/projects/<app>instead, alongside the Claude config repos. So here the Kingdom.md docroot is/var/projects/kingdom.md/public, not/var/www/....
Native serving — Docker / Laravel Sail is fully retired (2026-06-19). Kingdom.md (and this whole
environment) no longer uses Docker or Sail at all. The app is served natively: nginx → php-fpm
(Unix socket) → Laravel front controller. This is the payoff of the Windows→Linux-droplet migration —
the chown / OPcache / WSL2-bind-mount workarounds Sail-on-Windows needed simply dissolve on native
Linux. (Cleanup owed later: kingdom.md's own CLAUDE.md + working/ docs still describe the Sail
dev loop and need a de-Docker pass.)
Ownership is NOT fully moot even when serving the repo in place (the #3 nuance). Wherever the code
lives, php-fpm runs as www-data, and Laravel must be able to write storage/ and
bootstrap/cache/ — so those must be www-data-owned/writable or the app 500s on the first request.
A repo that's root:root serves static files fine (world-readable) but can't run Laravel until that
write access is granted. Trade-off on a dev box where you git-operate as root: chowning the whole
tree to www-data (matches the "everything www-data" convention) makes git-as-root emit a dubious
ownership warning (fixable via git config --global --add safe.directory <path>); the alternative is
chowning only storage/ + bootstrap/cache/ (standard Laravel deploy) and leaving the rest
root-owned.
Running composer/artisan as root pollutes the www-data writable trees (2026-06-19)
The gotcha. On a native install where php-fpm runs as www-data, running composer install
or php artisan … as root creates root-owned files inside Laravel's writable trees —
storage/logs/laravel.log, bootstrap/cache/{packages,services}.php, and (if migrate creates it
as root) database/*.sqlite. php-fpm can read them but not write them.
The nasty failure mode — a masked error. When php-fpm can't write storage/logs/laravel.log,
the logger itself throws: a real app exception (e.g. a missing SQLite DB) reaches the handler, the
handler tries to log it, Monolog's StreamHandler fails on the root-owned log (Permission denied),
and that logging failure is what surfaces — so the served error is misleading and nothing is
written to the log ("logless 500s"). Tells: the rendered stack trace's top frames are in
monolog/.../StreamHandler.php, and find storage bootstrap/cache -user root enumerates the
contamination. When a 500 won't log, suspect the log file's own writability, not just the app.
Chosen convention (2026-06-19): Brad keeps running composer/artisan as root and just
chown -R www-data:www-data storage bootstrap/cache afterward (and ensures any
database/*.sqlite ends up www-data-owned). It's infrequent but easy to forget. The cleaner
long-term fix (run those as the web user, or a proper deploy user) is bookmarked at the personal
layer for a later Linux-admin skill-up. Rule of thumb: anything php-fpm must write — storage/,
bootstrap/cache/, database/*.sqlite — must be www-data-owned. This is the flip side of the
"everything www-data" convention above: root-run tooling silently violates it.
End-to-end native standup recipe — the ordered sequence (distilled 2026-06-20)
The full order we walked to serve Kingdom.md natively on the droplet (no Docker). Each step's
why is in the subsections above; this is the checklist + the red you hit if you skip one.
First instance: Kingdom.md at /var/projects/kingdom.md, served at claude.hugin.gg.
- nginx —
apt install nginx(Debiansites-*layout). Author the app vhost, enable by symlink, add a catch-alldefault_server { return 404; },nginx -t&&systemctl reload. - PHP —
apt install php8.4-fpm php8.4-cli(Ondřej PPA). The fpm pool is alreadywww-data. - Composer — official installer →
/usr/local/bin/composer(commands + SHA fresh from the download page). Red if no PHP CLI:php: command not found. composer install— reds in the order they bite:ext-dom/ext-xmlmissing →apt install php8.4-xml(platform check, before download); then "zip extension and unzip/7z both missing" (hard fail — no source fallback in modern Composer) →apt install unzip php8.4-zip.- App config — repoint hardcoded paths to the native location (e.g.
config/kingdom.php/var/www/html→/var/projects/kingdom.md). Red: fail-loudRuntimeExceptionat boot. .env—cp .env.example .env&&php artisan key:generate. Red without it: a logless 500 — it fails before the logger is usable.- Database —
apt install php8.4-sqlite3(Red: "could not find driver"),touch database/database.sqlite,php artisan migrate:fresh --force(Brad's preferred migrate;--forceis non-interactive insurance —APP_ENV=localwon't prompt anyway). - Permissions —
chown -R www-data:www-data storage bootstrap/cache databaseafter ANY root-run artisan/composer (see the root-pollution lesson above). Red if skipped: a masked, logless 500 — the logger can't write its own file. - Front-end — install Node (NodeSource:
curl …/setup_24.x | bash -→apt install nodejs; gives node 24 + npm), thennpm install&&npm run build. Red without the build: "Vite manifest not found at …/public/build/manifest.json". - Verify it SERVES (a distinct gate from a green test suite — see the 2026-06-17 note):
curlthe app./→ 302 to the first kingdom; content page → 200 with rendered markdown and the built CSS asset served by nginx (also 200).
The reusable vhost (slim Laravel server block, what we landed on):
server {
listen 80;
listen [::]:80;
server_name claude.hugin.gg;
root /var/projects/kingdom.md/public; # prod hosts: /var/www/<app>/public
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
index index.php;
charset utf-8;
location / { try_files $uri $uri/ /index.php?$query_string; }
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
include fastcgi.conf; # sets SCRIPT_FILENAME via $document_root
}
location ~ /\.(?!well-known).* { deny all; }
}
HTTPS via Let's Encrypt / certbot (2026-06-20)
Once the vhost serves over HTTP on a publicly-resolvable domain, add TLS with certbot's nginx plugin — it obtains and installs the cert and wires up auto-renewal:
snap install --classic certbot && ln -sf /snap/bin/certbot /usr/bin/certbot
certbot --nginx -d <domain> -n --agree-tos -m <email> --redirect
- Pre-flight, or the ACME HTTP-01 challenge fails: the domain's DNS must point at this host
(
getent hosts <domain>== the box's public IP) and port 80 must be reachable from the internet — checkufw status; if active,ufw allow 'Nginx Full'(80+443). Here ufw was inactive and DNS already matched, so the challenge passed first try. - What it does to the vhost: appends a managed
listen 443 sslblock (cert paths +options-ssl-nginx.conf+ssl-dhparams.pem) and, with--redirect, a second:80server block that 301s the domain → HTTPS (keepingreturn 404for non-matching hosts). It only augments the slim block — so author the plain:80block first and let certbot add the SSL directives; don't hand-write them. - Renewal is automatic: the snap installs a systemd timer;
certbot renewre-issues + reloads nginx (90-day LE lifetime). First instance:claude.hugin.gg, expires 2026-09-18.
Service autostart on reboot — verify per service; Redis is the one to watch (2026-06-20)
After installing any service the app leans on, confirm it survives a reboot:
systemctl is-enabled <svc> should read enabled (the boot hook) and is-active active.
This stack is all enabled (checked 2026-06-20): nginx, php8.4-fpm, snap.certbot.renew.timer.
nginx here listens on all interfaces (80/443/[::]), not a pinned IP, so it's clear of the
classic "enabled but the bind fails before the network is up at boot" race.
Redis — check its autostart when/if we add it here. (Brad's memory of a past service
not coming back after a reboot is most likely Redis, not nginx.) When redis-server goes
in: confirm systemctl is-enabled redis-server (apt usually enables it, but verify), and set
supervised systemd in /etc/redis/redis.conf so systemd tracks the daemon's lifecycle
correctly. Not needed yet — Kingdom.md's .env carries REDIS_* defaults but SESSION/CACHE/
QUEUE all run on the database/SQLite driver, so no Redis is installed. Forward reminder for when
a Redis-backed cache/queue is introduced.
Open questions for the generic agent
(Things that are clearly Kingdom-specific now but might generalize — flag them here to decide later.)
Capture log
(Chronological dump as we go; periodically distilled up into the thematic sections.)
-
2026-06-11 — Meta-insight for the generic agent. The richest "decisions considered" material for a Laravel project already lives in the project's own
/brainstormingdesign doc — specifically its Decision log table (Kingdom.md's lists ~14 decisions with alternatives + rationale). The generic Laravel agent's first move on a new project should be to mine that decision log, not to reconstruct reasoning from scratch or keep a heavy parallel notebook. So this file stays light: harvest only the genuinely cross-project Laravel patterns (traversal guard, commonmark recipe, clean-install-not-starter-kit, theprosebeauty baseline) during execution; leave the per-project decision record where it belongs — in that project's design doc. -
2026-06-17 — Sail-on-Windows fresh-checkout bootstrap — the gotchas (generic). The
vendor/-on-a-named-volume dev-loop speedup carries a fresh-checkout cost worth a per-project bootstrap note: a brand-new named volume is root-owned, socomposer installfails (vendor/… does not exist and could not be created) until youdocker compose exec -u root … chown -R sail:sail /var/www/html/vendor. (From Git Bash, prefixMSYS_NO_PATHCONV=1or the/var/...argument is mangled into a Windows path.) And a fresh checkout's full bootstrap is more thanup:.env(gitignored), composer install,npm install && npm run build(gitignorednode_modules/public/build→@vite500s without the manifest), andartisan migrate— Laravel's defaultSESSION_DRIVER/CACHE_STORE=databasemake every web request 500 without thesessions/cachetables.- The sharp, cross-project lesson: a green Pest run does NOT prove the served app boots. Tests run on array session/cache drivers, which mask DB/session/asset misconfig that 500s every real HTTP request. So "does it actually serve?" (curl the running app / a smoke test) is a distinct gate from the suite — worth a Confirm-by step of its own when standing an app up somewhere new.
- (Surfaced standing Kingdom.md's stack up from a fresh
maincheckout after retiring the build worktree; the concrete per-project sequence lives in kingdom-md'sCLAUDE.md.)
-
2026-06-21 —
ext-mbstringis a render-time requirement for commonmark, not a deferral (generic). Viewing Kingdom.md documents 500'd withCall to undefined function mb_strcut()fromleague/commonmark'sRegexHelper; root cause wasmbstringmissing from php-fpm (it was on the deferred extension list). Distilled up into the Rendering/Markdown section + a caveat on the php-fpm deferred-set note. The meta-lesson: a markdown app can boot green and serve its homepage/tree while every content page 500s — render-path coverage is its own gate.