Making Magic with Laravel and Filament Admin

Pull a ready-made admin dashboard out of thin air!

·

13 min read

In this practical guide I will walk-through the process of building an admin panel to manage users. Along the way you'll get an introduction to the magic of Laravel and the new Filament Admin project.

If you're new to Filament (like me) this will serve as a great first project. I've also tried to make it helpful/informative for those new to Laravel and Livewire as well.

We will start from scratch using magic words and incantations to summon a finished product as quickly as possible. Copy, paste, maaaaaagic!


Our PHP Toolkit

Laravel

Laravel is a PHP framework for building web-based applications. Like any framework there is a steep learning curve to get started. However, once you understand it, or have Jeffrey Way explain everything, you will be able to conjure up amazing web apps.

Livewire

Livewire is a magical elixer which blends the front-end interface with your back-end code. You can create reactive interfaces, manipulating live data without refreshing the page. The best part is that it uses what you've already learned about Laravel to make it work.

Filament Admin

Filament is the alchemist who has combined these two elements to make developers live longer... or at least spend less of their life coding admin dashboards. You'll be astonished how much you can get done on your first day.


Setting Up A Bare Bones Project

Fresh Laravel install

Our first incantation will summon a fresh Laravel project from the ether.

You can choose the name of your new project folder. I'm calling it magic-admin.

composer create-project laravel/laravel magic-admin

cd magic-admin

This will download the latest Laravel along with all the other building blocks to make it work. It takes a few minutes to run, but it's taking away years of development work.

Docs: Your First Laravel Project

Laravel Breeze for user authentication

With a wave of the wand we'll add a complete user authentication system.

composer require laravel/breeze --dev

Note: The --dev flag on this command installs this package in development-only mode. But won't you need authentication in production, too? Yes, but this package installs and copies all of its files into your core app when you run this command...

php artisan breeze:install

Note if you're new to Laravel: This artisan command is part of the magic Laravel brings to your app development. You can see everything it can do with a call to php artisan list

Breeze will give you complete user authentication, along with registration and login forms, logout, and a password reset process. Done in a minute!

Docs: Breeze Starter Kit

Install Filament Admin

Our final ingredient in this potion is a dash of Filament. Let's install it in our project.

composer require filament/filament

Docs: Filament Installation

The spark of life

We need to breathe life into our freshly installed code.

What I skipped: I won't get into the details of running a local development environment for your code. If you're new to PHP and Laravel, this is what you need to see how your web app really looks in a browser. Laravel has its own solution for this called Sail . On Windows I was using Laragon as a simple all-in-one option. These days I like Lando , but it's a little more involved to set-up the first time.

Database set-up

Whatever local environment you're using, you'll need to set-up a database connection for your new app. You'll find these settings in your .env file in the root of your Laravel project.

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=

Migration files contain the instructions for building database tables. This migrate command will initialize the database, creating tables named users, password_resets and a couple others for Laravel business behind the scenes.

php artisan migrate

Didn't work? If that command gave you an error then you need to look again at the database connection configuration found in your .env file.

Docs: Running Migrations

Compiling javascript and CSS

Before we can load up our site in the browser we need to compile the CSS styles and Javascript code used. In this lesson today the default configuration is all we need, but this command will generate the app.css and app.js files for our site. (otherwise your site will be walking around completely naked!)

npm install
npm run dev

Docs: Laravel Mix

New project status check

After installing Laravel, Breeze, and Filament we've got the bones of a real web app!

magic-admin-base-screens.jpg

  1. Laravel standard welcome page located at the domain root /
  2. Breeze authentication pages at /login and /register (plus the bonus user /dashboard which we won't be working with in this tutorial.)
  3. Filament has secured itself behind a required login screen at /admin
  4. Once we register and sign-in, you can see the bare admin panel at /admin

Let's just take a moment to acknowledge how amazing this is. In a matter of minutes you have the benefit of years of development work rolled out before you. While it seems like magic on this side, it's the result of massive amounts of effort from many open-source contributors. Be thankful, and very kind, to these wonderful wizards.


The Magic of Filament

The first time I used Filament to create an admin panel I was truly astonished. Prepare to be amazed! (No really, we need to prepare first.) Add this package into your project:

composer require doctrine/dbal

This will give Filament the abilty to read your mind! (or at least read your database structure)

Now, here is the magic trick:

php artisan make:filament-resource User --generate

This artisan command generates the code for a Filament Resource based on the User model created from Breeze. the --generate flag will read the existing users database table and auto-magically create matching fields in the admin resource.

This is 100% functional! You can create, read, update and delete Users and it's all done by one wave of your all-powerful keyboard.

magic-admin-user-screens.jpg

Now imagine using this to auto-generate resources for an entire app. Pages, posts, teams, whatever. You could get 80% of your admin panel done in a matter of minutes.

I say eighty percent because you'll likely want to spend some time tinkering and making adjustements to the generated resources. This is what we'll be doing for the remainder of this tutorial. The amount of magic involved decreases as we go further along, but there may still be a few tricks up Filament's sleeves.

Generating fake users

Let's use a Laravel model factory to create some stuff for us to CRUD. This fake user generator was also built for us by the Breeze installation.

First, let's enter tinker mode, which allows us to quickly run PHP code as if it was part of our app.

php artisan tinker

This will give you a new command prompt where you can enter this command to generate 20 random users.

User::factory()->count(20)->create();

You can type quit to exit the tinker command line.


Customizing Filament Resources

With the auto-generated Filament resource the two primary components are the table which determines how our users show in the list, and the form which is how we create and edit a user.

Both of these pieces are defined in the app/Filament/Resources/UserResource.php file.

Changing the users list

This is how our auto-generated table is made out of the box:

public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\Columns\TextColumn::make('name'),
                Tables\Columns\TextColumn::make('email'),
                Tables\Columns\TextColumn::make('email_verified_at')
                    ->dateTime(),
                Tables\Columns\TextColumn::make('created_at')
                    ->dateTime(),
                Tables\Columns\TextColumn::make('updated_at')
                    ->dateTime(),
            ])
            ->filters([
                //
            ]);
    }

Make the list searchable

You'll love how easy this is! Just add searchable() to the columns you want to search.

Tables\Columns\TextColumn::make('name')->searchable(),
Tables\Columns\TextColumn::make('email')->searchable(),

It's a little bit magical, as the next time you refresh the page a search box will suddenly appear at the top of your list.

Docs: Filament Searchable columns

Make email verified a boolean display

I don't really care when, but I'd like to know if it an email is verified. Let's change this TextColumn to BooleanColumn.

// change this
Tables\Columns\TextColumn::make('email_verified_at')->dateTime(),

// to this
Tables\Columns\BooleanColumn::make('email_verified_at'),

It will read any date as true to display a green check mark in the list.

Docs: Boolean Column

Change the column label

The label Email verified at is no longer relevant. We can set a custom label:

Tables\Columns\BooleanColumn::make('email_verified_at')->label('Verified'),

Docs: Setting A Label

Add a filter to the list

We can add out first filter to the list, allowing you to narrow down the displayed records. To start, let's add a checkbox to show only verified users.

// Find the empty filters method within the table definition
->filters([
          //
]);

// You'll need to include the Builder class at the top of your Resource file
use Illuminate\Database\Eloquent\Builder;

// Now add in our first filter
->filters([
         Tables\Filters\Filter::make('verified')
                 ->query(fn (Builder $query): Builder => $query->whereNotNull('email_verified_at')),
]);

With a refresh you'll find a small funnel icon beside the search bar. Click on that and you can select to show only verified users.

So why not add a second filter to show only unverified users?

->filters([
         Tables\Filters\Filter::make('verified')
                 ->query(fn (Builder $query): Builder => $query->whereNotNull('email_verified_at')),
         Tables\Filters\Filter::make('unverified')
                 ->query(fn (Builder $query): Builder => $query->whereNull('email_verified_at')),
]);

Docs: Table Filters

Change date display

By default a datetime column shows a date like Dec 27, 2021 23:08:03 which is not ideal for human eyeballs. Let's give that a more readable look with the standard PHP Datetime formatting .

// This will show 'Dec 21, 2021' without the time
Tables\Columns\TextColumn::make('created_at')
                    ->dateTime('M j, Y'),
Tables\Columns\TextColumn::make('updated_at')
                    ->dateTime('M j, Y'),

Docs: Text Column Formatting

Sort by dates

Sorting by dates will be handy when we want to see the newest or oldest users. Much like searchable() this can be handled with one magic word; sortable()

Tables\Columns\TextColumn::make('created_at')
                    ->dateTime('M j, Y')
                    ->sortable(),
Tables\Columns\TextColumn::make('updated_at')
                    ->dateTime('M j, Y')
                    ->sortable(),

Now the column names will be clickable to sort. Click again to go the other direction. Note, however, that your randomly generated users were all created on the same day, so your dates will all appear identical at the moment.

Docs: Sortable Columns

Changing the user create/edit form

The auto-generated form allows us to change a user's name, email and password out of the box. It is pretty basic and it will stay that way until we start adding more models and relations to our app.

Your form field definitions are also found in the app/Filament/Resources/UserResource.php file.

public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\Components\TextInput::make('name')
                    ->required()
                    ->maxLength(255),
                Forms\Components\TextInput::make('email')
                    ->email()
                    ->required()
                    ->maxLength(255),
                Forms\Components\TextInput::make('password')
                    ->password()
                    ->required()
                    ->maxLength(255),
            ]);
    }

Solving the password hash problem

As it stands this user form includes a field for the password. However, if you type in a new password it will be saved as plain text in the database. Laravel hashes all passwords, so on a login attempt the non-hashed password won't match, and the user will be unable to login.

What I'm skipping: My preferred solution to this is to create a setPasswordAttribute() mutator on the User model. Any time the password is changed it would automatically be hashed. However, you also need to modify the standard authentication process to remove all the calls to Hash::make(), or else it would get double-hashed.

Instead, let's take a look at Filament's options to modify input.

// we'll need this imported
use Illuminate\Support\Facades\Hash;

Forms\Components\TextInput::make('password')
         ->password()
         ->required()
         ->maxLength(255)
         ->dehydrateStateUsing(fn ($state) => Hash::make($state)),

The mention of dehydrate refers to the Livewire lifecycle of how data changes. The dehydrateStateUsing function will be called as the input is changed, hashing the password in the process.

This works fine when you are creating a new user, but if you try to update an existing user (without changing the password) it is going to re-hash the hash. As a simple solution, let's make the password field only visible on the create user form.

// add this to the list of includes
use Livewire\Component;

Forms\Components\TextInput::make('password')
          ->password()
          ->required()
          ->maxLength(255)
          ->dehydrateStateUsing(fn ($state) => Hash::make($state))
          ->visible(fn (Component $livewire): bool => $livewire instanceof Pages\CreateUser),

Docs: Hiding components based on page

Add a field for email_verified_at

This is the easiest way to add an option to manually verify a user. We can add a field for email_verified_at which will either show a date, or null.

You can insert this at the end of the form definitions;

Forms\Components\DatePicker::make('email_verified_at'),

While email_verified_at is timestamp in the database, I've chosen a DatePicker field rather than a DateTimePicker field. It will simply set the time to 00:00:00 on the chosen day.

Docs: Date-Time Picker

We do need to add this attribute to the $fillable array on app/Models/User.php

protected $fillable = [
        'name',
        'email',
        'password',
        'email_verified_at',
    ];

What I skipped: Rather than a date field, this should probably be an action you can click to call user()->markEmailAsVerified() However, we did learn how to add a new field without the fuss of expanding the users table.


Tidying Up

We've explored how the magic of Laravel and Filament can help you make an admin panel at astonishing speed. Yes, we could chase the rabbit for many hours, making tweaks and customizations, but let's leave it where it is now; a fully functional admin dashboard.

There's just a couple things to finish up so it's ready for your live app.

Managing your Filament menu

It doesn't much matter when we only have our one Users resource, but as your dashboard grows you'll want to organize all the resources in the menu.

These can be set at the very top of your app/Filament/Resources/UserResource.php file.

class UserResource extends Resource
{
    protected static ?string $model = User::class;

    protected static ?string $navigationIcon = 'heroicon-o-collection';

    ...

We can change the Heroicon to something more appropriate for Users. And while we're here, let's add a sort order to determine where it shows in the menu.

// change this
protected static ?string $navigationIcon = 'heroicon-o-users';

// add this
protected static ?int $navigationSort = 1;

This way the user always comes first, just as it's meant to be! Down the road you may want to look into the options for grouping your menu items as well.

Docs: Filament Admin navigation

Securing the Admin dashboard

When your Laravel app environment is set to local every registered user will be able to access the admin panel. Once you go live with a production app, everybody (yourself included) will be locked out. To grant your admin-worthy users access we will need to modify the User model as defined in app/Models/User.php

  1. implement FilamentUser on the class declaration
  2. import Filament\Models\Contracts\FilamentUser
  3. Add a canAccessFilament() method
<?php

namespace App\Models;

use Filament\Models\Contracts\FilamentUser;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable implements FilamentUser
{
    // ...

    public function canAccessFilament(): bool
    {
        return $this->email == 'ryan@rpillz.com' && $this->hasVerifiedEmail();
    }
}

You'll probably want to change that to match your email, unless you want me to have access to all the dashboardz.


Final Result

After all that we have installed a Filament admin panel, and customized a resource to manage users. Rinse and repeat for all your models and you'll have a fully functional dashboard like magic!

Filament really puts the fun in functional when you start linking up your resources using relations . However, this tutorial needs to end somewhere... so how about now.

Ta Da!

In case you'd like to compare notes (cough copy the homework cough) here is my complete UserResource.php file with all the changes we've made. (not including changes made to app/Models/User.php)

<?php

namespace App\Filament\Resources;

use Closure;
use Filament\Forms;
use App\Models\User;
use Filament\Tables;
use Livewire\Component;
use Filament\Resources\Form;
use Filament\Resources\Table;
use Filament\Resources\Resource;
use Illuminate\Support\Facades\Hash;
use Illuminate\Database\Eloquent\Builder;
use App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource\RelationManagers;

class UserResource extends Resource
{
    protected static ?string $model = User::class;

    protected static ?string $navigationIcon = 'heroicon-o-users';

    protected static ?int $navigationSort = 1;

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\Components\TextInput::make('name')
                    ->required()
                    ->maxLength(255),
                Forms\Components\TextInput::make('email')
                    ->email()
                    ->required()
                    ->maxLength(255),
                Forms\Components\TextInput::make('password')
                    ->password()
                    ->required()
                    ->maxLength(255)
                    ->dehydrateStateUsing(fn ($state) => Hash::make($state))
                    ->visible(fn (Component $livewire): bool => $livewire instanceof Pages\CreateUser),
                Forms\Components\DatePicker::make('email_verified_at'),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\Columns\TextColumn::make('name')->searchable(),
                Tables\Columns\TextColumn::make('email')->searchable(),
                Tables\Columns\BooleanColumn::make('email_verified_at')->label('Verified'),
                Tables\Columns\TextColumn::make('created_at')
                    ->dateTime('M j, Y')
                    ->sortable(),
                Tables\Columns\TextColumn::make('updated_at')
                    ->dateTime('M j, Y')
                    ->sortable(),
            ])
            ->filters([
                Tables\Filters\Filter::make('verified')
                 ->query(fn (Builder $query): Builder => $query->whereNotNull('email_verified_at')),
                Tables\Filters\Filter::make('unverified')
                 ->query(fn (Builder $query): Builder => $query->whereNull('email_verified_at')),
            ]);
    }

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

    public static function getPages(): array
    {
        return [
            'index' => Pages\ListUsers::route('/'),
            'create' => Pages\CreateUser::route('/create'),
            'edit' => Pages\EditUser::route('/{record}/edit'),
        ];
    }
}