Skip to content
prod 352bb92
Browse

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:

  1. Create the standard storage symlinkphp artisan storage:link errors with “The [public/storage] link already exists” on re-runs — ugly, not fatal.
  2. Detect your app’s addon system — CodeCanyon apps deliver addon assets in one of three ways. Detect which yours uses — the action differs.
  3. Create addon symlinks (only if needed) — Run these only when §2 detected the directory and it’s a symlink-based system (not Modules/).
  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.
  5. Confirm the installer page in a browser — The curl check proves the route responds and TLS verifies; browser automation confirms the rendered installer when it is available.

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.

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.

  1. 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" ]; then
    echo "✅ public/storage links to THIS project's storage/app/public"
    readlink public/storage
    else
    # 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:link
    echo "→ new target:"; readlink public/storage
    fi
    # Expected: either the verified existing target, or a fresh relative link to ../storage/app/public
    • public/storage resolves to this project’s storage/app/public (not a dead /var/www/... path).

CodeCanyon apps deliver addon assets in one of three ways. Detect which yours uses — the action differs.

  1. 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.
  2. Match it to the correct action.

    MechanismExample appsHow assets reach public/Action
    Symlink (packages/, uploads/, addons/)Module-based and older CodeCanyon appspublic/packages → ../packages lets the server serve addon files directlyCreate the symlink (§3)
    Copy (Modules/ via nwidart/laravel-modules)Worksuite, SocietyPro, Froiden appsphp artisan module:publish-assets copies into public/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 appsVite 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.

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.

  1. 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"
    fi
    if [ -d uploads ] && [ ! -L public/uploads ]; then
    ( cd public && ln -s ../uploads uploads ) && echo "✅ public/uploads → ../uploads"
    fi
    if [ -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/).
  2. 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/public confirmed; a sample /packages/... asset returns 200, not 404.
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.

  1. Link the project to Herd, secure it, and restart. Run from the project root (the dir holding artisan + public/). herd secure is required for HTTPS — without it https://<project>.test returns 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 https
    herd restart # settle the new site + cert
    herd links | grep -i "[PROJECT_NAME]"
    # Expected: the project appears in the Herd links list; a cert is issued
    • [PROJECT_NAME].test is registered and secured with Herd.
  2. 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
    • readlink points at this project and the “Site not found” count is 0.
  3. If it serves the wrong project or “Site not found” — fix the symlink at the filesystem level. This is the common trap: a .test name linked months ago to an OLD project copy keeps serving it, and with Herd Pro the CLI herd unlink/herd link may report success yet NOT repoint the name (Herd Pro keeps its own site registry separate from the Sites/ 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 symlink
    ln -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-issued

    Then re-run §4.2 — readlink must show this project and the “Site not found” count must be 0. (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].test now serves this project (verified by re-running §4.2).
  4. 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 while php artisan runs 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.json require.php (Herd Pro only — skip on free Herd, which uses one global PHP).
  5. 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].test
    curl -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 /install returns HTTP 200.

The expected route states depend on whether the installer has run yet:

URLExpected (empty DB, first run)After installer (page 4)
https://[PROJECT_NAME].test (root)HTTP 500 acceptable — see below200 or 302 (redirect to login)
https://[PROJECT_NAME].test/installHTTP 200 — REQUIRED404 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.

  1. 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
    • /install returns 200 and ssl_verify_result is 0 (trusted).
  2. 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/install
    browser_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 secure and restart Herd.

Do not mark this step done until every box below is checked.

  • 🤖 Storage symlink resolves herepublic/storage points at this project’s storage/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, or test -d Modules && test ! -d packages proves N/A — Modules app publishes assets; production is restricted to asset extensions, not blanket source.
  • 🤖 Herd + TLS doneherd link + herd secure; cert file present.
  • 🤖 Serves THIS projectreadlink of the Herd site points here and “Herd - Site not found” count = 0.
  • 🔀 Routes verifiedcurl shows /install = HTTP 200 with TLS verify result 0; 🤖 browser verified (agent-driven via Playwright when available; 👤 fallback).