One command gives your localhost a public HTTPS URL — no DNS, no SSL, no firewall rules. Here's how to use ngrok effectively in a Laravel workflow, from testing Stripe webhooks locally to demoing features to clients without a deploy.
📋 Table of Contents
- 🔌 What ngrok Actually Does
- ⚙️ Installation and First Run
- 🔐 Static Domains and Stable URLs
- 💳 Testing Payment Webhooks Locally
- 🛠️ ngrok with Laravel Herd and Valet
- 🔍 The ngrok Inspector - Reading Raw Webhook Payloads
- 🧪 Automated Webhook Testing Without ngrok
- 🚀 Other Use Cases Worth Knowing
- ✅ Conclusion
🔌 What ngrok Actually Does
ngrok creates a secure tunnel from a public URL to a port running on your local machine. When a request hits https://abc123.ngrok-free.app, ngrok forwards it through an encrypted tunnel to http://localhost:8000 (or whatever port you configure).
Payment provider (Stripe)
│
▼
https://your-subdomain.ngrok-free.app ← public HTTPS endpoint
│ (encrypted tunnel)
▼
http://localhost:8000 ← your Laravel dev server
│
▼
WebhookController@handle
No DNS setup, no SSL certificate, no firewall rules. The tunnel is established outbound from your machine, so it works behind NAT, corporate firewalls, and VPNs.
What ngrok is not: a deployment tool. The tunnel dies when you close the terminal. It is a development and debugging utility only.
⚙️ Installation and First Run
macOS (Homebrew):
brew install ngrok/ngrok/ngrok
Direct download:
# Download from https://ngrok.com/download - unzip and move to PATH
sudo mv ngrok /usr/local/bin
Authenticate (free account required for stable URLs):
ngrok config add-authtoken YOUR_TOKEN_HERE
Expose your Laravel dev server:
# Start Laravel
php artisan serve # runs on :8000 by default
# In a second terminal, expose it
ngrok http 8000
Output:
Session Status online
Account [email protected] (Plan: Free)
Forwarding https://abc123.ngrok-free.app -> http://localhost:8000
Connections ttl opn rt1 rt5 p50 p90
0 0 0.00 0.00 0.00 0.00
The https://abc123.ngrok-free.app URL is now publicly reachable. Paste it anywhere - Stripe dashboard, Postman, a colleague's browser, a mobile device on another network.
With a custom port (Laravel Sail / Docker):
ngrok http 80 # if Sail maps to port 80
🔐 Static Domains and Stable URLs
The free tier assigns a random subdomain on every tunnel restart. This is a problem for webhook configuration - you'd need to update the Stripe dashboard every time you restart ngrok.
Solution: static domain (free)
ngrok's free plan includes one static domain. Claim it at dashboard.ngrok.com/domains.
ngrok http --domain=your-name.ngrok-free.app 8000
Now the URL never changes. Configure it in Stripe once, restart ngrok as many times as you need.
Config file for repeatable sessions:
# ~/.config/ngrok/ngrok.yml
version: "3"
authtoken: YOUR_TOKEN_HERE
tunnels:
laravel:
proto: http
addr: 8000
domain: your-name.ngrok-free.app
ngrok start laravel
💳 Testing Payment Webhooks Locally
This is where ngrok provides the most value. Payment providers send webhook events (payment confirmed, refund issued, subscription cancelled) to a URL you register in their dashboard. That URL must be publicly reachable. During local development, it can't be localhost.
Stripe
1. Get your ngrok URL:
ngrok http --domain=your-name.ngrok-free.app 8000
2. Register the webhook endpoint in Stripe Dashboard:
Developers → Webhooks → Add endpoint
URL: https://your-name.ngrok-free.app/webhook/stripe
Events: payment_intent.succeeded, payment_intent.payment_failed, charge.refunded
3. Laravel webhook route (outside api middleware, no CSRF):
// routes/web.php
use App\Http\Controllers\Webhook\StripeWebhookController;
Route::post('/webhook/stripe', StripeWebhookController::class)
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
4. The controller - always verify the signature:
// app/Http/Controllers/Webhook/StripeWebhookController.php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Webhook;
use App\Http\Controllers\Controller;
use App\Jobs\HandleStripePaymentSucceeded;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
class StripeWebhookController extends Controller
{
public function __invoke(Request $request): Response
{
$payload = $request->getContent();
$sigHeader = $request->header('Stripe-Signature');
try {
$event = Webhook::constructEvent(
$payload,
$sigHeader,
config('services.stripe.webhook_secret'),
);
} catch (SignatureVerificationException) {
return response('Invalid signature', 400);
}
match ($event->type) {
'payment_intent.succeeded' => HandleStripePaymentSucceeded::dispatch($event->data->object),
'payment_intent.payment_failed' => logger()->warning('Payment failed', ['id' => $event->data->object->id]),
default => null,
};
return response('OK', 200);
}
}
5. Set the webhook secret in .env:
# From Stripe Dashboard → Webhooks → your endpoint → Signing secret
STRIPE_WEBHOOK_SECRET=whsec_...
6. Trigger a test event from Stripe CLI:
brew install stripe/stripe-cli/stripe
stripe login
stripe trigger payment_intent.succeeded
Stripe sends a real signed webhook to your ngrok URL, which forwards it to Laravel. You can debug the full flow with dd() or Telescope without any mocking.
Przelewy24 / PayU / Tpay
Polish payment providers follow the same pattern, but use IP whitelisting or basic HMAC signatures instead of Stripe's header-based signing. ngrok works identically.
Przelewy24 example:
Register your ngrok URL as the return notification URL in the P24 merchant panel:
https://your-name.ngrok-free.app/webhook/p24
// app/Http/Controllers/Webhook/P24WebhookController.php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Webhook;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
class P24WebhookController extends Controller
{
public function __invoke(Request $request): Response
{
// P24 sends a CRC check - verify it before processing
$expectedSign = md5(
$request->input('p24_session_id') . '|' .
$request->input('p24_order_id') . '|' .
$request->input('p24_amount') . '|' .
$request->input('p24_currency') . '|' .
config('services.p24.crc_key')
);
if ($request->input('p24_sign') !== $expectedSign) {
Log::warning('P24 invalid signature', $request->all());
return response('ERROR', 400);
}
// Process the verified notification
Log::info('P24 payment confirmed', [
'order_id' => $request->input('p24_order_id'),
'session_id' => $request->input('p24_session_id'),
'amount' => $request->input('p24_amount'),
]);
return response('OK', 200);
}
}
Important: Always verify the signature or CRC of every incoming webhook. ngrok exposes your local server to the public internet. An unverified webhook handler is an open endpoint.
🛠️ ngrok with Laravel Herd and Valet
If you use Laravel Herd or Valet, your app is already at a .test domain (e.g., myapp.test). ngrok can tunnel to that virtual host directly.
Herd / Valet:
# Herd uses port 80 with virtual hosting - pass the host header
ngrok http --host-header=myapp.test 80
Without --host-header, the request arrives at nginx/caddy without the correct Host header and returns a 404.
Or use the Herd built-in share feature:
Laravel Herd Pro includes a built-in site sharing feature that wraps ngrok. If you have Herd Pro, you can right-click a site in the Herd menu bar app and select "Share" - it handles the ngrok configuration automatically and gives you a shareable URL instantly.
For Herd Basic, use ngrok directly with the --host-header flag.
🔍 The ngrok Inspector - Reading Raw Webhook Payloads
ngrok runs a local web inspector at http://localhost:4040. This is one of its most useful features for webhook debugging.
Open it in a browser while ngrok is running:
http://localhost:4040
You get a real-time log of every request that passed through the tunnel:
- Full request headers and body (raw JSON payload from Stripe, P24, etc.)
- Response status and body returned by your Laravel app
- Request timing
- Replay button - resend the exact same request without triggering it from the payment provider again
The replay feature is especially useful when iterating on webhook handler logic. Trigger the webhook once from Stripe, then replay it as many times as you need from the inspector while you fix your code. No need to create real test payments repeatedly.
Access the inspector via API:
# List recent requests programmatically
curl http://localhost:4040/api/requests/http | jq '.requests[0].response.status'
🧪 Automated Webhook Testing Without ngrok
ngrok is the right tool for interactive debugging. For automated tests, use Laravel's built-in HTTP testing instead.
// tests/Feature/Webhook/StripeWebhookTest.php
<?php
declare(strict_types=1);
use App\Models\Order;
use Illuminate\Support\Facades\Queue;
use App\Jobs\HandleStripePaymentSucceeded;
it('processes a valid Stripe payment_intent.succeeded webhook', function () {
Queue::fake();
$order = Order::factory()->create(['status' => 'pending']);
$payload = json_encode([
'type' => 'payment_intent.succeeded',
'data' => [
'object' => [
'id' => 'pi_test_123',
'metadata' => ['order_id' => $order->id],
'amount' => 9900,
'currency' => 'pln',
],
],
]);
// Build a valid Stripe signature for the test
$secret = config('services.stripe.webhook_secret');
$timestamp = time();
$signature = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret);
$header = "t={$timestamp},v1={$signature}";
$this->postJson('/webhook/stripe', json_decode($payload, true), [
'Stripe-Signature' => $header,
])->assertOk();
Queue::assertDispatched(HandleStripePaymentSucceeded::class);
});
it('rejects a webhook with an invalid signature', function () {
$this->postJson('/webhook/stripe', ['type' => 'payment_intent.succeeded'], [
'Stripe-Signature' => 't=000,v1=invalidsig',
])->assertStatus(400);
});
This test runs without ngrok, without network access, and without a real Stripe account. Use ngrok for the first time you wire up a new webhook integration - once you understand the payload structure, write the test and you no longer need the tunnel for regression testing.
🚀 Other Use Cases Worth Knowing
Mobile app development: Your React Native or Flutter app needs to hit a real API. http://localhost:8000 doesn't work on a physical device or simulator on a different network. Point the app at your ngrok URL.
OAuth callback URLs: OAuth providers (GitHub, Google) require a registered redirect URI. During development, register your ngrok domain as the callback URL and update it once per static domain setup.
Sharing a work-in-progress with a client: No deploy needed. Start ngrok, send the link, demo the feature. The URL works for anyone.
Testing HTTPS-required features locally: Some browser APIs (geolocation, camera, Service Workers) require HTTPS. ngrok provides HTTPS out of the box, even when your local server is plain HTTP.
Inspecting any HTTP traffic: ngrok works with any local server - not just PHP. Use it with python -m http.server, Node.js, or any other local service.
✅ Conclusion
- ngrok creates an encrypted public tunnel to your local server in one command - no DNS, no SSL, no firewall rules
- Use a static domain (free) so your webhook URLs don't change between tunnel restarts
- For payment webhooks (Stripe, P24, PayU, Tpay), register the ngrok URL in the provider dashboard once and test the full flow end-to-end locally - including real signatures
- Always verify webhook signatures - ngrok is a public endpoint
- The ngrok inspector at
http://localhost:4040lets you replay webhooks without re-triggering them from the payment provider - For CI and regression testing, mock the webhook with a signed payload in a feature test - save ngrok for interactive integration work
- The
--host-headerflag is required when tunneling to Herd or Valet virtual hosts
Follow me on LinkedIn for more Laravel tips! Have you ever lost hours debugging a webhook that only worked in production? ngrok would have saved that time. Let me know in the comments below!