tomatophp/tomato-menus-module
A Tomato PHP module for Laravel that adds menu management to your app, letting you create, organize, and render navigation menus with a flexible builder and easy integration into Tomato admin panels and frontends.
Install the module and let Laravel's package discovery register its service provider:
composer require tomatophp/tomato-menus-module
php artisan vendor:publish --tag="tomato-menus-module-config"
php artisan vendor:publish --tag="tomato-menus-module-migrations"
php artisan migrate
If you are running the wider Tomato admin stack, the module expects tomato-admin (or the Filament plugin variant) to be installed and its auth guard configured first — install order matters, because the module's routes and resources register against the panel that is already booted.
Look here first, in this order:
vendor/tomatophp/tomato-menus-module/database/migrations/ — the schema tells you more about the package than the README. You want to see the menus and menu_items tables: the key/slug column on the parent and the parent_id, type, order columns on the child.src/Models/Menu.php and src/Models/MenuItem.php — relationships (items, children), scopes, casts, and any HasTranslations trait usage.config/tomato-menus.php — cache toggle, route prefix, middleware, and the item-type map.resources/lang/ and resources/views/ — the renderables and the strings the module ships.First use case — a header menu you can actually ship:
Create a menu record with a stable key (header, footer, sidebar), attach items in the admin UI, then render it from your layout. Resist hardcoding a menu ID anywhere: resolve by key so the menu survives DB refreshes between environments.
{{-- resources/views/layouts/app.blade.php --}}
@include('tomato-menus::render', ['menu' => menus('header')])
or, if you prefer to own the markup, fetch the tree once and build a recursive partial over $menu->items filtered to parent_id === null.
Two-table adjacency list. The package models menus as a named container plus a self-referencing item tree. Treat parent_id + order as the source of truth for structure; never derive order from id or created_at, because drag-and-drop reordering rewrites order in bulk.
Resolve links late, not at save time. Store the reference (type = route / url / page / morph model + id), and resolve the actual href at render time. If you store a resolved URL, every slug change on the target page silently leaves a dead link in the menu. A small resolver keeps this honest:
public function resolveUrl(): string
{
return match ($this->type) {
'route' => Route::has($this->route) ? route($this->route, $this->params ?? []) : '#',
'model' => optional($this->model)->url() ?? '#',
default => $this->url ?: '#',
};
}
Cache the rendered tree, keyed by menu key. Menus are read on every page and written once a month — the ideal cache candidate. Wrap the tree build in Cache::remember("menu:{$key}", $ttl, ...) and flush from model events (saved, deleted) on both Menu and MenuItem.
Blade component + view composer. For multi-menu layouts, a view composer on layouts.* that shares $headerMenu, $footerMenu keeps controllers clean and guarantees the tree is built once per request, not per @include.
Seed menus in a seeder, manage items in the UI. Ship MenuSeeder that calls firstOrCreate(['key' => 'header']). Items are editorial content; don't seed them outside local/demo environments, or a deploy will resurrect items an editor deliberately deleted.
Custom item types via config. Most Tomato modules expose a type map in config/tomato-menus.php. Register your own type class there rather than forking vendor views — that is the supported extension point, and it survives composer update.
Testing. Use RefreshDatabase, build a three-level tree in a factory, and assert flattened order plus resolved URLs. Menu bugs are almost always ordering or nesting bugs, and they are cheap to pin down in a test:
$menu->items()->saveMany([
MenuItem::factory()->make(['order' => 1]),
$child = MenuItem::factory()->make(['order' => 2, 'parent_id' => 1]),
]);
$this->assertSame([1, 2], $menu->fresh()->flattenedItems()->pluck('order')->all());
v1.0.2 is a translation-only release. The single change is a lang-file update from a first-time contributor. Nothing in it touches schema, routes, or public APIs, so it is safe to bump. But: if you have published the module's lang files (vendor:publish --tag=...translations), Laravel now loads your copy, not vendor's — you will not see any of the new strings until you re-publish or manually diff vendor/tomatophp/tomato-menus-module/resources/lang against lang/vendor/tomato-menus-module. This class of release is exactly where stale published assets go unnoticed for months.
Treat published lang files as a tracked fork. Publishing translations flips the module from "upgrade and get fixes" to "merge by hand forever." If you only need one or two keys overridden, prefer a small project-level lang file over publishing the whole set.
Bulk reorder may skip model events. Drag-and-drop reordering frequently persists via a query builder loop (MenuItem::where(...)->update(['order' => $i])), which does not fire saved. Cache invalidation hooked only to model events will leave a stale tree. Flush the menu cache explicitly at the end of the reorder handler.
N+1 in recursive rendering. Rendering $item->children inside a partial triggers a query per level. Eager-load the whole subtree in one go (->with('childrenRecursive') or build the tree from a single flat query) and pass the assembled array to the view.
parent_id deletion semantics. Check the migration — if the FK is restrictive, deleting a parent with children throws; if it cascades, deleting a parent silently wipes its subtree. Neither is obviously right; decide per project and enforce it in the admin policy.
Route caching breaks dynamic links. If menu items store route names with parameters, php artisan route:cache will throw on missing params at render time. Always guard resolution with Route::has() and a try/catch, falling back to # rather than 500-ing the whole layout.
Locale fallback shows blanks, not fallbacks. With spatie/laravel-translatable, an empty string is a valid translation and will not fall back to the default locale. Editors who "clear" a field to retranslate produce empty menu labels. Normalise '' to null on save.
Route/prefix collisions. The module registers admin routes under a configurable prefix. If your app already owns that prefix (or you run two Tomato modules with clashing route names), you get silent 404s on the admin screen only — check php artisan route:list --path=menus first when the UI misbehaves.
Pin to the Tomato core version you actually run. These modules move in lockstep with tomato-admin/Filament. A patch bump to the module is low-risk, but a minor bump can require a matching core version — read the composer.json constraints, not just the changelog.
Debugging checklist: Menu::with('items')->where('key','header')->first()->toArray() to confirm structure; DB::listen() to catch the N+1; Cache::forget("menu:header") when the UI and page disagree; and php artisan vendor:publish --force only after committing, since it overwrites your customised views.
How can I help you explore Laravel packages today?