optimistdigital/nova-menu-builder
A Laravel Nova package for building and managing menus with a drag-and-drop interface. Create nested menus, link to models or custom URLs, and localize menu items directly from Nova's admin panel.
Architecture fit. The package fills a narrow, well-bounded slot: it is an admin-side editor and persistence layer for editorial navigation (header, footer, legal, locale switcher, campaign items). It does not own rendering, routing semantics, or caching. That is the right shape for a TPM to sponsor, because the boundary is legible — the deliverable is "editors can change the header without a deploy," and the engineering surface downstream of the package is clearly ours. The critical architectural gate is Laravel Nova: without an existing Nova 4/5 license and panel, this package reduces to a migrations-and-models snippet and a bespoke admin screen is the cheaper path. Confirm Nova ownership before this appears on any roadmap.
Integration feasibility. Install is genuinely small: composer require, publish config and migrations, register the tool in NovaServiceProvider, migrate. The mental model lives in one config file (locales, the menus registry with slug/name/max_depth, and menu_item_types). Extension is via one class per concept extending the package's abstract base item type. Realistic effort for a smoke test is under an hour; realistic effort for production-ready integration is 3–5x install time, because the resolver, locale fallback chain, caching, cache invalidation, active-state logic, and broken-link filtering are all ours to build. That multiplier is the number that gets lost when the package is sold internally as "free," and it should be a named line item in the estimate.
Technical risk. The risks are concentrated and known, which is good, but they are not trivial:
optimistdigital/nova-menu-builder → outl1ne/nova-menu-builder, with a namespace change (OptimistDigital\MenuBuilder → Outl1ne\MenuBuilder). Copied snippets and older tutorials fatal on class-not-found. Pin the canonical name and an exact version.menus / menu_items are generic. Verify against a production-shaped staging database before the first migrate, and rename via config if needed. A half-applied migration on shared infrastructure is the day-one incident.data JSON column. Renaming or removing a custom item-type field silently orphans stored values. No exception fires; the editor shows empty and the site shows a broken link.Key questions.
de → en → first available), or is that still open?Stack fit. Server-rendered Laravel with Blade is the natural fit: a <x-site.menu slug="header" /> component wrapping our resolver. Inertia works via sharing the resolved array from HandleInertiaRequests. A decoupled frontend is a poor fit — the package ships no endpoint, so we build the resource layer anyway and the value proposition narrows considerably. Confirm the transitive dependency set (sibling Outl1ne packages, any nested-set implementation, translatability support) sits inside our supported Laravel/PHP matrix before committing.
Migration path. Two workstreams in parallel:
app/Menus/MenuResolver.php with a for(string $slug, ?string $locale = null): array signature before any view code exists. Views call the resolver; views never touch MenuItem. This converts abandonment risk from "rewrite the admin" into "swap one class," and it is roughly an hour of work now versus a sprint later.Resolve to plain arrays with a stable shape (label, url, active, children), never Eloquent models-with-relations. That stable array is also the only sane cache payload.
Compatibility. Pin exact versions in Composer (not ^) — there is no downstream ecosystem absorbing breakage here. Keep a CI job that boots the Nova panel against the next Nova major so an upgrade incompatibility surfaces in CI rather than as a blocked upgrade with a frozen menu editor. Grep the codebase for both vendor namespaces before trusting any copied snippet. Confirm the translatability dependency is in the matrix, since multi-locale fields are JSON under the hood and a "missing label" is usually a locale mismatch rather than a resolver bug.
Sequencing.
header menu in config; smoke test with a URL item, a linked-model item, and one nested child via tinker.MenuResolver + observer-driven cache invalidation + locale fallback + null filtering.menus.dynamic config flag with static nav as fallback for one clean release cycle.Maintenance. The package itself is low-churn; the maintenance burden is ours and lives in the resolver, the observers, and the custom item types. Two ongoing obligations: keep custom types thin (store an ID, resolve lazily at render, return null when the linked model is gone), and treat item-type data keys as versioned API — renaming a field requires a migration, not a shrug. Upgrades are the risk, not install. Name the fork owner now; MIT plus a shallow integration surface makes fork-and-own cheap, which is the real mitigation.
Support. Support load splits cleanly: editorial questions ("why doesn't my link show?") route to the resolver and the enabled/URL-null filters; engineering questions route to caching and locale fallback. Document the locale fallback chain explicitly — the package does not decide what happens when de has no header, so an undocumented chain becomes a recurring ticket. Authorize and gate the tool as a privileged action so support doesn't have to reason about who could have redirected the login link.
Scaling. Menu volume is trivial; the scaling concern is request-path cost and cache correctness, not data size. Use Cache::rememberForever() with a version-stamped key (menus:v{$version}:header:en) bumped by MenuObserver/MenuItemObserver on save and delete, rather than enumerating keys across locales. Always orderBy('order') on relations — ordering is per-parent, not global, and un-ordered relations make both the tree and the cache nondeterministic.
Failure modes. Ranked by likelihood and blast radius:
children. Recursion is our job unless the pinned version ships a recursive accessor; verify in MenuItem.php.onDelete('cascade') or a deleting hook in our own migration.data field. Filter to enabled && url !== null before caching so the frontend never serves dead links.getRules(), escape with {{ }}, add rel="noopener noreferrer" on target="_blank".Ramp-up. An engineer familiar with Laravel and Nova reaches a working smoke test in under an hour. Genuine productivity requires reading config/nova-menu-builder.php, then src/MenuItemTypes/ (better extension documentation than the README), then src/Models/Menu.php and MenuItem.php to learn the exact relation and scope names in the pinned version. Budget half a day for that orientation plus the resolver seam. The TPM-facing ramp-up is the framing: this package buys the admin UI and the schema; everything downstream — rendering, markup, ARIA, caching, invalidation, broken-link handling — is ours, and it is the majority of the work.
How can I help you explore Laravel packages today?