ngrok gives a local endpoint a temporary public HTTPS URL. It is useful for development, but every inbound request remains hostile until the provider signature is verified. Register the generated callback URL in the provider console as normal prose, not a heading hidden in the middle of an article.
php artisan serve
ngrok http 8000
For Stripe, verify the raw body with the official signing secret. md5, $request->all() and a client-provided event type prove nothing and can also alter the payload before verification.
<?php
declare(strict_types=1);
use Illuminate\Http\Request;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
public function __invoke(Request $request): Response
{
try {
$event = Webhook::constructEvent($request->getContent(), (string) $request->header('Stripe-Signature'), config('services.stripe.webhook_secret'));
} catch (SignatureVerificationException) {
abort(400, 'Invalid Stripe signature.');
}
ProcessStripeEvent::dispatch($event->id, $event->type, $event->data->object);
return response()->noContent();
}
Persist the provider event ID under a unique database constraint before irreversible work; retries are normal. Return a fast 2xx after validation and queue slow work. Remove dd() before a provider test: it produces failed deliveries and may expose payment data in a debug page. ngrok's inspector is valuable for comparing raw payloads and headers, never as authentication.
Create a tunnel that matches the local host
Install ngrok, authenticate once and forward the port Laravel really serves. A free URL changes between sessions; a reserved domain makes a shared development callback stable. Neither is a production ingress.
ngrok config add-authtoken "$NGROK_AUTHTOKEN"
php artisan serve --host=127.0.0.1 --port=8000
ngrok http --domain=webhooks-dev.example.ngrok.app 8000
Herd and Valet use a hostname rather than localhost. Preserve it when the
application depends on host-based routes, signed URLs or cookie domains.
ngrok http --host-header=app.test 80
Persist first, then queue an idempotent delivery
The raw body must be verified before parsing. A unique provider event ID, not an in-memory flag, survives concurrent retries and worker restarts.
<?php
declare(strict_types=1);
namespace App\\Http\\Controllers\\Webhooks;
use App\\Jobs\\ProcessStripeEvent;
use App\\Models\\WebhookDelivery;
use Illuminate\\Http\\Request;
use Illuminate\\Http\\Response;
use Stripe\\Exception\\SignatureVerificationException;
use Stripe\\Webhook;
final class StripeWebhookController
{
public function __invoke(Request $request): Response
{
try {
$event = Webhook::constructEvent($request->getContent(), (string) $request->header('Stripe-Signature'), (string) config('services.stripe.webhook_secret'));
} catch (SignatureVerificationException) {
return response()->noContent(400);
}
$delivery = WebhookDelivery::query()->firstOrCreate(['provider' => 'stripe', 'provider_event_id' => $event->id]);
if ($delivery->wasRecentlyCreated) {
ProcessStripeEvent::dispatch($delivery->getKey());
}
return response()->noContent();
}
}
Inspector versus a feature test
Use the inspector to compare raw headers, payload and response after a valid, forged and duplicate delivery. It is a diagnostic tool, never authentication. Most branches are faster and more reliable as a feature test; reserve ngrok for the provider-console and real-retry seam.
<?php
declare(strict_types=1);
it('rejects a forged Stripe signature', function (): void {
$this->postJson('/webhooks/stripe', ['id' => 'evt_test'], ['Stripe-Signature' => 't=1,v1=forged'])
->assertBadRequest();
});
When not to use ngrok
Do not treat a temporary tunnel as a production ingress, and do not use it when an official provider CLI can forward signed events more faithfully. Automated tests should use signed fixtures and replay paths without requiring an external tunnel.