/ Content
You're building an integration with Stripe, or Twilio, or some other service that sends webhooks. The service needs a publicly accessible URL to POST to, but you're developing locally. How do you test that your webhook handler actually works?
Years ago this meant figuring out your public IP, port forwarding through your router, hoping your ISP didn't block incoming connections, and remembering to disable it all when you were done. If you had multiple sites on the same machine, routing to the right one was another headache.
ngrok solved most of this. It creates a tunnel from your local machine to their servers and gives you a public URL. Requests to that URL get forwarded to your local server. No router configuration, no firewall rules, works through NAT.
With Laravel Herd, local development is even simpler. No Vagrant, no Docker, just native PHP and nginx on your Mac. Combining Herd with ngrok gives you a fast local dev environment with public webhook access when you need it.
How ngrok Works
ngrok runs a client on your machine that maintains a persistent connection to ngrok's edge servers. When you start a tunnel, ngrok assigns you a public URL like https://abc123.ngrok.io. Any request to that URL travels to ngrok's servers, through the tunnel to your machine, and to whatever local port you specified.
Basic usage:
ngrok http 80
This tunnels public traffic to port 80 on localhost. ngrok shows you the assigned URL and a dashboard URL for inspecting requests.
The Herd Complication
With Herd, your sites are accessible at yoursite.test using the .test TLD. Herd's nginx configuration routes requests based on the Host header. A request to myapp.test goes to the myapp directory in your Herd sites folder.
When ngrok forwards a request, the Host header is the ngrok URL (abc123.ngrok.io), not your local domain. Herd's nginx doesn't know where to route it. You get a 404 or the wrong site.
The fix is the --host-header flag:
ngrok http 80 --host-header=myapp.test
This tells ngrok to rewrite the Host header on incoming requests to myapp.test before forwarding them. Now Herd routes the request correctly.
If you're using HTTPS locally (Herd can secure sites with herd secure), tunnel to port 443:
ngrok http 443 --host-header=myapp.test
The ngrok Dashboard
Every ngrok session includes a local web dashboard at http://127.0.0.1:4040. This is invaluable for webhook development.
The dashboard shows:
- Every request that came through the tunnel
- Full request headers and body
- Response status, headers, and body
- Timing information
When a webhook fails, you can see exactly what the service sent and exactly what your app returned. You can also replay requests, which saves a lot of time when debugging. Instead of triggering the webhook from the external service repeatedly, just click replay in the dashboard.
The ngrok API
ngrok exposes a local API at the same port as the dashboard. You can query it to get tunnel information programmatically:
curl http://127.0.0.1:4040/api/tunnels
This returns JSON with details about active tunnels, including the public URL:
{
"tunnels": [
{
"name": "command_line",
"public_url": "https://abc123.ngrok.io",
"proto": "https",
"config": {
"addr": "http://localhost:80"
}
}
]
}
This API is useful for scripting. Instead of copying the URL from the terminal every time you start ngrok, you can fetch it programmatically and inject it into your app's configuration.
A Helper Script
Here's a simple bash script that starts ngrok and writes the public URL to a file your Laravel app can read:
#!/bin/bash
# ngrok.sh - Start ngrok and write config for Laravel
SITE="${1:-$(basename $(pwd)).test}"
PORT="${2:-443}"
CONFIG_FILE=".ngrok.json"
# Check if ngrok is already running
if pgrep -x "ngrok" > /dev/null; then
echo "ngrok is already running. Kill it first: pkill ngrok"
exit 1
fi
# Start ngrok in background
ngrok http $PORT --host-header=$SITE > /dev/null &
NGROK_PID=$!
echo "Starting ngrok for $SITE..."
# Wait for ngrok to initialize
sleep 2
# Fetch the public URL
PUBLIC_URL=$(curl -s http://127.0.0.1:4040/api/tunnels | grep -o '"public_url":"https://[^"]*' | head -1 | cut -d'"' -f4)
if [ -z "$PUBLIC_URL" ]; then
echo "Failed to get ngrok URL. Check if ngrok started correctly."
kill $NGROK_PID 2>/dev/null
exit 1
fi
# Write config file
echo "{\"status\":\"active\",\"url\":\"$PUBLIC_URL\"}" > $CONFIG_FILE
echo "ngrok running at: $PUBLIC_URL"
echo "Dashboard: http://127.0.0.1:4040"
echo "Config written to $CONFIG_FILE"
echo ""
echo "Press Ctrl+C to stop"
# Cleanup on exit
cleanup() {
echo ""
echo "Shutting down..."
echo "{\"status\":\"inactive\",\"url\":\"\"}" > $CONFIG_FILE
kill $NGROK_PID 2>/dev/null
exit 0
}
trap cleanup SIGINT SIGTERM
# Keep script running
wait $NGROK_PID
Save it as ngrok.sh in your project root, make it executable with chmod +x ngrok.sh, and run it:
./ngrok.sh myapp.test 443
Or with no arguments to use the current directory name:
./ngrok.sh
The script writes a .ngrok.json file with the tunnel status and URL. When you stop the script, it updates the file to show inactive status.
Add .ngrok.json to your .gitignore:
.ngrok.json
Reading the URL in Laravel
Create a helper to read the ngrok configuration:
// app/Support/Ngrok.php
namespace App\Support;
class Ngrok
{
public static function isActive(): bool
{
return self::getConfig()['status'] === 'active';
}
public static function url(): ?string
{
$config = self::getConfig();
return $config['status'] === 'active' ? $config['url'] : null;
}
protected static function getConfig(): array
{
$path = base_path('.ngrok.json');
if (!file_exists($path)) {
return ['status' => 'inactive', 'url' => ''];
}
return json_decode(file_get_contents($path), true) ?? ['status' => 'inactive', 'url' => ''];
}
}
Dynamic APP_URL for Webhooks
The common pattern is overriding APP_URL when ngrok is active. You can do this in your AppServiceProvider:
// app/Providers/AppServiceProvider.php
use App\Support\Ngrok;
public function boot(): void
{
if (app()->environment('local') && Ngrok::isActive()) {
config(['app.url' => Ngrok::url()]);
url()->forceRootUrl(Ngrok::url());
}
}
Now url(), route(), and config('app.url') all return the ngrok URL when the tunnel is active. When you register webhook URLs with external services, they'll point to ngrok automatically.
Be careful with this approach. You probably don't want every URL in your app pointing to ngrok. A more targeted approach is to only use the ngrok URL where you actually need it:
// When registering a webhook with an external service
$webhookUrl = Ngrok::isActive()
? Ngrok::url() . '/webhooks/stripe'
: route('webhooks.stripe');
$stripe->webhookEndpoints->create([
'url' => $webhookUrl,
'enabled_events' => ['payment_intent.succeeded'],
]);
Or create a dedicated helper:
// app/Support/Ngrok.php
public static function route(string $name, array $parameters = []): string
{
$url = route($name, $parameters);
if (!self::isActive()) {
return $url;
}
// Replace the app URL with the ngrok URL
return str_replace(config('app.url'), self::url(), $url);
}
Usage:
$webhookUrl = Ngrok::route('webhooks.stripe');
Package.json Script
If you prefer npm scripts:
{
"scripts": {
"ngrok": "./ngrok.sh"
}
}
Then npm run ngrok starts the tunnel.
Free vs Paid ngrok
The free tier gives you random URLs that change every time you restart ngrok. Paid plans let you reserve custom subdomains that stay consistent. For webhook development, the random URLs are usually fine since you're re-registering webhooks frequently anyway. If you're demoing to clients or need stable URLs, the paid plan is worth it.
Alternatives
Expose is a similar tool written in PHP, made by the Beyond Code team. It works essentially the same way and integrates nicely with Laravel.
Laravel Herd Pro includes built-in tunnel support without needing a separate tool. If you're already paying for Herd Pro, that's the simplest option.
For most development work, the free ngrok tier does the job. The script approach here works regardless of which tunneling tool you use since they all provide similar APIs.