TL;DR
- Exposing a local Laravel app over a public HTTPS tunnel breaks two ways: assets load from your
localhost(teammate sees no CSS), andphp artisan servespeaks plain HTTP behind the HTTPS proxy, so Livewire/Flux emithttp://URLs that the browser blocks as mixed content. - Fix the protocol with
trustProxies(at: '*'), and fix the asset origin by pointingAPP_URL+ASSET_URLat the tunnel URL. - I wrapped the whole thing in a
composer sharecommand 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: '*');
// ...
})
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
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
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
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)