morcen / passage
API gateway for Laravel
Requires
- php: ^8.2
- illuminate/contracts: ^11.0|^12.0
- spatie/laravel-package-tools: ^1.16.0
Requires (Dev)
- laravel/pint: ^1.19.0
- nunomaduro/collision: ^8.0
- orchestra/testbench: ^9.0
- pestphp/pest: ^2.35.0|^3.0
- pestphp/pest-plugin-laravel: ^2.4.0|^3.0
- spatie/laravel-ray: ^1.37.0
- dev-main
- v3.0.0
- v2.0.0
- v1.2.4
- v1.2.3
- v0.2.1
- v0.2.0
- v0.1.0
- dev-feature/phase-4-improvement
- dev-feature/phase-3-improvement
- dev-feature/phase-2-improvement
- dev-feature/phase-1-improvement
- dev-dependabot/github_actions/dependabot/fetch-metadata-3.0.0
- dev-feature/v3.0.0
- dev-feature/add-passage-controller-test
- dev-fix/tests/fix-test-matrix-for-php8.3
- dev-v1.2.4-r1
This package is auto-updated.
Last update: 2026-04-04 14:13:52 UTC
README
Introduction
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.
Why developers use Passage
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:
- Keep frontend or client apps talking to your Laravel app instead of directly to third-party APIs
- Centralize headers, tokens, request shaping, and response shaping in one place
- Add Laravel middleware, route groups, authentication, or rate limiting around upstream API calls
- Hide upstream API structure from consumers so you can change providers later with less surface-area impact
- Build a thin backend-for-frontend layer without writing the same HTTP plumbing over and over
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.
Passage is a good fit when
- You need a simple API proxy inside an existing Laravel app
- You want route-level control over how requests are forwarded
- You need to inject auth credentials or normalize payloads before calling an upstream service
- You want to reuse Laravel's routing and middleware system instead of introducing a separate gateway product
Passage is probably not the right fit when
- Your app calls external APIs only from internal service classes or jobs and does not need inbound proxy routes
- You need a full enterprise API gateway with dashboards, analytics, service discovery, advanced policies, or traffic orchestration
- You need complex multi-service aggregation, retries, circuit breakers, or workflow logic as a first-class feature
- You want a general-purpose HTTP client wrapper rather than a request proxy layer
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.
Features
- Route-based proxy definitions using a familiar
Passage::get/post/...API - Per-route request and response transformation hooks
- Global and per-handler Guzzle options (timeout, headers, etc.)
- Works naturally with Laravel route groups, middleware, named routes, and
route:list - Secure by default: sensitive client headers stripped before forwarding
- Auth helper traits: Bearer token, API key, HMAC signing
- Inbound request validation via Laravel's built-in validator
- Response caching for GET/HEAD routes
- Automatic retry with configurable backoff
- Streaming response support for large payloads
- Laravel event hooks around every proxy call
- Connectivity health check via
passage:health
Requirements
- PHP 8.2 or higher
- Laravel 11.x or 12.x
Upgrading 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.
Installation
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
Usage
Defining proxy routes
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.
Route groups
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');
Creating a Passage handler
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 response
If 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.
Global options
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.
Listing proxy routes
php artisan passage:list
Displays a table of all registered Passage routes with their HTTP methods, URIs, and upstream targets.
Disabling Passage
Set PASSAGE_ENABLED=false in your .env to disable all Passage proxying without removing route definitions:
PASSAGE_ENABLED=false
Security
Header stripping
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'], ],
Forwarding a client header on a specific route
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.
Allowed hosts guard
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.
Aborting a request from a handler
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.
Inbound validation
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.
Rate limiting
Passage routes are real Laravel routes, so the built-in throttle middleware works directly:
Passage::post('orders/{path?}', CreateOrderHandler::class) ->middleware('throttle:60,1');
Auth helpers
PassageHandler includes three built-in auth traits. Use them inside getRequest() to inject credentials:
Bearer token
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
API key
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
HMAC signing
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
Resilience
Retry
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.
Upstream error handling
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.
Caching
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
Streaming
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.
Observability
Events
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
Health check
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).
Production checklist
Before deploying Passage in production:
- Set
PASSAGE_ENFORCE_ALLOWED_HOSTS=trueand list all permitted upstream hosts inconfig/passage.php - Confirm that sensitive headers (
cookie,authorization) are NOT being forwarded unless intentional (checkstrip_client_headers) - Apply
throttlemiddleware to any publicly accessible Passage routes - Set
PASSAGE_TIMEOUTandPASSAGE_CONNECT_TIMEOUTappropriate for your upstream services (default: 30s / 10s) - Enable retry (
passage_retry_times) for routes calling unreliable upstream services - Enable caching (
passage_cache_ttl) for high-traffic read-only routes - Register
PassageEventSubscriberand configure apassagelog channel for observability - Run
php artisan passage:healthas a post-deployment check
Upgrading from v2
v3.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"
What changed
| 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 |
Migration steps
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).
Testing
composer test
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). Please see License File for more information.