Blog Admin Dashboard with Filament
Build a blog admin panel with Laravel and Filament for full CRUD.
Introduction
30 Second Summary
Every content-driven website needs an admin panel where writers and editors can manage their posts. Building one from scratch usually means weeks of coding forms, tables, authentication, and validation.
In this project, you will build a blog admin dashboard using Laravel and Filament. You will create, edit, and delete blog posts and categories through a polished admin interface without writing any frontend code.
What You'll Build
A blog admin dashboard powered by Laravel 13 and Filament 5, with full CRUD operations for posts and categories plus a stats widget dashboard.
By the end of this project, you'll have:
- A Laravel 13 application with an SQLite database and user authentication.
- An admin panel at /admin with full CRUD for blog posts and categories.
- Dashboard widgets displaying real-time content statistics for total posts, published posts, and draft posts.
- Secret Mission: Add a rich text editor and table filtering for a production-ready content management experience.
Are there any prerequisites?
No prior experience with PHP or Laravel is required. This guide includes full setup instructions for every tool you need, including PHP, Composer, and the Laravel installer.
Install PHP, Laravel, and Create Your Project
Every web application needs a foundation. Before you can build an admin dashboard, you need the language it runs on (PHP), the framework that structures your code (Laravel), and a project folder with all the right files in place.
In this step, you will install all the tools your project needs and create a fresh Laravel 13 application with a database ready to go.
In this step, get ready to:
- Install PHP, Composer, and the Laravel installer.
- Create a new Laravel 13 application with an SQLite database.
- Start the development server and confirm it works.
Install PHP, Composer, and the Laravel installer
The official php.new installer is a single command that installs PHP, Composer, the Laravel installer, and Node.js on your machine all at once.
- Press the Windows key to open your search bar.
- Type PowerShell and select Run as administrator.
- Install PHP, Composer, the Laravel installer, and Node.js by running this command:
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://php.new/install/windows'))
What does this command do?
This one-liner downloads and runs the official PHP installer for Windows. It sets a temporary execution policy so PowerShell can run the downloaded script, then fetches the installer from php.new and executes it.
Once complete, your machine will have php, composer, laravel, node, and npm available from the command line.
- Close PowerShell completely after the installer finishes.
- Reopen PowerShell by pressing the Windows key, typing PowerShell, and pressing Enter.
Why do I need to restart PowerShell?
The installer adds new programs to your system PATH. Your current terminal session still has the old PATH loaded. Closing and reopening PowerShell forces it to read the updated PATH so it can find the new tools.
- Verify PHP is installed by running:
php -v
✔️ I see version 8.3 or higher
PHP is installed and ready. Move on to the next verification below.
ⓧ I see an older version
You may have a pre-existing PHP installation that is taking priority over the newly installed version.
- Close and reopen PowerShell to make sure it picks up the latest PATH.
- If you still see an older version, run the php.new installer again. It will update your PHP to the latest version.
- Visit php.new for additional troubleshooting guidance.
ⓧ Command not found
The installer may not have completed successfully, or PowerShell was not restarted.
- Make sure you closed and reopened PowerShell after running the installer.
- Try running the install command again in a PowerShell window opened with Run as administrator.
- Visit php.new for troubleshooting guidance.
- Verify the Laravel installer is available by running:
laravel -v
✔️ I see a version number
The Laravel installer is ready. You can now create projects.
ⓧ Command not found
The Laravel installer was not set up correctly.
- Make sure you closed and reopened PowerShell after the install.
- Run the php.new install command again as administrator.
Create a new Laravel application
With your tools installed, you can now scaffold a brand new Laravel 13 project. The installer asks a few configuration questions to set up the database and dependencies.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Laravel project by running:
laravel new blog-admin
What will the installer ask?
The laravel new command launches an interactive wizard. It will prompt you to choose a starter kit, a testing framework, and a database. It also asks whether to run npm install to set up frontend dependencies.
- When prompted for a starter kit, select None.
- When prompted for a testing framework, accept the default by pressing Enter.
- When prompted for a database, select SQLite.
- When asked whether to run npm install, select yes.
- For any other prompts, accept the defaults by pressing Enter.
Why no starter kit?
Laravel starter kits include authentication scaffolding and a frontend framework. You are selecting None because Filament provides its own authentication and UI. Adding a starter kit would create conflicts with the admin panel you install in the next step.
- Wait for the installer to finish. It will create a blog-admin folder on your Desktop, set up the SQLite database, and install npm packages.
- Move into your new project folder by running:
cd blog-admin
- Confirm the project was created by running:
ls
You should see folders like app, database, routes, and files like composer.json and .env.
Don't see these files?
Make sure you ran cd blog-admin after the installer completed. If the blog-admin folder does not exist on your Desktop, the installer may have encountered an error. Re-run laravel new blog-admin and check for error messages in the output.
Help me troubleshoot my Laravel project creation
Start the development server
Laravel includes a built-in development server so you can preview your application in the browser without any extra setup.
- Start the development server by running:
composer run dev
What does this command do?
The composer run dev command starts the Laravel local development server, a queue worker, and the Vite development server all at once. It keeps running in your terminal until you stop it with Ctrl+C.
- Open your browser and navigate to http://localhost:8000.
You should see the Laravel welcome page with the Laravel logo and links to documentation.
Don't see the welcome page?
Check that the composer run dev command is still running in your terminal. If you see an error about a port being in use, another application may be using port 8000. Close that application and try again.
Help me get the Laravel development server running
Your Laravel application is live. Next up, you will install Filament and create your admin user to unlock the admin panel.
Install Filament and Create Your Admin User
Your Laravel project is running and ready. Now you need an admin panel to manage blog content.
Building an admin panel from scratch means writing forms, tables, authentication, and navigation by hand. Filament generates all of this from your models. In this step, you will install Filament and create your first admin user.
In this step, get ready to:
- Install Filament into your Laravel project.
- Create an admin user account.
- Sign in to the Filament admin dashboard.
Install Filament
Filament is a free, open-source admin panel framework for Laravel. It provides pre-built UI components for forms, tables, and dashboards so you do not need to write frontend code.
- Stop the development server by pressing Ctrl+C in the terminal where composer run dev is running.
- Install the Filament package by running this command:
composer require filament/filament:"~5.0"
Why ~5.0 instead of ^5.0?
Windows PowerShell ignores the ^ character in version constraints. The Filament docs recommend using ~5.0 instead of ^5.0 when working in PowerShell.
- Scaffold the Filament admin panel by running:
php artisan filament:install --panels
What do these commands do?
The first command downloads Filament and all its dependencies into your project. The second command creates a service provider at app/Providers/Filament/AdminPanelProvider.php that registers the admin panel at the /admin path.
Filament also runs any necessary database migrations during this process, setting up the tables it needs for authentication and panel management.
- Confirm the install completed without errors. You should see a success message in the terminal output.
Seeing an error about the service provider?
If you get an error when accessing the panel later, check that the service provider is registered in bootstrap/providers.php. Filament attempts to register it automatically, but if it fails you will need to add it manually.
help me fix a Filament service provider registration error
Create an admin user
Filament uses Laravel's built-in authentication system. You need to create a user account to sign in to the admin panel.
- Create your admin user by running:
php artisan make:filament-user
What will this command ask?
The command prompts you for three pieces of information:
- Name - your display name in the admin panel.
- Email - the email address you will use to sign in.
- Password - your login password (it will not appear as you type).
- Enter a name when prompted (e.g. Admin).
- Enter an email address (e.g. admin@example.com).
- Enter a password. Remember this password for the next substep.
You should see a success message confirming the user was created.
Getting a database error?
Make sure you selected SQLite when creating the project in Step 1. If the database/database.sqlite file does not exist, create it manually and run php artisan migrate before trying again.
help me fix a database error when creating a Filament user
Access the admin panel
With Filament installed and your user created, you can now sign in to the admin dashboard.
- Start the development server again by running:
composer run dev
- Open your browser and navigate to http://localhost:8000/admin.
- Enter the email and password you created in the previous substep.
- Click Sign in.
You should see the Filament admin dashboard. It is empty for now, but you will fill it with blog management features in the next steps.
Can't reach the admin panel?
Make sure composer run dev is running and check that you are visiting http://localhost:8000/admin (not just http://localhost:8000). The /admin path is required.
help me access the Filament admin panel at localhost
Your admin panel is live and ready. Next up, you will create the Category model and its Filament resource to start managing blog content.
Create the Category Model, Migration, and Resource
Your admin panel is live and you can sign in. Now it needs something to manage.
In this step, you will create the Category model (your data layer) and a Filament resource (the admin interface for that data). By the end, you will be able to create, edit, and delete categories directly from the admin panel.
In this step, get ready to:
- Generate the Category model and database migration.
- Create and configure a Filament resource for categories.
- Test the resource by creating categories in the admin panel.
Generate the Category model and migration
Laravel uses Eloquent models to represent database tables as PHP classes. A migration is a version-controlled file that defines the shape of a database table (its columns and types).
- Generate both the model and its migration in one command by running:
php artisan make:model Category -m
What does this command do?
The -m flag tells Laravel to create a migration file alongside the model. This generates two files: app/Models/Category.php (your model) and a timestamped migration file in database/migrations/.
You should see confirmation messages in your terminal showing both files were created.
The generated migration has an empty table structure. You need to define what columns the categories table should have.
- In your code editor, open the new migration file inside database/migrations/. Its filename ends with _create_categories_table.php.
- Find the up() method. Inside the Schema::create closure, replace the contents so the closure body looks like this:
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->timestamps();
What do these columns do?
- id() creates an auto-incrementing primary key. This was already in the generated file.
- string('name') creates a VARCHAR column to store the category name (e.g. "Technology").
- string('slug')->unique() creates a unique VARCHAR column for URL-friendly identifiers (e.g. "technology").
- timestamps() adds created_at and updated_at columns. This was already in the generated file.
- Save the migration file.
Now you need to configure the model so Laravel knows which fields are safe to fill through forms.
- Open app/Models/Category.php in your code editor.
- Replace the entire file contents with this code:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Category extends Model
{
protected $fillable = ['name', 'slug'];
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
}
What does this model do?
- $fillable tells Laravel which columns can be mass-assigned through forms. Without this, Filament's forms would not be able to save data.
- posts() defines a one-to-many relationship. Each category can have many posts. You will use this relationship in Step 4.
- Save app/Models/Category.php.
- Run the migration to create the categories table in your database:
php artisan migrate
You should see output confirming the categories table was created.
Seeing a syntax error?
- Check that you have a semicolon at the end of each $table-> line in your migration file.
- Make sure you did not accidentally delete the opening function (Blueprint $table) { line or the closing });.
Help me debug my migration error.
Create and configure the Filament resource
A Filament resource connects your model to the admin panel. It defines the form learners fill out and the table that displays existing records.
- Generate the Category resource by running:
php artisan make:filament-resource Category
What does this command generate?
This creates a CategoryResource.php file in app/Filament/Resources/ along with a Pages/ subdirectory containing page classes for listing, creating, and editing categories.
The generated resource has empty form and table configurations. You need to define what fields appear in the form and what columns display in the table.
- Open app/Filament/Resources/CategoryResource.php in your code editor.
- Replace the entire file contents with this code:
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\CategoryResource\Pages;
use App\Models\Category;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class CategoryResource extends Resource
{
protected static ?string $model = Category::class;
protected static ?string $navigationIcon = 'heroicon-o-tag';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255),
Forms\Components\TextInput::make('slug')
->required()
->maxLength(255)
->unique(ignoreRecord: true),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable(),
Tables\Columns\TextColumn::make('slug'),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable(),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListCategories::route('/'),
'create' => Pages\CreateCategory::route('/create'),
'edit' => Pages\EditCategory::route('/{record}/edit'),
];
}
}
What does this resource configure?
The form() method defines two text inputs: one for the category name (required) and one for the slug (required, must be unique across all categories).
The table() method defines three columns (name, slug, created_at) plus edit and delete action buttons on each row. The searchable() modifier on the name column adds a search bar to the table header.
The $navigationIcon property sets the icon that appears next to "Categories" in the admin sidebar.
- Save app/Filament/Resources/CategoryResource.php.
Don't see Categories in the sidebar?
- Make sure the file is saved and refresh your browser at http://localhost:8000/admin.
- Confirm the file is located at app/Filament/Resources/CategoryResource.php (the exact path matters for auto-discovery).
Help me figure out why my Filament resource is not showing up.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete CategoryResource.php file:
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\CategoryResource\Pages;
use App\Models\Category;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class CategoryResource extends Resource
{
protected static ?string $model = Category::class;
protected static ?string $navigationIcon = 'heroicon-o-tag';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255),
Forms\Components\TextInput::make('slug')
->required()
->maxLength(255)
->unique(ignoreRecord: true),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable(),
Tables\Columns\TextColumn::make('slug'),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable(),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListCategories::route('/'),
'create' => Pages\CreateCategory::route('/create'),
'edit' => Pages\EditCategory::route('/{record}/edit'),
];
}
}
And here is the complete Category.php model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Category extends Model
{
protected $fillable = ['name', 'slug'];
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
}
And here is the migration file (inside the Schema::create closure in the up() method):
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->timestamps();
Test the resource by creating categories
- In your browser, go to http://localhost:8000/admin.
- Click Categories in the left sidebar.
- Click the New category button in the top right corner.
- Enter Technology in the Name field.
- Enter technology in the Slug field.
- Click Create.
You should see a success notification and your new category listed in the table.
- Repeat the process to create two more categories: Laravel (slug: laravel) and PHP (slug: php).
Your categories table should now display three rows with name, slug, and created_at columns.
Try the search bar
Type a category name into the search bar above the table. Because you added ->searchable() to the name column, Filament filters results as you type.
Your data layer is ready and you have a working admin interface for categories. Next up, you will create the Post model with a relationship back to categories and build a richer resource with more field types.
Create the Post Model, Migration, and Resource
Your categories are ready. Now you need a way to manage the actual blog posts.
Posts are the core content of any blog. Each post belongs to a category, has a publication status, and stores its content. In this step, you will create the Post model, define its database structure, connect it to categories through an Eloquent relationship, and build the Filament resource that powers the CRUD interface.
In this step, get ready to:
- Generate the Post model and migration, then define the database columns.
- Set up the Eloquent relationship between posts and categories.
- Create and configure the Filament Post resource with a form and table.
Generate the Post model and migration
Laravel can generate a model and its corresponding migration file in one command. The migration defines the columns your posts table will have in the database.
- Open a new terminal window in the blog-admin folder (keep your dev server running in the other terminal).
- Generate the Post model and migration by running this command:
php artisan make:model Post -m
What does this command do?
The -m flag tells Laravel to create both a model file at app/Models/Post.php and a migration file in database/migrations/. The migration file has a timestamp prefix in its filename.
The generated migration file has an empty schema. You need to define the columns your posts table requires.
- Open the new migration file in database/migrations/. It ends with _create_posts_table.php.
- Replace the entire file content with the following:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('slug')->unique();
// Content is nullable so posts can start as empty drafts
$table->text('content')->nullable();
// Every new post starts as a draft by default
$table->string('status')->default('draft');
$table->timestamp('published_at')->nullable();
// Links each post to a category, nulls out if category is deleted
$table->foreignId('category_id')->nullable()->constrained()->nullOnDelete();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};
What does this migration do?
Each line defines a column in your posts table:
- title and slug are strings for the post name and URL-friendly identifier.
- content is a text field (nullable because a post might start as an empty draft).
- status defaults to 'draft' so every new post starts unpublished.
- category_id is a foreign key linking each post to a category. The nullOnDelete() ensures that if a category is deleted, the post keeps its category field as null instead of being deleted.
- Save the migration file.
- Run the migration to create the table in your database:
php artisan migrate
- You should see output confirming the posts table was created.
Migration failed?
- Check for typos in the column definitions. A missing semicolon or misspelled method name will cause a syntax error.
- Make sure you saved the migration file before running the command.
- If you see a table already exists error, run php artisan migrate:fresh to reset all tables. Note that this deletes your test categories and admin user, so you will need to recreate them.
Help me debug my migration error.
Set up the Eloquent relationship
A post belongs to one category, and a category can have many posts. The Category model already has a posts() relationship from the previous step. Now you need to define the other side on the Post model.
- Open app/Models/Post.php and replace its entire content with the following:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Post extends Model
{
// Fields that can be mass-assigned when creating or updating posts
protected $fillable = [
'title',
'slug',
'content',
'status',
'published_at',
'category_id',
];
// Cast published_at from a raw string to a Carbon datetime object
protected function casts(): array
{
return [
'published_at' => 'datetime',
];
}
// Each post belongs to one category
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
}
What does this model do?
- $fillable lists the fields that can be set through forms. This protects against unwanted mass assignment.
- casts() tells Laravel to treat published_at as a datetime object instead of a raw string. This enables date formatting in your table columns.
- category() defines the belongsTo relationship. Combined with the posts() hasMany on the Category model, Laravel can navigate between posts and categories in both directions.
- Save app/Models/Post.php.
Seeing a class not found error later?
- Make sure the namespace line reads namespace App\Models; exactly.
- Confirm the use statement imports BelongsTo from Illuminate\Database\Eloquent\Relations\BelongsTo.
Help me fix a class not found error in my Post model.
Create the Post Filament resource
With your model and migration ready, you can generate the Filament resource that provides the admin interface for managing posts.
- Generate the Post resource by running this command:
php artisan make:filament-resource Post
What does this generate?
This command creates a PostResource.php class along with page files for listing, creating, and editing posts. It also generates separate schema and table configuration files.
For this project, you will define the form and table directly inside the resource class. This keeps everything in one place and is simpler to follow.
- Open app/Filament/Resources/PostResource.php and replace its entire content with the following:
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\PostResource\Pages;
use App\Models\Post;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class PostResource extends Resource
{
protected static ?string $model = Post::class;
protected static ?string $navigationIcon = 'heroicon-o-document-text';
public static function form(Form $form): Form
{
return $form
->schema([
// Title and slug for the post identity
Forms\Components\TextInput::make('title')
->required()
->maxLength(255),
Forms\Components\TextInput::make('slug')
->required()
->maxLength(255)
->unique(ignoreRecord: true),
// Content area spans the full width of the form
Forms\Components\Textarea::make('content')
->columnSpanFull(),
// Status dropdown with draft as the default
Forms\Components\Select::make('status')
->options([
'draft' => 'Draft',
'published' => 'Published',
])
->default('draft')
->required(),
// Category selector pulls options from the relationship
Forms\Components\Select::make('category_id')
->relationship('category', 'name')
->searchable()
->preload(),
// Optional publish date
Forms\Components\DateTimePicker::make('published_at'),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('title')
->searchable(),
// Display the related category name via dot notation
Tables\Columns\TextColumn::make('category.name')
->sortable(),
// Status shown as a colored badge
Tables\Columns\TextColumn::make('status')
->badge()
->color(fn (string $state): string => match ($state) {
'draft' => 'gray',
'published' => 'success',
}),
Tables\Columns\TextColumn::make('published_at')
->dateTime()
->sortable(),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListPosts::route('/'),
'create' => Pages\CreatePost::route('/create'),
'edit' => Pages\EditPost::route('/{record}/edit'),
];
}
}
What does this resource configure?
The form() method defines what the admin sees when creating or editing a post:
- TextInput fields for title and slug with validation rules.
- Textarea for content that spans the full form width.
- Two Select fields. One for status (with hardcoded options) and one for category (pulled dynamically from the database via the relationship).
- DateTimePicker for choosing when to publish.
The table() method defines the columns on the posts list page:
- category.name uses dot notation to display the related category name.
- The status column uses badge() to render as a colored pill. Gray for drafts, green for published.
- Save app/Filament/Resources/PostResource.php.
- Go to your admin panel at http://localhost:8000/admin and refresh the page.
- You should see a Posts link in the sidebar navigation.
- Click Posts, then click New post.
- Fill in a title, slug, choose a category, and set the status to Published.
- Click Create to save the post.
- Confirm the post appears in the table with the correct category name and a green Published badge.
Don't see Posts in the sidebar?
- Make sure you saved PostResource.php and refreshed the admin panel page.
- Check that the namespace in your file is App\Filament\Resources. A wrong namespace means Filament cannot discover the resource.
- If you see an error about the posts table not existing, confirm you ran php artisan migrate successfully earlier.
Help me figure out why Posts is not showing in my Filament sidebar.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the full PostResource.php for comparison:
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\PostResource\Pages;
use App\Models\Post;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class PostResource extends Resource
{
protected static ?string $model = Post::class;
protected static ?string $navigationIcon = 'heroicon-o-document-text';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('title')
->required()
->maxLength(255),
Forms\Components\TextInput::make('slug')
->required()
->maxLength(255)
->unique(ignoreRecord: true),
Forms\Components\Textarea::make('content')
->columnSpanFull(),
Forms\Components\Select::make('status')
->options([
'draft' => 'Draft',
'published' => 'Published',
])
->default('draft')
->required(),
Forms\Components\Select::make('category_id')
->relationship('category', 'name')
->searchable()
->preload(),
Forms\Components\DateTimePicker::make('published_at'),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('title')
->searchable(),
Tables\Columns\TextColumn::make('category.name')
->sortable(),
Tables\Columns\TextColumn::make('status')
->badge()
->color(fn (string $state): string => match ($state) {
'draft' => 'gray',
'published' => 'success',
}),
Tables\Columns\TextColumn::make('published_at')
->dateTime()
->sortable(),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListPosts::route('/'),
'create' => Pages\CreatePost::route('/create'),
'edit' => Pages\EditPost::route('/{record}/edit'),
];
}
}
Here is the full Post.php model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Post extends Model
{
protected $fillable = [
'title',
'slug',
'content',
'status',
'published_at',
'category_id',
];
protected function casts(): array
{
return [
'published_at' => 'datetime',
];
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
}
Here is the migration file (your filename will have a different timestamp prefix):
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('slug')->unique();
$table->text('content')->nullable();
$table->string('status')->default('draft');
$table->timestamp('published_at')->nullable();
$table->foreignId('category_id')->nullable()->constrained()->nullOnDelete();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};
Your blog now has full CRUD for both categories and posts. Next up, you will build dashboard widgets that give you a quick overview of your content at a glance.
Build Dashboard Widgets
In the last step, you built full CRUD for posts and categories. That covers creating, editing, and deleting content.
However, every time you want a quick count of your published or draft posts, you would need to open the Posts table and count them manually. Filament solves this with dashboard widgets. In this step, you will build a stats overview widget that displays post metrics right on your admin homepage.
In this step, get ready to:
- Generate a stats overview widget with Artisan.
- Configure the widget to display post count metrics.
- Verify the stats update as you create new posts.
Generate a stats overview widget
Filament provides a dedicated widget type for displaying statistics. The StatsOverviewWidget renders stat cards on the dashboard, each showing a label and a value.
- Open a new terminal tab (keep your development server running in the other one).
- Generate the widget by running this command:
php artisan make:filament-widget PostStatsOverview --stats-overview
What does this command do?
The make:filament-widget command generates a new widget class.
The --stats-overview flag tells Filament to create a stats overview widget specifically, which extends StatsOverviewWidget and includes a getStats() method you can fill in.
- Confirm the file was created by running:
ls app/Filament/Widgets/
You should see PostStatsOverview.php in the output.
Configure the widget to display post metrics
The generated file has a placeholder getStats() method. You will replace the entire file contents with three stat cards that query your Post model.
- Open app/Filament/Widgets/PostStatsOverview.php in your editor.
- Replace the entire file contents with the following code:
<?php
namespace App\Filament\Widgets;
use App\Models\Post;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class PostStatsOverview extends BaseWidget
{
protected function getStats(): array
{
return [
// Count all posts regardless of status
Stat::make('Total Posts', Post::count()),
// Count only posts with status 'published'
Stat::make('Published Posts', Post::where('status', 'published')->count()),
// Count only posts with status 'draft'
Stat::make('Draft Posts', Post::where('status', 'draft')->count()),
];
}
}
What does this code do?
- The class extends StatsOverviewWidget which renders stat cards in a row on the dashboard.
- Each Stat::make() call creates one card with a label (first argument) and a value (second argument).
- The Post::count() and Post::where(...)->count() calls are Eloquent queries that run against your posts table.
- Filament automatically discovers widgets in the app/Filament/Widgets directory, so no manual registration is needed.
- Save PostStatsOverview.php.
Seeing an error about the widget class?
- Make sure the namespace is exactly App\Filament\Widgets and the file is saved in the correct directory.
- Check that you have use App\Models\Post; at the top of the file.
Why isn't my widget showing up on the dashboard?
✔️ Awesome, I've got everything!
Great. Make sure you have saved your file before moving on.
ⓧ I'd like to double check the full code
<?php
namespace App\Filament\Widgets;
use App\Models\Post;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class PostStatsOverview extends BaseWidget
{
protected function getStats(): array
{
return [
Stat::make('Total Posts', Post::count()),
Stat::make('Published Posts', Post::where('status', 'published')->count()),
Stat::make('Draft Posts', Post::where('status', 'draft')->count()),
];
}
}
Create test posts and verify the dashboard
Your widget is ready, but it needs data to display meaningful numbers. You will create a few posts with different statuses, then check the dashboard to confirm the stat cards update correctly.
- In your browser, go to http://localhost:8000/admin.
- Click Posts in the sidebar.
- Click New post to create your first post.
- Fill in the form with a title like Getting Started with Laravel, a slug like getting-started-with-laravel, and set the status to Published.
- Click Create to save the post.
- Create a second post with a title like Draft Ideas for Next Week and leave the status as Draft (the default).
- Create a third post with status set to Published.
- Click the Dashboard link in the sidebar (or navigate to http://localhost:8000/admin).
You should see three stat cards at the top of your dashboard: Total Posts showing 3, Published Posts showing 2, and Draft Posts showing 1.
Why do the stats update automatically?
Each time you load the dashboard, the getStats() method runs fresh Eloquent queries against your database. The values are always current because they are computed on every page load, not cached.
Stats all showing zero?
- Make sure you actually saved the posts by clicking Create on each form. Check the Posts list to confirm they appear.
- Verify that the status column in your query matches what your form saves. The select options use draft and published (lowercase).
Why are my dashboard stats not showing the correct numbers?
Secret mission
Rich Text Editor and Table Filters
Upgrade your blog admin with a richer editing experience and powerful data filtering. Replace the plain text area with a full rich text editor, then add filters so you can slice your posts by status and category.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine with a SQLite database, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The blog-admin project folder on your Desktop (includes the SQLite database, all application code, and dependencies).
- Node.js (installed during project setup for compiling frontend assets).
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the project soon.
- Your blog-admin folder stays on your Desktop.
- PHP, Composer, and the Laravel installer remain installed on your machine. These are useful for any future Laravel projects.
- Node.js stays installed and available for other JavaScript projects.
- To restart the development server at any time, open PowerShell, navigate to your project folder, and run composer run dev.
Pause - I'll come back to this later
Stop any running processes to free up system resources. Your files stay on disk so you can pick up where you left off.
- In your terminal where composer run dev is running, press Ctrl+C to stop the development server.
- Close any open VS Code or Cursor windows for the project.
- Your blog-admin folder and database remain intact on your Desktop.
Delete - I don't want to use this again
Remove all project files and optionally uninstall tools you no longer need.
- Stop the development server if it is still running by pressing Ctrl+C in the terminal.
- Delete the project folder by running this command in PowerShell:
Remove-Item -Recurse -Force ~\Desktop\blog-admin
- To uninstall Node.js, open Settings.
- Select Apps, then Apps & features.
- Search for Node.js in the list, click it, and select Uninstall.
Nice Work!
Nice Work!
You just built a professional blog admin dashboard using Laravel and Filament. You went from zero to a fully functional content management system with authentication, CRUD operations, and real-time statistics.
Here's what you accomplished:
- Built a Laravel 13 application with Eloquent models, relationships, and an SQLite database.
- Created Filament resources with configured forms and tables for full CRUD operations on posts and categories.
- Built a dashboard widget displaying real-time content statistics using Eloquent queries.
- Added a rich text editor and table filters for a production-ready admin experience.
Ready to quiz yourself?