DEV Community

Cover image for Share Your Local Laravel App on a Public URL Without Mixed-Content Hell
Nasrul Hazim Bin Mohamad
Nasrul Hazim Bin Mohamad

Posted on

Share Your Local Laravel App on a Public URL Without Mixed-Content Hell

TL;DR

  • Exposing a local Laravel app over a public HTTPS tunnel breaks two ways: assets load from your localhost (teammate sees no CSS), and php artisan serve speaks plain HTTP behind the HTTPS proxy, so Livewire/Flux emit http:// URLs that the browser blocks as mixed content.
  • Fix the protocol with trustProxies(at: '*'), and fix the asset origin by pointing APP_URL + ASSET_URL at the tunnel URL.
  • I wrapped the whole thing in a composer share command that builds assets, opens the tunnel, rewrites .env, and restores it on exit. It's in cleaniquecoders/kickoff.

The problem

You want a teammate to click through your work-in-progress without deploying. The classic move: cloudflared tunnel (or ngrok) gives you a public https://... URL pointing at your local php artisan serve.

Then two things go wrong.

Symptom Cause Fix
Teammate sees raw HTML, no styling @vite points at the Vite dev server on your localhost (public/hot exists) Build assets, delete public/hot
Console: "mixed content blocked", random 419s Proxy is HTTPS but artisan serve is HTTP → request()->isSecure() is false → http:// asset/script URLs Trust the proxy + set ASSET_URL

Fix 1: trust the proxy

The tunnel terminates TLS and forwards plain HTTP to your app with an X-Forwarded-Proto: https header. Laravel ignores that header unless you tell it the proxy is trustworthy.

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    // Honour X-Forwarded-Proto from the tunnel so isSecure() is true
    // and Livewire/Flux emit https:// URLs (no mixed-content blocks).
    $middleware->trustProxies(at: '*');
    // ...
})
Enter fullscreen mode Exit fullscreen mode

at: '*' trusts any proxy. That's fine for a throwaway tunnel on your own machine. In production, trust specific load-balancer IPs instead — a wildcard there lets a client spoof the forwarded headers.

Fix 2: point assets at the tunnel

Even with HTTPS detected, @vite and asset helpers resolve against APP_URL. If that's still http://localhost, the public visitor's browser tries to fetch your localhost. So rewrite both to the tunnel URL once it's up:

# wait for the tunnel to print its public https URL, then:
set_env APP_URL   "$PUBLIC_URL"
set_env ASSET_URL "$PUBLIC_URL"
php artisan config:clear
Enter fullscreen mode Exit fullscreen mode

The one habit that saves you: back up .env first and restore it on exit, so a throwaway URL never gets left behind in your config.

ENV_BACKUP="$(mktemp)"; cp .env "$ENV_BACKUP"

cleanup() {
    rm -f public/hot
    cp "$ENV_BACKUP" .env   # restore APP_URL / ASSET_URL verbatim
    php artisan config:clear >/dev/null 2>&1 || true
}
trap cleanup EXIT
trap 'exit 130' INT TERM
Enter fullscreen mode Exit fullscreen mode

One command

Wrapped together, composer share does the boring sequence every time: npm run build → rm public/hot → php artisan serve → open a Cloudflare (or ngrok) tunnel → scrape the public URL → set APP_URL/ASSET_URL → stream output until Ctrl+C → restore .env. Cloudflare's quick tunnel needs no account, so it's the default.

composer share
  │
  ├─ npm run build         (real assets, not the dev server)
  ├─ rm public/hot         (stop @vite pointing at localhost)
  ├─ php artisan serve     (:8000)
  ├─ cloudflared tunnel    -> https://xxxx.trycloudflare.com
  ├─ set APP_URL+ASSET_URL -> that url, config:clear
  └─ Ctrl+C -> restore .env, drop public/hot
Enter fullscreen mode Exit fullscreen mode

Takeaway

The tunnel was never the hard part — the protocol mismatch was. Trust the forwarded proto, anchor your asset URL to the public host, and always restore .env. Bundle it into one command so "send me a link" takes five seconds, not five minutes of debugging blank CSS.

Code lives in cleaniquecoders/kickoff.

Top comments (0)