morcen/passage
Morcen Passage is a lightweight Laravel package that provides a clean, expressive API for defining and managing state transitions and workflows on your Eloquent models. Keep complex business logic organized, track changes with ease, and integrate seamlessly into any Laravel application.
Passage is a Laravel package for wrapping outbound HTTP integrations in first-class "handlers" (controller-like classes) with built-in retries, caching, signed requests, and event logging. Day-to-day, think of it as the boundary layer between your app and every third-party API you talk to.
Minimal setup:
composer require morcen/passage (v3.1.1 adds Laravel 12 support, so a fresh L12 app is fine).php artisan vendor:publish --tag=passage-config and set your upstream definitions (base_uri, allowed client headers, retry/cache defaults).php artisan passage:list before writing any code. It enumerates every registered handler your app knows about — this is your map of the integration surface and the fastest way to orient in an unfamiliar codebase.php artisan passage:controller ExternalApiController (or --with-retry if the upstream is flaky).First use case: replacing a raw Http::get(...) call scattered in a controller with a single handler class that centralizes the base URI, auth headers, retry policy, and caching — then swapping that call site for a handler invocation.
One handler per upstream service. Treat each handler as the owning class for one external API. It holds the base URI, default headers, and the retry/cache policy for that upstream. Everything else in the app should go through it rather than constructing HTTP calls directly.
Implement the interface, inject the abstraction. PassageControllerInterface (usage example added in this release) is the contract to type-hint against in your services. This keeps your app code testable and lets you swap a handler implementation without touching call sites. Prefer constructor injection over resolving handlers from the container by string.
Conditional retries with $when. The common production pattern is to retry only on transient failures — connection errors, 429, 5xx — and never on a 4xx that means your payload was wrong. Use the $when callback to encode that policy explicitly rather than accepting the default. Documented in this release, with a corrected @param signature, so check the docblock if you're on an older pin.
Scaffold, then harden. passage:controller --with-retry generates a handler already wired for retries. Treat the output as a starting point: review which verbs get retried, whether the backoff matches the upstream's rate-limit semantics, and whether idempotency keys are needed.
Signed upstreams (HMAC). The new HMAC controller stub gives you a starting shape for APIs that require request signing. Use it as a template for the signing header construction, then keep timestamps and secret retrieval in your own code — don't hardcode secrets into generated stubs.
Cache the reads, not the writes. PassageCacheManager is the right seam for memoizing GET responses that don't change often (reference data, config lookups). Key by handler + path + query so two handlers hitting the same URL don't collide. Never route a mutating request through the cache.
Test at the handler boundary. The release's test additions point at the patterns worth copying: assert on mixed-case client headers (HTTP headers are case-insensitive, and the package normalizes them), assert the response when a base URI is missing/invalid, and assert the retry callback fires the expected number of times for each failure class.
Observability via events. PassageEventSubscriber emits structured log lines per request/retry. In staging, tail these logs when a handler misbehaves — it's usually faster than adding dd() to the handler. If you already have a central log pipeline, subscribe to the same events and forward to your APM rather than duplicating logging in handlers.
passage:list is now resilient to broken handlers. That's a feature and a trap: a handler class that fails to resolve is skipped instead of blowing up the command. After adding a handler, re-run passage:list and confirm it's actually there — a typo in the namespace will now be quiet.$when excludes anything that isn't safe to replay, or add an idempotency key.base_uri joins are a classic source of doubled or missing slashes when the config value and the handler path both contribute a /. Keep one convention: no trailing slash in config.composer update and re-run your handler tests — the retry $when signature docblock was also corrected here, which is a hint people were getting the callback arity wrong.How can I help you explore Laravel packages today?