3 · Storage, symlinks & SSL
Objective — make the public directory serveable and give the project a secure local domain: create the storage symlink, detect and wire the app’s addon system (Modules vs packages vs addons), then link the project to Herd over HTTPS and prove the installer route is reachable.
Steps at a glance:
- Create the standard storage symlink —
php artisan storage:linkerrors with “The [public/storage] link already exists” on re-runs — ugly, not fatal. - Detect your app’s addon system — CodeCanyon apps deliver addon assets in one of three ways. Detect which yours uses — the action differs.
- Create addon symlinks (only if needed) — Run these only when §2 detected the directory and it’s a
symlink-based system (not
Modules/). - Link the project to Herd and secure it with TLS — Give the project a local
.testdomain and a trusted certificate, then prove the domain serves this project — not “Site not found” and not an old copy. - Confirm the installer page in a browser — The
curlcheck proves the route responds and TLS verifies; browser automation confirms the rendered installer when it is available.
Background
Section titled “Background”Before the installer can run, the public directory needs its symlinks and the project needs a secure local domain. Two classes of “it looks fine but serves nothing” bugs hide here: a shipped public/storage symlink pointing at a dead server path (assets 403/404), and a .test name still pointing at an OLD project copy (Herd serves the wrong app, and Herd Pro’s CLI may refuse to repoint it). Both are caught by verifying the target, not just that the link “exists”. Addon systems also differ — get the detection right and you avoid a whole class of missing-asset bugs. The full Herd / Herd Pro reference is the zaj-herd skill.
1. Create the standard storage symlink
Section titled “1. Create the standard storage symlink”php artisan storage:link errors with “The [public/storage] link already exists” on re-runs — ugly, not fatal. The bigger trap is a shipped public/storage symlink that points at a dead server path (e.g. /var/www/<app>/storage/app/public): it “already exists” so the command skips, but every /storage/* asset 403/404s locally until you relink. So don’t just check existence — check the target actually resolves to this project’s storage/app/public, and relink if not.
-
Create or repair the link — existence alone isn’t enough.
Terminal window if [ -L public/storage ] && [ -e public/storage ] \&& [ "$(cd public/storage 2>/dev/null && pwd -P)" = "$PWD/storage/app/public" ]; thenecho "✅ public/storage links to THIS project's storage/app/public"readlink public/storageelse# Missing, OR a dead/wrong symlink (often a shipped /var/www/... server path) — relink fresh.[ -e public/storage ] || [ -L public/storage ] && { echo "↻ removing stale public/storage:"; readlink public/storage 2>/dev/null; rm -f public/storage; }php artisan storage:linkecho "→ new target:"; readlink public/storagefi# Expected: either the verified existing target, or a fresh relative link to ../storage/app/public- ✅
public/storageresolves to this project’sstorage/app/public(not a dead/var/www/...path).
- ✅
2. Detect your app’s addon system
Section titled “2. Detect your app’s addon system”CodeCanyon apps deliver addon assets in one of three ways. Detect which yours uses — the action differs.
-
Probe for the four addon directory shapes.
Terminal window [ -d Modules ] && echo "📦 Modules/ — nwidart/laravel-modules (copy-based, NO symlink)"[ -d packages ] && echo "📦 packages/ — in-tree packages (symlink required)"[ -d addons ] && echo "📦 addons/ — in-tree addons (symlink required)"[ -d uploads ] && echo "📦 uploads/ — root uploads dir (symlink required)"# Expected: a line per directory that exists — tells you which mechanism applies- ✅ The app’s addon mechanism is identified.
-
Match it to the correct action.
Mechanism Example apps How assets reach public/Action Symlink ( packages/,uploads/,addons/)Module-based and older CodeCanyon apps public/packages → ../packageslets the server serve addon files directlyCreate the symlink (§3) Copy ( Modules/vianwidart/laravel-modules)Worksuite, SocietyPro, Froiden apps php artisan module:publish-assetscopies intopublic/modules/<name>/No symlink — the installer (page 4) runs module:publish-assets. Re-run after manual module updates.Build output ( Modules/*/public/compiled)Newer modular apps Vite builds module assets into public/build/No symlink — handled by npm run build(page 1)- ✅ The right action for this app’s mechanism is identified.
3. Create addon symlinks (only if needed)
Section titled “3. Create addon symlinks (only if needed)”Run these only when §2 detected the directory and it’s a symlink-based system (not Modules/). The symptom this fixes: in-tree module assets live at packages/<vendor>/<Module>/src/... on disk but are referenced in HTML as /packages/<vendor>/... URLs — and public/packages doesn’t exist, so every module image 404s and the landing page renders broken. The file is on disk; only the public mapping is missing.
-
Create the symlinks for whichever directories exist.
Terminal window if [ -d packages ] && [ ! -L public/packages ]; then( cd public && ln -s ../packages packages ) && echo "✅ public/packages → ../packages"fiif [ -d uploads ] && [ ! -L public/uploads ]; then( cd public && ln -s ../uploads uploads ) && echo "✅ public/uploads → ../uploads"fiif [ -d addons ] && [ ! -L public/addons ]; then( cd public && ln -s ../addons addons ) && echo "✅ public/addons → ../addons"fi# Expected: a ✅ line per symlink created (nothing for Modules/ apps)- ✅ Each required addon symlink exists (or nothing, correctly, for
Modules/).
- ✅ Each required addon symlink exists (or nothing, correctly, for
-
Verify what exists, then prove a module asset actually resolves.
Terminal window ls -la public/ | grep "^l" || echo "(none — normal for Modules/ apps)"[ -d storage/app/public ] && echo "✅ storage/app/public" || echo "❌ MISSING"# Smoke-test one real module asset URL (adjust the path to a file you can see on disk):ASSET=$(find packages -type f \( -name "*.png" -o -name "*.jpg" -o -name "*.svg" \) 2>/dev/null | head -1)[ -n "$ASSET" ] && curl -sI -o /dev/null -w "module asset: HTTP %{http_code}\n" \"https://[PROJECT_NAME].test/$ASSET"# Expected: the symlinks list, "✅ storage/app/public", and the module asset = HTTP 200 (not 404)- ✅ Symlinks and
storage/app/publicconfirmed; a sample/packages/...asset returns 200, not 404.
- ✅ Symlinks and
4. Link the project to Herd and secure it with TLS
Section titled “4. Link the project to Herd and secure it with TLS”Give the project a local .test domain and a trusted certificate, then prove the domain serves this project — not “Site not found” and not an old copy.
-
Link the project to Herd, secure it, and restart. Run from the project root (the dir holding
artisan+public/).herd secureis required for HTTPS — without ithttps://<project>.testreturns a 404 / cert error.Terminal window herd link [PROJECT_NAME] # serves https://[PROJECT_NAME].test → this dir's public/herd secure [PROJECT_NAME] # issues the TLS cert — REQUIRED for httpsherd restart # settle the new site + certherd links | grep -i "[PROJECT_NAME]"# Expected: the project appears in the Herd links list; a cert is issued- ✅
[PROJECT_NAME].testis registered and secured with Herd.
- ✅
-
VERIFY the domain serves THIS project — don’t trust “link created”. The link can succeed while the name still points at an old copy or at nothing. Two checks: the symlink target, and a real HTTP fetch that must contain zero “Site not found” markers.
Terminal window SITES="$HOME/Library/Application Support/Herd/config/valet/Sites"readlink "$SITES/[PROJECT_NAME]" # MUST print the path of THIS project (the dir you're in)[ "$(readlink "$SITES/[PROJECT_NAME]")" = "$PWD" ] && echo "✅ points at THIS project" \|| echo "❌ points elsewhere — fix in §4.3"curl -sSLk -o /tmp/h.html -w 'HTTP %{http_code}\n' --retry 20 --retry-delay 1 --retry-connrefused \https://[PROJECT_NAME].test/grep -c 'Herd - Site not found' /tmp/h.html # MUST be 0# Expected: readlink = this project; HTTP 200/500; grep count = 0- ✅
readlinkpoints at this project and the “Site not found” count is0.
- ✅
-
If it serves the wrong project or “Site not found” — fix the symlink at the filesystem level. This is the common trap: a
.testname linked months ago to an OLD project copy keeps serving it, and with Herd Pro the CLIherd unlink/herd linkmay report success yet NOT repoint the name (Herd Pro keeps its own site registry separate from theSites/symlinks). The deterministic fix is to drop and recreate the symlink yourself, then re-secure.Terminal window SITES="$HOME/Library/Application Support/Herd/config/valet/Sites"rm -f "$SITES/[PROJECT_NAME]" # drop the stale / wrong symlinkln -s "$PWD" "$SITES/[PROJECT_NAME]" # point it at THIS project (run from project root)herd secure [PROJECT_NAME] && herd restart# Expected: the symlink now points here; cert re-issuedThen re-run §4.2 —
readlinkmust show this project and the “Site not found” count must be0. (In the Herd Pro GUI you can also delete the poisoned site and re-add it, but the filesystem fix is reliable when the CLI won’t repoint.)- ✅
[PROJECT_NAME].testnow serves this project (verified by re-running §4.2).
- ✅
-
Herd Pro: set the per-site PHP version to match
composer.json. Herd Pro runs sites at a per-site PHP version that may differ from your CLI PHP. A mismatch makes the browser boot under the wrong PHP whilephp artisanruns under another — confusing “works in terminal, 500 in browser” splits.Terminal window php -r 'echo "composer.json requires: ", json_decode(file_get_contents("composer.json"))->require->php ?? "(unset)", PHP_EOL;'# Then in the Herd Pro GUI → this site → PHP version → pick the matching major.minor (e.g. 8.2/8.3).# Expected: per-site PHP matches composer.json's require.php- ✅ The site’s PHP version matches
composer.jsonrequire.php(Herd Pro only — skip on free Herd, which uses one global PHP).
- ✅ The site’s PHP version matches
-
Confirm the cert exists and both routes resolve.
Terminal window ls -la ~/Library/Application\ Support/Herd/config/valet/Certificates/ | grep -i "[PROJECT_NAME].test" \|| echo "❌ No cert — re-run herd secure"curl -sI -o /dev/null -w "root: HTTP %{http_code} | TLS %{ssl_verify_result}\n" https://[PROJECT_NAME].testcurl -sI -o /dev/null -w "install: HTTP %{http_code}\n" https://[PROJECT_NAME].test/install# Expected: a cert file listed; /install returns HTTP 200 (root 500 is acceptable here)- ✅ The cert file exists and
/installreturns HTTP 200.
- ✅ The cert file exists and
The expected route states depend on whether the installer has run yet:
| URL | Expected (empty DB, first run) | After installer (page 4) |
|---|---|---|
https://[PROJECT_NAME].test (root) | HTTP 500 acceptable — see below | 200 or 302 (redirect to login) |
https://[PROJECT_NAME].test/install | HTTP 200 — REQUIRED | 404 or 403 (blocked on page 6) |
5. Confirm the installer page in a browser
Section titled “5. Confirm the installer page in a browser”The curl check proves the route responds and the certificate verifies. Browser automation then proves the installer renders, without disturbing the user’s open tab.
-
Verify the TLS result before the browser pass.
Terminal window curl -sI -o /dev/null -w "install: HTTP %{http_code} | TLS %{ssl_verify_result}\n" https://[PROJECT_NAME].test/install# Expected: install: HTTP 200 | TLS 0- ✅
/installreturns200andssl_verify_resultis0(trusted).
- ✅
-
Open the installer page with browser automation and confirm the welcome screen renders. Use an isolated browser instance so the user’s tab is not disturbed.
browser_navigate https://[PROJECT_NAME].test/installbrowser_snapshot# Expected: installer welcome page renders; no browser TLS warning/interstitial- ✅ The installer welcome page renders without a TLS warning. If the browser shows a cert interstitial, re-run
herd secureand restart Herd.
- ✅ The installer welcome page renders without a TLS warning. If the browser shows a cert interstitial, re-run
Checklist
Section titled “Checklist”Do not mark this step done until every box below is checked.
- 🤖 Storage symlink resolves here —
public/storagepoints at this project’sstorage/app/public, not a dead/var/www/...path. - 🤖 Addon system identified — Modules / packages / addons / uploads.
- 🤖 Addon symlinks created + asset 200, or N/A proved — a sample
/packages/...asset returns 200, ortest -d Modules && test ! -d packagesprovesN/A — Modules app publishes assets; production is restricted to asset extensions, not blanket source. - 🤖 Herd + TLS done —
herd link+herd secure; cert file present. - 🤖 Serves THIS project —
readlinkof the Herd site points here and “Herd - Site not found” count =0. - 🔀 Routes verified —
curlshows/install= HTTP 200 with TLS verify result0; 🤖 browser verified (agent-driven via Playwright when available; 👤 fallback).