morcen/passage is a Laravel package for api gateway for laravel.
It currently has 54 GitHub stars and 29 downloads on Packagist (latest version v3.0.0).
Install it with composer require morcen/passage.
Discover more Laravel packages by morcen
or browse all Laravel packages to compare alternatives.
Last updated
Passage is a lightweight API gateway package for Laravel that proxies incoming requests to external services. It gives you per-route control over HTTP method, path, request transformation, and response transformation — using a routing syntax that mirrors Laravel's own.
Passage is for Laravel apps that need to sit in front of one or more external APIs and expose them through your own application routes.
It is especially useful when you want to:
In practice, Passage helps when your app needs a controlled proxy layer, not a full API management platform. You define proxy routes like normal Laravel routes, then customize how each request is forwarded and how each upstream response is returned.
If you are building a Laravel app that needs to expose a stable, app-owned endpoint in front of external APIs, Passage gives you a lightweight and Laravel-native way to do that.
If you want to see how this maps to real projects, read the example scenarios.
Passage::get/post/... APIroute:listpassage:healthUpgrading from v2? v3.0.0 is a breaking release. The config-based
servicesarray andRoute::passage()macro have been removed. See the Upgrading from v2 section below.
On v1.x? PHP 8.1 and Laravel 10.x are no longer supported as of v2.0.0. Use v1.2.4 for older environments.
composer require morcen/passage
Then publish the config file:
php artisan passage:install
To publish the controller stub for generating Passage handlers:
php artisan vendor:publish --tag=passage-stubs
Passage routes are defined in your route files (e.g. routes/web.php) using the Passage facade. The syntax mirrors Laravel's own routing:
use Morcen\Passage\Facades\Passage;
Passage::get('github/{path?}', GithubPassageController::class);
Passage::post('stripe/{path?}', StripePassageController::class);
Passage::any('payments/{path?}', PaymentsPassageController::class);
Each call registers a real Laravel route, so your proxy routes appear in php artisan route:list alongside your application's own routes.
The {path?} parameter captures the sub-path that is forwarded to the upstream service. For example:
GET /github/users/morcen → GET https://api.github.com/users/morcen
POST /stripe/charges → POST https://api.stripe.com/charges
All supported methods: get, post, put, patch, delete, any.
Passage routes work inside any Laravel route group:
Route::prefix('v1')->middleware('auth')->group(function () {
Passage::get('github/{path?}', GithubPassageController::class);
Passage::post('stripe/{path?}', StripePassageController::class);
});
Named routes and other route chaining also work:
Passage::get('github/{path?}', GithubPassageController::class)
->name('github.proxy')
->middleware('throttle:60,1');
Every Passage route requires a handler class. Generate one with:
php artisan passage:controller GithubPassageController
This creates app/Http/Controllers/Passages/GithubPassageController.php extending PassageHandler:
use Morcen\Passage\PassageHandler;
class GithubPassageController extends PassageHandler
{
public function getOptions(): array
{
return [
'base_uri' => 'https://api.github.com/',
];
}
}
You only need to override the methods relevant to your handler. All three interface methods have no-op defaults in PassageHandler:
getOptions(): array — upstream base URI and any Guzzle optionsgetRequest(Request $request): Request — transform or add credentials before forwardinggetResponse(Request $request, Response $response): Response — transform the upstream responseIf you prefer to implement the interface directly without the base class, implement PassageControllerInterface instead.
Note: The
base_urimust end with a trailing slash/, otherwise sub-path forwarding may not work correctly.
Timeout and connection settings that apply to all Passage routes can be configured in config/passage.php or via environment variables:
PASSAGE_TIMEOUT=30
PASSAGE_CONNECT_TIMEOUT=10
Options defined in a handler's getOptions() override these global defaults.
php artisan passage:list
Displays a table of all registered Passage routes with their HTTP methods, URIs, and upstream targets.
Set PASSAGE_ENABLED=false in your .env to disable all Passage proxying without removing route definitions:
PASSAGE_ENABLED=false
Passage strips sensitive client-origin headers before forwarding requests upstream. By default, cookie, authorization, and proxy-authorization are removed from every incoming request. This prevents client credentials from leaking to upstream services.
Handlers can re-add credentials from your own config inside getRequest():
public function getRequest(Request $request): Request
{
$request->headers->set('Authorization', 'Bearer '.config('services.github.token'));
return $request;
}
To change which headers are stripped globally, edit config/passage.php:
'security' => [
'strip_client_headers' => ['cookie', 'authorization', 'proxy-authorization'],
],
If a route legitimately needs to forward a specific client header (for example, forwarding a client's Authorization to an upstream that validates it), implement AcceptsClientHeaders on the handler:
use Morcen\Passage\Contracts\AcceptsClientHeaders;
use Morcen\Passage\PassageHandler;
class RelayHandler extends PassageHandler implements AcceptsClientHeaders
{
public function allowedClientHeaders(): array
{
return ['authorization'];
}
public function getOptions(): array
{
return ['base_uri' => 'https://api.partner.com/'];
}
}
The listed headers bypass the strip policy only for that handler. All other handlers continue to strip them.
To prevent a misconfigured handler from proxying to an unintended host, enable the allowed hosts guard:
PASSAGE_ENFORCE_ALLOWED_HOSTS=true
Then list the permitted upstream hostnames in config/passage.php:
'security' => [
'enforce_allowed_hosts' => true,
'allowed_hosts' => ['api.github.com', 'api.stripe.com'],
],
Any handler whose base_uri resolves to a host not in the list will throw DisallowedProxyTargetException instead of forwarding the request.
To abort a request early with a specific HTTP status, throw PassageRequestAbortedException inside getRequest():
use Morcen\Passage\Exceptions\PassageRequestAbortedException;
public function getRequest(Request $request): Request
{
if (! $this->isAllowed($request)) {
throw new PassageRequestAbortedException('Access denied.', 403);
}
return $request;
}
Passage catches this exception and returns a JSON error response with the given status code.
To validate the incoming request before it is forwarded, implement ValidatesInboundRequest and declare Laravel validation rules:
use Morcen\Passage\Contracts\ValidatesInboundRequest;
use Morcen\Passage\PassageHandler;
class CreateOrderHandler extends PassageHandler implements ValidatesInboundRequest
{
public function rules(): array
{
return [
'product_id' => ['required', 'integer'],
'quantity' => ['required', 'integer', 'min:1'],
];
}
public function getOptions(): array
{
return ['base_uri' => 'https://orders.example.com/'];
}
}
Validation runs before getRequest(). If it fails, a 422 response is returned and the upstream is never called.
Passage routes are real Laravel routes, so the built-in throttle middleware works directly:
Passage::post('orders/{path?}', CreateOrderHandler::class)
->middleware('throttle:60,1');
PassageHandler includes three built-in auth traits. Use them inside getRequest() to inject credentials:
use Morcen\Passage\PassageHandler;
class GithubHandler extends PassageHandler
{
public function getRequest(Request $request): Request
{
return $this->withBearerToken($request, config('services.github.token'));
}
public function getOptions(): array
{
return ['base_uri' => 'https://api.github.com/'];
}
}
Generate a handler pre-scaffolded for Bearer auth:
php artisan passage:controller GithubHandler --with-auth=bearer
public function getRequest(Request $request): Request
{
// Inject as a header (default: X-API-Key)
return $this->withApiKey($request, config('services.stripe.key'));
// Or inject as a query parameter
return $this->withApiKeyQuery($request, config('services.stripe.key'), 'api_key');
// Or use a custom header name
return $this->withApiKey($request, config('services.stripe.key'), 'X-Stripe-Key');
}
php artisan passage:controller StripeHandler --with-auth=apikey
public function getRequest(Request $request): Request
{
return $this->withHmacSignature($request, config('services.partner.secret'));
}
This signs the request body and a timestamp using HMAC-SHA256 and adds X-Timestamp and X-Signature headers to the outgoing request.
php artisan passage:controller PartnerHandler --with-auth=hmac
Add automatic retry by returning passage_retry_times (and optionally passage_retry_sleep_ms) from getOptions(), or use the withRetry() helper from HasResilienceOptions:
use Morcen\Passage\PassageHandler;
class PaymentsHandler extends PassageHandler
{
public function getOptions(): array
{
return array_merge(
['base_uri' => 'https://payments.example.com/'],
$this->withRetry(3, 200),
);
}
}
php artisan passage:controller PaymentsHandler --with-retry
withRetry($times, $sleepMs, ?callable $when) accepts an optional third argument — a callable that receives the exception and response and returns true if the request should be retried:
$this->withRetry(
times: 3,
sleepMs: 200,
when: function (\Exception $e, \Illuminate\Http\Client\Response $response) {
// Only retry on connection errors, not on 4xx responses
return $e instanceof \Illuminate\Http\Client\ConnectionException;
}
)
Passage maps transport-layer failures to appropriate HTTP status codes automatically:
| Cause | Status | |---|---| | Connection refused / DNS failure | 502 Bad Gateway | | Timeout | 504 Gateway Timeout | | Too many redirects | 502 Bad Gateway | | Unexpected exception | 500 Internal Server Error |
Upstream 4xx and 5xx responses are passed through unchanged.
GET and HEAD responses can be cached per-route. Return passage_cache_ttl (seconds) from getOptions():
public function getOptions(): array
{
return [
'base_uri' => 'https://api.example.com/',
'passage_cache_ttl' => 60,
];
}
php artisan passage:controller ExampleHandler --with-cache
The cache store used defaults to Laravel's default cache driver. To use a specific store, set it in config/passage.php:
'cache' => [
'store' => 'redis',
],
Or via environment variable:
PASSAGE_CACHE_STORE=redis
For large or long-running upstream responses, enable streaming so Passage does not buffer the full response body in memory:
public function getOptions(): array
{
return [
'base_uri' => 'https://files.example.com/',
'passage_streaming' => true,
];
}
When streaming is enabled, the getResponse() transformation hook is skipped (the response body has not been read yet). The Content-Type and other upstream headers are still passed through.
Passage fires three Laravel events around every proxy call:
| Event | When |
|---|---|
| PassageRequestSending | Before the upstream call |
| PassageResponseReceived | After a successful response |
| PassageRequestFailed | After a transport error |
To log all Passage activity, register PassageEventSubscriber in your EventServiceProvider:
use Morcen\Passage\Listeners\PassageEventSubscriber;
protected $subscribe = [
PassageEventSubscriber::class,
];
This subscriber logs to a passage channel at info level (request/response) and error level (failures).
To disable events:
PASSAGE_EVENTS=false
Ping the base_uri of every registered Passage route and see connectivity status:
php artisan passage:health
Useful in CI pipelines and post-deployment checks. Use --timeout=10 to adjust the per-route probe timeout (default: 5 seconds).
Before deploying Passage in production:
PASSAGE_ENFORCE_ALLOWED_HOSTS=true and list all permitted upstream hosts in config/passage.phpcookie, authorization) are NOT being forwarded unless intentional (check strip_client_headers)throttle middleware to any publicly accessible Passage routesPASSAGE_TIMEOUT and PASSAGE_CONNECT_TIMEOUT appropriate for your upstream services (default: 30s / 10s)passage_retry_times) for routes calling unreliable upstream servicespassage_cache_ttl) for high-traffic read-only routesPassageEventSubscriber and configure a passage log channel for observabilityphp artisan passage:health as a post-deployment checkv3.0.0 is a breaking release. If you are on v2 and are not ready to migrate, pin your version in composer.json:
"morcen/passage": "^2.0"
| v2 | v3 |
|----|----|
| config/passage.php services array | Removed — routes are defined in route files |
| Route::passage() in routes/web.php | Removed — use Passage::get/post/... instead |
| Array-based handlers (['base_uri' => '...']) | Removed — a handler class is always required |
1. Remove Route::passage() from your route files.
2. For each entry in config/passage.php services:
If the entry was an array:
// v2 config/passage.php
'github' => ['base_uri' => 'https://api.github.com/'],
Create a handler class (or use passage:controller) and move base_uri into getOptions():
// v3 app/Http/Controllers/Passages/GithubPassageController.php
public function getOptions(): array
{
return ['base_uri' => 'https://api.github.com/'];
}
If the entry was already a controller class, it can be reused as-is — just make sure it implements PassageControllerInterface.
3. Register routes in your route files:
// v3 routes/web.php
use Morcen\Passage\Facades\Passage;
Passage::get('github/{path?}', GithubPassageController::class);
4. Remove the services key from config/passage.php (or re-publish the config with php artisan vendor:publish --tag=passage-config --force).
composer test
Please 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.