Building Dynamic Menus in Filament + Laravel

Building dynamic navigation menus is a common requirement in modern web applications. Whether you’re creating a CMS, a SaaS dashboard, or a content-driven website, hardcoding menus quickly becomes difficult to maintain and scale.

In frameworks like Laravel, developers often rely on structured data and admin panels to manage application content. This is where Filament becomes extremely powerful, allowing you to build elegant admin interfaces with minimal effort.

In this guide, we will walk through how to create a fully dynamic menu and submenu system from scratch. Instead of relying on third-party solutions right away, we will first understand the core architecture by:

  • Designing the database structure for menus and nested items
  • Creating models and relationships to support submenus
  • Managing everything through Filament resources and relation managers
  • Preparing the data for frontend rendering

By the end of this article, you’ll have a solid foundation for building scalable navigation systems, and you’ll also be ready to explore more advanced approaches using dedicated packages in future steps.

Database Design

Lets create our model for Menu and MenuItems.

php artisan make:model Menu -m
Schema::create('menus', function (Blueprint $table) {
            $table->id();
            $table->string('name'); 
            $table->string('slug')->unique();
            $table->timestamps();
        });
php artisan make:model MenuItem -m
 Schema::create('menu_items', function (Blueprint $table) {
            $table->id();
            $table->foreignId('menu_id')->constrained()->cascadeOnDelete();
            $table->string('title');
            $table->string('url')->nullable(); 
            $table->string('type')->default('custom'); 
            $table->foreignId('parent_id')->nullable()->constrained('menu_items')->cascadeOnDelete();
            $table->integer('order')->default(0);
            $table->timestamps();
        });

Our Menu model

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Menu extends Model
{
    protected $fillable = ["name","slug"];

    public function items(): HasMany
    {
        return $this->hasMany(MenuItem::class);
    }
}

Our MenuItems model

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Cache;

class MenuItem extends Model
{
    protected $fillable = ['menu_id','title','url','type','parent_id','order'];

    public function parent():BelongsTo
    {
        return $this->belongsTo(self::class,'parent_id');
    }

    public function children(): HasMany
    {
        return $this->hasMany(self::class,'parent_id');
    }

    public function menu(): BelongsTo
{
return $this->belongsTo(Menu::class,'menu_id');
}
}

Filament Resource

Lets create a filament resource for menu resource.

php artisan make:filament-resource Menu

The above command will generate the ready to use complete MenuResource with form and tables.

Lets create a relation manager now:

php artisan make:filament-relation-manager Menu items name

The above command will generate us the ItemsRelationManager inside the MenuResource directory. Lets register the relation manager.

 public static function getRelations(): array
    {
        return [
            ItemsRelationManager::class
        ];
    }

The default Relation Manager works like a typical CRUD interface, which is not ideal for managing hierarchical menu structures. To make this more user-friendly and easier to control, we will use a tree-based solution with the filament-tree package.

Install the pacakge

composer require solution-forest/filament-tree

Now we need to add a ModelTree concerns on our Menu model.

class Menu extends Model{
 use ModelTree
}

Create a menuitem widget

Now we need to create a menuitem widget using the pacakge.

php artisan make:filament-tree-widget MenuItemWidget --model=MenuItem

Now we can easily customize our widget on the basis of our need.

Register the widget

Now finally lets register the page inside the EditMenu page.

<?php

namespace App\Filament\Resources\Menus\Pages;

use App\Filament\Resources\Menus\MenuResource;
use App\Livewire\MenuItemWidget;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;

class EditMenu extends EditRecord
{
    protected static string $resource = MenuResource::class;

    protected function getHeaderActions(): array
    {
        return [
            DeleteAction::make(),
        ];
    }

    protected function getFooterWidgets(): array
    {
        return [
            MenuItemWidget::class // this line
        ]; 
    }
}

Finally now we can easily see our menuitem widget in EditPage. Now we can remove our existing itemsrealtions.

Final Code

<?php

namespace App\Livewire;

use App\Models\Menu;
use App\Models\MenuItem;
use Dom\Text;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use SolutionForest\FilamentTree\Widgets\Tree;

class MenuItemWidget extends Tree
{
    protected static string $model = MenuItem::class;

    protected static int $maxDepth = 3;

    protected ?string $treeTitle = 'MenuItemWidget';

    protected bool $enableTreeTitle = true;

    public ?Model $record = null;


    protected function getTreeQuery(): Builder
    {
        return MenuItem::query()
            ->where('menu_id', $this->record?->id); 
    }

    protected function getFormSchema(): array
    {
        return [
            TextInput::make('title')
                ->required(),
            TextInput::make('url')
                ->required(),
            TextInput::make('type')
                ->required()
                ->default('custom'),
            Select::make('parent_id')
                ->relationship('parent', 'title'),
            TextInput::make('order')
                ->required()
                ->numeric()
                ->default(0),
        ];
    }

    protected function getViewFormSchema(): array
    {
        return [
            // INFOLIST, CAN DELETE
        ];
    }

    protected function getTreeToolbarActions(): array
    {
        return [
            \SolutionForest\FilamentTree\Actions\CreateAction::make()
                ->mutateDataUsing(function (array $data) {
                    $data['menu_id'] = $this->record?->id;
                    return $data;
                }),
        ];
    }

    // CUSTOMIZE ICON OF EACH RECORD, CAN DELETE
    // public function getTreeRecordIcon(?\Illuminate\Database\Eloquent\Model $record = null): ?string
    // {
    //     return null;
    // }

    // CUSTOMIZE ACTION OF EACH RECORD, CAN DELETE 
    // protected function getTreeActions(): array
    // {
    //     return [
    //         Action::make('helloWorld')
    //             ->action(function () {
    //                 Notification::make()->success()->title('Hello World')->send();
    //             }),
    //         // ViewAction::make(),
    //         // EditAction::make(),
    //         ActionGroup::make([
    //             
    //             ViewAction::make(),
    //             EditAction::make(),
    //         ]),
    //         DeleteAction::make(),
    //     ];
    // }
    // OR OVERRIDE FOLLOWING METHODS
    protected function hasDeleteAction(): bool
    {
       return true;
    }
    protected function hasEditAction(): bool
    {
       return true;
    }
    protected function hasViewAction(): bool
    {
       return true;
    }
}

Read more filament examples.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top