flyo/nitro-php-bridge
A lightweight PHP bridge for Flyo Nitro that lets Laravel and other PHP applications connect to the Nitro content API, fetch structured headless CMS content, and integrate it cleanly into templates, pages, and custom applications.
What this package is (and isn't). flyo/nitro-php-bridge is the transport/adapter layer between Flyo Cloud (the headless CMS) and a PHP backend. It is deliberately thin: authentication, HTTP calls against the Nitro API, and decoding responses into PHP structures. It is not a full CMS integration — routing, caching, view models, preview handling and error semantics are your job. Treat it as "an authenticated HTTP client with Flyo's schema baked in."
Minimal setup
composer require flyo/nitro-php-bridge
Then, in a Laravel app:
.env — typically a space/project identifier plus an API token (and a separate preview token if the Cloud project exposes drafts):FLYO_NITRO_TOKEN=...
FLYO_NITRO_SPACE=...
FLYO_NITRO_BASE_URI=https://api.flyo.cloud # confirm against README
config/nitro.php or similar) and only read env there, never in application code — otherwise php artisan config:cache will silently produce null tokens in production.$this->app->singleton(NitroClient::class, fn ($app) => new NitroClient(
token: config('nitro.token'),
baseUri: config('nitro.base_uri'),
));
Where to look first, in this order
README.md — the only reliable source for constructor signature and endpoint helpers.src/ root — the client class(es); skim public methods to see what endpoints are actually wrapped versus what you must call by hand.config/ and any *ServiceProvider — tells you whether it's Laravel-aware or framework-agnostic (the -php-bridge suffix suggests the latter, with framework packages layered on top).tests/ — the fastest way to learn intended usage; fixtures usually double as response-shape documentation.CHANGELOG / git tags — this package has essentially no public adoption, so version drift is your risk, not the community's.First use case to implement. The canonical flow is: a catch-all route resolves a slug → the bridge fetches the corresponding page/entity → you map it to a view model → render. Build that one vertical slice end-to-end before wiring anything else; it forces you to confront response shape, 404 semantics and caching immediately, which are the three things that will bite later.
Wrap it. Don't call it from controllers directly. Even at 0 stars, the API surface of a bridge package is the most likely thing to change under you. Introduce one application-level seam:
interface ContentSource
{
public function page(string $slug, ?string $locale = null): ?PageData;
public function collection(string $handle, array $params = []): Collection;
}
Implement it with the bridge, and inject ContentSource everywhere. Swapping to the raw Nitro API, a cache-backed decorator, or a different CMS then becomes a one-line container change.
Catch-all content routing. One route, one controller, resolver decides what to render:
Route::get('/{path?}', ContentController::class)->where('path', '.*');
The controller asks ContentSource for a page by path; null → abort(404). Keep the resolver dumb — no template-specific branching inside it. Map the returned structure into a typed view model (PageData, BlockData) so Blade views never touch raw bridge arrays. This is the single highest-value habit with headless CMS adapters: the raw response shape leaks into 40 templates and then you can never change it.
Caching belongs to you. The bridge will hit the network on every call. Wrap ContentSource in a decorator:
class CachedContentSource implements ContentSource
{
public function page(string $slug, ?string $locale = null): ?PageData
{
return Cache::remember(
"nitro:page:{$locale}:{$slug}",
now()->addMinutes(10),
fn () => $this->inner->page($slug, $locale),
);
}
}
Bind the decorator in the container, keep the inner implementation private, and expose a small nitro:flush artisan command (or listen for a Flyo webhook) to invalidate. Never cache null/404s for long — content that was just published will appear broken for the TTL duration.
Preview / draft mode. Model this as a second client instance rather than a boolean flag threaded through everything:
$this->app->singleton(ContentSource::class, fn ($app) => $app->bound('request') && $app['request']->hasValidPreviewCookie()
? new PreviewContentSource($app->make(PreviewNitroClient::class))
: new CachedContentSource($app->make(NitroContentSource::class)));
Preview bypasses the cache and uses the draft token; the public path never sees draft content. Gate the preview cookie behind auth middleware — a leaked preview token exposes unpublished content.
Batch and expand, don't loop. Remote CMS APIs are the classic N+1 trap. If the Nitro response supports relation expansion (?expand=/?include= style), use it. Iterating a list of 30 items and calling the bridge 30 times to resolve relationships will dominate your request time and trip rate limits.
Error mapping. Translate at the boundary: bridge/HTTP 404 → ModelNotFoundException (so Laravel renders 404), 5xx/timeouts → ContentUnavailableException → 503 with a cached fallback if you have one. Don't let a Guzzle exception surface from a controller.
Testing. Because you introduced ContentSource, tests bind a fake and never hit the network:
$this->swap(ContentSource::class, new InMemoryContentSource([...]));
Record one real response per content type as a fixture and let a single integration test (marked @group external) assert the bridge still parses it. That test is your early-warning system for upstream schema changes.
Locale handling. If the Cloud project is multi-locale, resolve locale once in middleware and pass it explicitly into ContentSource — never read app()->getLocale() deep inside the adapter, or cache keys and content will drift apart.
"flyo/nitro-php-bridge": "1.2.*", not ^1.2) and keep it behind your ContentSource seam. Budget for reading src/ when something breaks, because there's nobody else who has.composer show flyo/nitro-php-bridge --all). If it doesn't, you'll be on dev-main, which is a materially different risk posture and should be documented in your README.env() calls outside config/ return null once config:cache runs. If content mysteriously 401s in staging but works locally, this is the cause 90% of the time.null vs. exceptions. Adapters in this genre often return null/empty arrays on failure rather than throwing. Assert explicitly on the response shape in your adapter layer, or you'll ship a blank page instead of an alert.nullable-tolerant mapping layer with sensible defaults for optional blocks, so a new field appearing upstream (or a field being emptied by an editor) doesn't throw a TypeError on a production page.How can I help you explore Laravel packages today?