forci/menu-builder-api-bundle
Symfony bundle that exposes API endpoints for the Forci Menu Builder, enabling programmatic creation, retrieval, update, and deletion of menus and menu items. Add a ready-made menu management API layer to your application.
First, establish what you actually installed. forci/menu-builder-api-bundle is packaged for Symfony (type: symfony-bundle, registered in config/bundles.php). There is no Laravel service provider, facade, config stub, or publishable migration set in the naming or metadata. composer require may resolve, but nothing will boot in app/. Treat the install step as "stand this up in a scratch Symfony app," then consume it over HTTP.
Read these four things, in this order, before writing any Laravel code:
composer.json — type, the require block (expect symfony/framework-bundle, likely doctrine/orm, symfony/serializer), PSR-4 namespace, and the license field.src/DependencyInjection/Configuration.php — the authoritative list of config keys. Trust this over the README.src/Controller/ — the actual HTTP surface: route paths, methods, request/response shapes.Resources/config/routes.* — how routes are imported and under what prefix.Minimal standalone bring-up (throwaway Symfony skeleton, ~30 minutes):
composer create-project symfony/skeleton menu-api
cd menu-api
composer require forci/menu-builder-api-bundle
# register in config/bundles.php, add the routes import, configure the DB
php bin/console debug:router | grep -i menu
php bin/console debug:container --tag=controller
Hit the endpoints with curl and save the raw JSON to a fixture file. That fixture is now your contract, and it survives even if you later decide not to adopt the package.
First use case to target: GET the menu tree by handle/slug, parse it into a flat [id => item] map plus a children index, and render one Blade partial from it. Do not start with CRUD, ordering, or role scoping — prove the read path first, because that's the path that runs on every page render.
HTTP client wrapper, not a dependency. Put the menu API behind one small class bound in the container, and never call Http:: from controllers or Blade:
final class MenuApiClient
{
public function __construct(private Client $http, private string $baseUrl, private string $token) {}
public function tree(string $handle): array
{
return $this->http->withToken($this->token)
->timeout(2)
->retry(2, 200)
->get("{$this->baseUrl}/api/menus/{$handle}")
->throw()
->json('data', []);
}
}
Everything downstream — caching, fallbacks, DTO hydration — hangs off this one seam. If you later swap the Symfony service for a Laravel-native implementation, you change one file.
Cache aggressively; menus are read-hot and write-cold. Wrap MenuApiClient::tree() in Cache::remember("menu:{$handle}", 3600, ...) and bust the key from a webhook or an admin action. This also decouples your page latency from the availability of the Symfony service — a menu outage should degrade to a stale tree, not a 500.
Normalize to a shape your views own. Don't leak the remote payload into Blade. Convert once:
$flat = collect($tree['items'])->keyBy('id');
$roots = $flat->whereNull('parent_id')->sortBy('position');
$children = $flat->groupBy('parent_id');
Then a single recursive Blade partial renders from $children. Keep the normalizer as a plain class with unit tests against the saved fixture — that's your schema-drift alarm.
Contract tests, not integration tests. Snapshot the JSON fixture and assert key names, nesting shape, and ordering field. If the remote contract shifts, the fixture diff tells you before a client does. If the package ships no versioned spec, generate one yourself from a few captured responses and treat it as the source of truth.
Run it as a sidecar, not inline. The realistic production topology is a container running the Symfony app plus its database, with Laravel as an HTTP consumer. That means: own the network policy, put the API behind the same auth as your other internal services, and pin the image digest so a composer update upstream can't silently change behavior under you.
Local dev parity. Add a menu-api service to docker-compose.yml (or Sail equivalent) so php artisan serve and the API come up together. Without this, every developer needs their own Symfony checkout — which nobody will maintain.
If you go the extraction route: check whether a framework-agnostic forci/menu-builder core exists. If it does, port the data model and ordering logic to Eloquent (adjacency list is usually enough; nested sets only if you need deep subtree moves), and reimplement the controllers as Laravel routes. You keep the domain semantics and drop the Symfony wiring.
The license is NOASSERTION. This is a hard stop, not a footnote. Read LICENSE and the composer.json license key literally and get it cleared before any code ships to production. Do not assume MIT because the ecosystem usually is.
Zero stars, zero dependents means you are the maintainer. There is no upstream triage, no security channel, no Stack Overflow corpus. Budget for reading source when something breaks, and for being the one who patches it. If that isn't acceptable, the honest alternative is a few days building a Laravel-native menu module — the requirement is small and well-trodden.
It will not boot in Laravel, and Composer won't stop you. No service provider, no config/menu-builder.php stub, no migrations. Symfony's Bundle class and DI extension are inert under Laravel's container. Any "it installed fine" moment is misleading.
Schema ownership must be assigned explicitly. If the API is Doctrine-backed, its migrations are not Laravel migrations. Either the sidecar owns its tables entirely (and Laravel never touches them), or you're looking at double-migration drift. Decide before the first deploy.
Depth and breadth are the scaling cliff. Menu trees are cheap until they're not: recursive serialization is a classic N+1, and deep trees plus per-node permission filtering get expensive fast. Benchmark the read path with realistic depth and breadth early — it's the hot path on every render.
Ordering persistence is the question the README won't answer. Find out whether ordering is a numeric position column, a linked list, or materialized path. It determines how you handle drag-and-drop reordering upstream and whether your cache invalidation can be granular or must be whole-tree.
Debugging without the Symfony profiler is painful. When a response looks wrong, log the raw body before hydration, keep the fixture file current, and add a ?debug=1-style raw passthrough on your wrapper during development. Don't debug through your normalizer.
Extension points live on the Symfony side. Serialization shape is almost certainly controlled by serializer normalizers/groups, and behavior changes by event subscribers. If you genuinely need a different payload, the fastest path is usually a small fork (vendored into your own repo, pinned) rather than a wrapper that post-processes JSON.
Version-pin and treat the API as untrusted input. Pin the image digest or the fork's commit. Validate and type-check the response before it reaches a view; an unmaintained REST surface is a boundary, and auth belongs at your edge, not in the bundle's config.
Test the fallback path explicitly. Timeouts, 5xx, empty payload, and malformed JSON should all resolve to a cached tree or a minimal static nav — never a broken page.
How can I help you explore Laravel packages today?