forci/menu-builder-client-bundle
A Symfony bundle that integrates the Forci menu builder client into your application, providing ready-made services, dependency injection wiring and configuration options for building, managing and rendering menus within the Symfony framework.
This is a Symfony bundle, not a Laravel package. Every "zero-config" claim in the description applies to a Symfony kernel — config/bundles.php registration, a Configuration tree, and autowired services. In Laravel there is no bundle mechanism, so "getting started" splits into two honest paths: (1) you're integrating into a Symfony app, or (2) you're using this as reference code to build a Laravel client. Assume the second until you've read the source.
For a Symfony app:
composer require forci/menu-builder-client-bundleconfig/bundles.php if Flex hasn't already:
Forci\MenuBuilderClientBundle\ForciMenuBuilderClientBundle::class => ['all' => true],
config/packages/forci_menu_builder_client.yaml — typically base URI, API key/token, timeout, and any cache service.For a Laravel dev, the first real step is a source audit, not composer require. Clone the repo and read, in this order:
composer.json — check if it requires symfony/framework-bundle or symfony/twig-bundle. If yes, it is not portable; reimplement.DependencyInjection/ — tells you exactly which services are wired and with what config. This is effectively your config schema for a Laravel translation.Client/ or Service/ (whatever the HTTP layer is called) — this is the part worth reusing. If it's PSR-18/PSR-7 with no container or Twig coupling, it ports cleanly as a package under app/Services/MenuBuilder/.The first use case in either framing is the same: fetch a named menu (e.g. main-nav) for a locale, render it as a nested <ul>/Blade recursive partial, and cache the raw response. Build that vertical slice before anything else; it will force you to answer the questions that matter — auth shape, error semantics, tree depth, and idempotency.
Bind once, inject everywhere. In Symfony the bundle already registers the client as a service; consumers autowire it. When porting to Laravel, mirror that with a singleton binding in a ServiceProvider:
$this->app->singleton(MenuBuilderClient::class, fn ($app) => new MenuBuilderClient(
http: $app->make(\Illuminate\Http\Client\Factory::class),
baseUrl: config('menu-builder.base_url'),
token: config('menu-builder.token'),
timeout: config('menu-builder.timeout', 2),
));
Consumers should depend on an interface you own (MenuRepository / MenuProvider), never on the vendor class directly. That's the swap-out seam if the vendor disappears.
Cache, don't cache-per-request-forever. Menus are highly cacheable and should almost never be fetched on the hot render path. The pattern that holds up:
public function get(string $slug, string $locale): Menu
{
return Cache::remember("menu:{$slug}:{$locale}", now()->addMinutes(10), function () use ($slug, $locale) {
$raw = $this->client->fetch($slug, $locale);
return Menu::fromApi($raw);
});
}
Cache the raw response (or a serialized DTO), not the rendered HTML — otherwise you can't invalidate on locale switch or re-render with different templates. Invalidate via a webhook if the vendor offers one, otherwise short TTLs with jitter (e.g. 10m ± 60s) to avoid synchronized stampedes across app instances.
Wrap the fetch in a resilient boundary. A remote menu service in the render path is a latency and availability coupling. Every fetch should be:
Http::retry(1, 200).Treat empty results as anomalies. A menu returning [] should be logged and alerted, not silently rendered as an empty <ul>. On a vendor schema change, deserialization often "succeeds" into an empty collection — this is your canary.
Render with a recursive partial. Whatever the DTO shape, flatten or recurse — Blade makes this clean:
@foreach ($menu->items as $item)
<li>
<a href="{{ $item->url }}">{{ $item->label }}</a>
@if ($item->children)
<ul>@include('partials.menu-items', ['items' => $item->children])</ul>
@endif
</li>
@endforeach
Don't try to port the Twig extension; it won't survive the trip, and Blade recursion is trivial anyway.
Testing seam. The bundle advertises a PSR-compliant HTTP layer "easy to test." In Laravel, this maps to Http::fake() in feature tests and a MenuBuilderClientContract with a fake implementation for unit tests. Write one contract test against a recorded fixture of the real API response — this is your early-warning system for schema drift.
License is the first blocker, not a technical one. NOASSERTION in composer.json means no rights are explicitly granted — not even internal use, depending on legal's reading. Get this resolved in writing before any code lands in a product repo. This is a hard gate that outranks every other concern below.
You are the maintainer from day one. Zero stars, zero dependents, zero release activity means no one else has validated this in production. Assume no PHP/Symfony version bumps, no security patches, no issue triage. Vendor the code (fork + VCS/path repository in composer.json) rather than depending on Packagist, and assign a named owner.
"Bundle" is the tell. If a composer require pulls in symfony/framework-bundle or symfony/twig-bundle, the correct move is to reimplement — not to try to shoehorn it. The reusable surface is small (likely <200 lines of HTTP + DTO); writing it yourself removes the license question, the maintenance burden, and the framework mismatch in one go.
Configuration quirks to check in DependencyInjection/Configuration.php:
Failure modes and what to do about them:
Http::fake(['*' => Http::response([], 503)]) and asserting the page still renders nav.[].artisan hook, or use Cache::lock() single-flight so only one instance fetches on stampede.Extension points worth knowing. If the client is PSR-18-based (likely, given the description), you can swap the underlying HTTP client, decorate it with a caching decorator, or wrap it in a retry middleware — all without touching the vendor class. In Laravel, that means binding the vendor's PSR-18 client into the container and using Http::withClient() or a Psr18Client adapter. Look for constructor injection of Psr\Http\Client\ClientInterface in the source; if it's a hardcoded GuzzleHttp\Client inside a method, that's a portability ding.
Rollout tip. Ship behind a feature flag with the current nav as fallback, cut over to the remote source on a low-traffic page first, and watch latency p95 and error rate for a week before it touches the primary layout. Navigation is every page — the blast radius of a bad integration is the whole site.
How can I help you explore Laravel packages today?