Forward incoming HTTP requests to multiple destinations -- asynchronously, reliably, and with zero config overhead.
Some webhook providers only allow a single callback URL. This package sits behind that URL and fans the request out to as many targets as you need -- different servers, Slack, Discord, or any custom destination -- all processed through Laravel's queue system with automatic retries and failure logging.
- Async by default -- Requests are dispatched to a queue job so your response time stays fast.
- Multi-target fan-out -- Forward a single incoming request to one or many endpoints in parallel.
- Custom providers -- Ship your own delivery logic by implementing a single interface. Discord provider included.
- Events on every delivery --
WebhookSentandWebhookFailedevents let you build dashboards, alerts, or audit logs. - Automatic retries -- Configurable
triesand exponentialbackoffper queue job, with failure logging out of the box.
- Requirements
- Installation
- Quick Start
- Configuration Reference
- Usage
- Events
- Queue & Retry
- Error Handling & Logging
- Upgrade Guide (v1.x to v2.0)
- Testing
- Changelog
- Contributing
- Maintainers
- Security Vulnerabilities
- Credits
- License
- PHP 8.2 or higher
- Laravel 11.x or 12.x
Install the package via Composer:
composer require moneo/laravel-request-forwarderPublish the configuration file:
php artisan vendor:publish --tag="request-forwarder-config"1. Set your target URL in .env:
REQUEST_FORWARDER_DEFAULT_URL=https://your-target.com/webhook
2. Add the middleware to any route you want to forward:
Route::middleware('request-forwarder')
->post('/webhook', fn () => response()->json(['status' => 'ok']));That's it. Every request hitting /webhook is now forwarded to your target asynchronously via the queue.
After publishing, the config lives at config/request-forwarder.php:
return [
'default_webhook_group_name' => 'default',
'webhooks' => [
'default' => [
'targets' => [
[
'url' => env('REQUEST_FORWARDER_DEFAULT_URL', 'https://example.com/webhook'),
'method' => 'POST',
],
],
],
],
'timeout' => 30,
'queue_name' => env('REQUEST_FORWARDER_QUEUE', ''),
'queue_class' => Moneo\RequestForwarder\ProcessRequestForwarder::class,
'tries' => 3,
'backoff' => [5, 30, 60],
'log_failures' => true,
];Key-by-key breakdown:
default_webhook_group_name-- Which webhook group to use when the middleware is called without a parameter.webhooks-- A map of named groups. Each group contains atargetsarray. Every target needs at least aurl. Optional keys per target:method(defaultPOST),provider,headers,timeout.timeout-- Global HTTP timeout in seconds for outgoing requests. Can be overridden per target.queue_name-- The queue connection/name for async jobs. Leave empty to use Laravel's default queue.queue_class-- The job class used for dispatching. Override this if you need custom job logic.tries-- How many times a failed job is retried before being marked as permanently failed.backoff-- Seconds to wait between retries. Accepts a single integer or an array for progressive backoff.log_failures-- Whentrue, failed deliveries are written to your Laravel log.
Attach the request-forwarder middleware to any route. The middleware dispatches a queue job and lets the request continue normally -- your users see no delay.
// Forward using the default webhook group
Route::middleware('request-forwarder')
->post('/webhook', fn () => 'OK');
// Forward using a named webhook group
Route::middleware('request-forwarder:payments')
->post('/payments/webhook', fn () => 'OK');Use the RequestForwarder facade when you need more control:
use Moneo\RequestForwarder\Facades\RequestForwarder;
// Dispatch to queue (async)
RequestForwarder::sendAsync($request);
RequestForwarder::sendAsync($request, 'payments');
// Trigger immediately (sync) -- useful in jobs or artisan commands
RequestForwarder::triggerHooks('https://original-url.com/hook', ['key' => 'value']);
RequestForwarder::triggerHooks('https://original-url.com/hook', $data, 'payments');Define as many groups as you need. Each group is independently configurable:
'webhooks' => [
'default' => [
'targets' => [
['url' => 'https://primary-backend.com/hook', 'method' => 'POST'],
],
],
'payments' => [
'targets' => [
['url' => 'https://accounting.internal/stripe', 'method' => 'POST'],
[
'url' => 'https://discord.com/api/webhooks/...',
'method' => 'POST',
'provider' => \Moneo\RequestForwarder\Providers\Discord::class,
],
],
],
],Add authentication or custom headers to individual targets:
'targets' => [
[
'url' => 'https://api.partner.com/webhook',
'method' => 'POST',
'headers' => [
'Authorization' => 'Bearer your-api-token',
'X-Webhook-Secret' => 'shared-secret',
],
],
],Override the global timeout for slow endpoints:
'targets' => [
[
'url' => 'https://slow-service.example.com/hook',
'method' => 'POST',
'timeout' => 60,
],
],The default provider sends JSON over HTTP. Need a different format? Implement ProviderInterface:
use Illuminate\Http\Client\Factory;
use Illuminate\Http\Client\Response;
use Moneo\RequestForwarder\Providers\ProviderInterface;
class SlackProvider implements ProviderInterface
{
public function __construct(private readonly Factory $client)
{
}
public function send(string $url, array $params, array $webhook): Response
{
return $this->client
->timeout($webhook['timeout'] ?? 30)
->send('POST', $webhook['url'], [
'json' => [
'text' => "Webhook from {$url}\n```" . json_encode($params, JSON_PRETTY_PRINT) . '```',
],
]);
}
}Register your provider in the target config:
'targets' => [
[
'url' => 'https://hooks.slack.com/services/T.../B.../xxx',
'method' => 'POST',
'provider' => App\Webhooks\SlackProvider::class,
],
],The package validates that every provider class exists and implements ProviderInterface before instantiation. Providers are resolved through Laravel's container, so constructor injection works out of the box.
Every delivery attempt dispatches an event you can hook into:
WebhookSent -- Dispatched after a successful HTTP response (any status code).
string $sourceUrl-- The original incoming request URL.string $targetUrl-- The target the request was forwarded to.int $statusCode-- The HTTP status code returned by the target.
WebhookFailed -- Dispatched when the delivery throws any exception.
string $sourceUrl-- The original incoming request URL.string $targetUrl-- The target that failed.\Throwable $exception-- The exception that was caught.
Example listener:
use Moneo\RequestForwarder\Events\WebhookSent;
use Moneo\RequestForwarder\Events\WebhookFailed;
// In a service provider or event subscriber
Event::listen(WebhookSent::class, function (WebhookSent $event) {
logger()->info("Forwarded to {$event->targetUrl}", [
'status' => $event->statusCode,
]);
});
Event::listen(WebhookFailed::class, function (WebhookFailed $event) {
logger()->error("Forward to {$event->targetUrl} failed", [
'error' => $event->exception->getMessage(),
]);
});The middleware dispatches a ProcessRequestForwarder job. Configure its behavior in the config:
'queue_name' => env('REQUEST_FORWARDER_QUEUE', 'webhooks'),
'tries' => 3,
'backoff' => [5, 30, 60], // wait 5s, then 30s, then 60s- If
queue_nameis empty, Laravel's default queue is used. triesandbackoffare validated at runtime; invalid values fall back to safe defaults (3tries,[5, 30, 60]backoff).
Custom job class: If you need to customize serialization, middleware, or tagging, extend the default job and point the config to your class:
// app/Jobs/CustomForwarder.php
class CustomForwarder extends \Moneo\RequestForwarder\ProcessRequestForwarder
{
public $timeout = 120;
public function tags(): array
{
return ['webhook-forwarder', "group:{$this->webhookName}"];
}
}// config/request-forwarder.php
'queue_class' => App\Jobs\CustomForwarder::class,The package is designed to never break your application flow:
-
Inside
triggerHooks: Each target is processed independently. If one target fails, the remaining targets still execute. Failed targets dispatch aWebhookFailedevent and (whenlog_failuresistrue) write an error to your Laravel log. -
Queue job failures: When all retries are exhausted, the job's
failed()method logs the final error with the source URL and webhook group name. -
Strict validation: Invalid URLs, unsupported HTTP methods, non-positive timeouts, non-existent provider classes, and malformed config shapes all throw
InvalidArgumentExceptioneagerly -- so misconfigurations surface during development, not in production.
- PHP
8.2+(was8.1+). - Laravel
11.xor12.x(Laravel 10 support dropped; it reached EOL in February 2025).
v2.0 fails fast on invalid configuration instead of silently ignoring it. Check your webhook groups:
targetsmust be a non-empty array.- Each target must have a valid
url(passesFILTER_VALIDATE_URL). methodmust be one of:GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS.timeoutmust be a positive number.headersmust be an array of string keys with scalar values.
- Custom providers must exist as classes and implement
ProviderInterface. - Invalid providers now trigger
WebhookFailedinstead of being silently skipped.
- Empty
queue_nameno longer forces an empty string -- it falls back to Laravel's default queue. triesandbackoffare validated and normalized at runtime.
WebhookFailed::$exceptionis now\Throwable(was\Exception).
- Update your dependency:
composer require moneo/laravel-request-forwarder:^2.0 - Re-publish the config:
php artisan vendor:publish --tag="request-forwarder-config" --force - Review your webhook groups against the validation rules above.
- Run your test suite and verify webhook flows in staging.
- Monitor logs for any
InvalidArgumentExceptionentries from the package.
# Run the test suite
composer test
# Run static analysis
composer analyse
# Fix code style
composer formatPlease see CHANGELOG for more information on what has changed recently.
Please see CONTRIBUTING for details.
Please review our security policy on how to report security vulnerabilities.
The MIT License (MIT). Please see License File for more information.

