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 /planning for 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>):

  1. Docroot + page: mkdir -p /var/www/<fqdn> and a minimal index.html with <h1>It works!</h1>.
  2. 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; }
    }
    
  3. Enable by symlink (the workflow Brad likes): ln -s /etc/nginx/sites-available/<fqdn> /etc/nginx/sites-enabled/.
  4. Validate + reload: nginx -t && systemctl reload nginx.
  5. Verify locally (no DNS needed): curl -H "Host: <fqdn>" http://127.0.0.1/ or curl --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 nginx can still hit an old worker on the previous config (graceful reload overlaps workers briefly) — it briefly served the default page, 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 / --resolve tests 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 -t before systemctl 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 default site, but unsymlinked. Leave /etc/nginx/sites-available/default in place as a reference, but remove its sites-enabled symlink 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 dedicated sites-available/catch-all, symlinked in, owns the slot:
    server {
        listen 80 default_server;
        listen [::]:80 default_server;
        server_name _;
        return 404;
    }
    
    (Verified live: correct host → 200, wrong host → 404, raw IP → 404.) Ordering gotcha: with the bundled default also enabled you hit duplicate default server on nginx -t — unsymlink default before enabling the catch-all.
  • Run web services as www-data:www-data, and match it on /var/www/*. nginx workers already run as www-data (the user www-data; directive in nginx.conf); php-fpm pools and later supervisorctl-managed workers get the same uid/gid; and each host's /var/www/* trees are chown -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's user/group + listen.owner/listen.group to www-data when 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 appleague/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 treesstorage/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.

  1. nginxapt install nginx (Debian sites-* layout). Author the app vhost, enable by symlink, add a catch-all default_server { return 404; }, nginx -t && systemctl reload.
  2. PHPapt install php8.4-fpm php8.4-cli (Ondřej PPA). The fpm pool is already www-data.
  3. Composer — official installer → /usr/local/bin/composer (commands + SHA fresh from the download page). Red if no PHP CLI: php: command not found.
  4. composer install — reds in the order they bite: ext-dom/ext-xml missing → 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.
  5. App config — repoint hardcoded paths to the native location (e.g. config/kingdom.php /var/www/html/var/projects/kingdom.md). Red: fail-loud RuntimeException at boot.
  6. .envcp .env.example .env && php artisan key:generate. Red without it: a logless 500 — it fails before the logger is usable.
  7. Databaseapt install php8.4-sqlite3 (Red: "could not find driver"), touch database/database.sqlite, php artisan migrate:fresh --force (Brad's preferred migrate; --force is non-interactive insurance — APP_ENV=local won't prompt anyway).
  8. Permissionschown -R www-data:www-data storage bootstrap/cache database after 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.
  9. Front-end — install Node (NodeSource: curl …/setup_24.x | bash -apt install nodejs; gives node 24 + npm), then npm install && npm run build. Red without the build: "Vite manifest not found at …/public/build/manifest.json".
  10. Verify it SERVES (a distinct gate from a green test suite — see the 2026-06-17 note): curl the 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 — check ufw 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 ssl block (cert paths + options-ssl-nginx.conf + ssl-dhparams.pem) and, with --redirect, a second :80 server block that 301s the domain → HTTPS (keeping return 404 for non-matching hosts). It only augments the slim block — so author the plain :80 block first and let certbot add the SSL directives; don't hand-write them.
  • Renewal is automatic: the snap installs a systemd timer; certbot renew re-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-11Meta-insight for the generic agent. The richest "decisions considered" material for a Laravel project already lives in the project's own /brainstorming design 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, the prose beauty baseline) during execution; leave the per-project decision record where it belongs — in that project's design doc.

  • 2026-06-17Sail-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, so composer install fails (vendor/… does not exist and could not be created) until you docker compose exec -u root … chown -R sail:sail /var/www/html/vendor. (From Git Bash, prefix MSYS_NO_PATHCONV=1 or the /var/... argument is mangled into a Windows path.) And a fresh checkout's full bootstrap is more than up: .env (gitignored), composer install, npm install && npm run build (gitignored node_modules/public/build@vite 500s without the manifest), and artisan migrate — Laravel's default SESSION_DRIVER/CACHE_STORE=database make every web request 500 without the sessions/cache tables.

    • 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 main checkout after retiring the build worktree; the concrete per-project sequence lives in kingdom-md's CLAUDE.md.)
  • 2026-06-21ext-mbstring is a render-time requirement for commonmark, not a deferral (generic). Viewing Kingdom.md documents 500'd with Call to undefined function mb_strcut() from league/commonmark's RegexHelper; root cause was mbstring missing 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.