Ini adalah website personal Selamat M. Harjono
Rabu, 17 Desember 2025 | oleh Selamat Muliyadi Harjono | Materi
Durasi: 3 jam (Teori: 1 jam, Praktik: 2 jam)
A. Opening & Review Fase 2 (30 menit)
B. Kenapa FilamentPHP? (30 menit)
Perbandingan Admin Panel Solutions:
SolutionKelebihanKekuranganCocok untukFilamentPHP | Built on Laravel, Rapid development, Beautiful UI, Extensible | Learning curve, Less documentation | Laravel projects, Internal tools
Laravel Nova | Official, Feature-rich, Great support | Expensive ($199/site), Less customizable | Enterprise, Budget available
Backpack for Laravel | Easy for beginners, Many addons | Performance issues, Complex setup | Quick prototypes, Small projects
Custom Build | Full control, Tailored to needs | Time-consuming, Maintenance heavy | Unique requirements, Large teams
WordPress | User-friendly, Huge ecosystem | Not Laravel, Performance issues | Non-technical users, Content-focused
Keunggulan Filament untuk Blog System:
Arsitektur Filament:
text
Laravel Application ├── Filament Panel (Admin Area) │ ├── Resources (CRUD interfaces) │ ├── Pages (Custom pages) │ ├── Widgets (Dashboard components) │ └── Plugins (Extensions) └── Public Frontend (Blog untuk visitor)
C. Instalasi & Setup (45 menit - PRAKTIKUM)
Langkah 1: Install Filament:
bash
# Di project Laravel yang sudah ada composer require filament/filament:"^3.0" -W # Publish assets php artisan filament:install --panels # Install plugin untuk roles/permissions (opsional tapi recommended) composer require spatie/laravel-permission php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider" php artisan migrate
Langkah 2: Setup User Model:
php
// app/Models/User.php
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable implements FilamentUser
{
use HasFactory, Notifiable, HasRoles;
/**
* Determine if the user can access the Filament admin panel.
*/
public function canAccessPanel(Panel $panel): bool
{
// Hanya user dengan role admin atau author yang bisa akses
return $this->hasAnyRole(['admin', 'author']);
}
// Tambahkan ke $fillable
protected $fillable = [
'name',
'email',
'password',
'role',
'bio',
'avatar',
];
}Langkah 3: Konfigurasi Panel:
bash
# Buat provider untuk panel php artisan make:provider/Filament/AdminPanelProvider
php
<?php
// app/Providers/Filament/AdminPanelProvider.php
namespace App\Providers\Filament;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Pages;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\Widgets;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->login()
->colors([
'primary' => Color::Indigo,
'danger' => Color::Rose,
'success' => Color::Emerald,
'warning' => Color::Orange,
])
->font('Inter')
->favicon(asset('images/favicon.ico'))
->brandLogo(asset('images/logo.svg'))
->brandLogoHeight('2rem')
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
->pages([
Pages\Dashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->widgets([
Widgets\AccountWidget::class,
Widgets\FilamentInfoWidget::class,
])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
])
->plugins([
\BezhanSalleh\FilamentShield\FilamentShieldPlugin::make(),
]);
}
}Langkah 4: Register Provider:
php
// config/app.php
'providers' => [
// ...
App\Providers\Filament\AdminPanelProvider::class,
],Langkah 5: Create Admin User:
bash
# Buat user admin via tinker
php artisan tinker
>>> $user = App\Models\User::create([
... 'name' => 'Admin',
... 'email' => 'admin@blog.com',
... 'password' => bcrypt('password123'),
... 'role' => 'admin'
... ]);
>>> $user->assignRole('admin');Langkah 6: Test Panel:
bash
php artisan serve # Buka http://localhost:8000/admin # Login dengan admin@blog.com / password123
D. Struktur Panel Filament (30 menit)
Directory Structure:
text
app/ ├── Filament/ │ ├── Resources/ # CRUD resources │ │ ├── PostResource.php │ │ ├── CategoryResource.php │ │ └── ... │ ├── Pages/ # Custom pages │ │ ├── Dashboard.php │ │ ├── Settings.php │ │ └── ... │ └── Widgets/ # Dashboard widgets │ ├── BlogStats.php │ ├── RecentPosts.php │ └── ...
Navigation Structure:
php
// Di dalam resource atau panel config
public static function getNavigationItems(): array
{
return [
NavigationItem::make('Blog')
->icon('heroicon-o-book-open')
->sort(1)
->children([
NavigationItem::make('Posts')
->url(route('filament.admin.resources.posts.index'))
->icon('heroicon-o-document-text')
->sort(1),
NavigationItem::make('Categories')
->url(route('filament.admin.resources.categories.index'))
->icon('heroicon-o-tag')
->sort(2),
NavigationItem::make('Tags')
->url(route('filament.admin.resources.tags.index'))
->icon('heroicon-o-hashtag')
->sort(3),
NavigationItem::make('Comments')
->url(route('filament.admin.resources.comments.index'))
->icon('heroicon-o-chat-bubble-left-ellipsis')
->sort(4),
]),
NavigationItem::make('Portfolio')
->icon('heroicon-o-briefcase')
->sort(2)
->url(route('filament.admin.resources.portfolios.index')),
NavigationItem::make('Users')
->icon('heroicon-o-users')
->sort(3)
->url(route('filament.admin.resources.users.index')),
];
}
E. Resource & CRUD Otomatis (60 menit - PRAKTIKUM)
1. Membuat Resource untuk Post:
bash
php artisan make:filament-resource Post --generate # Flag --generate akan membuat semua file: Resource, Pages, Relation Managers
2. Post Resource Structure:
php
<?php
// app/Filament/Resources/PostResource.php
namespace App\Filament\Resources;
use App\Filament\Resources\PostResource\Pages;
use App\Filament\Resources\PostResource\RelationManagers;
use App\Models\Post;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class PostResource extends Resource
{
protected static ?string $model = Post::class;
protected static ?string $navigationIcon = 'heroicon-o-document-text';
protected static ?string $navigationGroup = 'Blog';
protected static ?int $navigationSort = 1;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make('Post Content')
->schema([
Forms\Components\TextInput::make('title')
->required()
->maxLength(200)
->live(onBlur: true)
->afterStateUpdated(function ($state, $set) {
if (empty($state)) return;
$set('slug', \Str::slug($state));
}),
Forms\Components\TextInput::make('slug')
->required()
->maxLength(220)
->unique(ignorable: fn ($record) => $record),
Forms\Components\Select::make('user_id')
->relationship('user', 'name')
->default(auth()->id())
->required()
->searchable()
->preload(),
Forms\Components\Select::make('category_id')
->relationship('category', 'name')
->searchable()
->preload()
->createOptionForm([
Forms\Components\TextInput::make('name')
->required()
->maxLength(50),
Forms\Components\TextInput::make('slug')
->required()
->maxLength(60),
]),
Forms\Components\RichEditor::make('content')
->required()
->columnSpanFull()
->fileAttachmentsDisk('public')
->fileAttachmentsDirectory('posts')
->fileAttachmentsVisibility('public'),
Forms\Components\Textarea::make('excerpt')
->rows(3)
->maxLength(500),
])
->columns(2),
Forms\Components\Section::make('Featured Image')
->schema([
Forms\Components\FileUpload::make('featured_image')
->disk('public')
->directory('posts')
->image()
->maxSize(5120)
->imageResizeMode('cover')
->imageCropAspectRatio('16:9')
->imageResizeTargetWidth('1200')
->imageResizeTargetHeight('630'),
])
->collapsible(),
Forms\Components\Section::make('Publishing')
->schema([
Forms\Components\Select::make('status')
->options([
'draft' => 'Draft',
'published' => 'Published',
'archived' => 'Archived',
])
->default('draft')
->required(),
Forms\Components\DateTimePicker::make('published_at')
->hidden(fn ($get) => $get('status') !== 'published')
->required(fn ($get) => $get('status') === 'published'),
Forms\Components\Toggle::make('is_featured')
->label('Featured Post')
->default(false),
])
->columns(2),
Forms\Components\Section::make('SEO Settings')
->schema([
Forms\Components\TextInput::make('meta_title')
->maxLength(200),
Forms\Components\Textarea::make('meta_description')
->rows(2)
->maxLength(500),
])
->collapsible(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\ImageColumn::make('featured_image')
->circular()
->defaultImageUrl(url('/images/default-post.jpg')),
Tables\Columns\TextColumn::make('title')
->searchable()
->sortable()
->limit(50)
->tooltip(fn ($record) => $record->title),
Tables\Columns\TextColumn::make('user.name')
->label('Author')
->sortable()
->searchable(),
Tables\Columns\TextColumn::make('category.name')
->sortable()
->searchable()
->badge()
->color('info'),
Tables\Columns\SelectColumn::make('status')
->options([
'draft' => 'Draft',
'published' => 'Published',
'archived' => 'Archived',
])
->selectablePlaceholder(false)
->sortable(),
Tables\Columns\TextColumn::make('published_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: false),
Tables\Columns\TextColumn::make('view_count')
->numeric()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->options([
'draft' => 'Draft',
'published' => 'Published',
'archived' => 'Archived',
]),
Tables\Filters\SelectFilter::make('category')
->relationship('category', 'name')
->searchable()
->preload(),
Tables\Filters\SelectFilter::make('author')
->relationship('user', 'name')
->searchable()
->preload(),
Tables\Filters\Filter::make('published_at')
->form([
Forms\Components\DatePicker::make('published_from'),
Forms\Components\DatePicker::make('published_until'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when(
$data['published_from'],
fn (Builder $query, $date): Builder => $query->whereDate('published_at', '>=', $date),
)
->when(
$data['published_until'],
fn (Builder $query, $date): Builder => $query->whereDate('published_at', '<=', $date),
);
}),
Tables\Filters\TrashedFilter::make(),
])
->actions([
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
Tables\Actions\Action::make('publish')
->action(function (Post $record) {
$record->update([
'status' => 'published',
'published_at' => now(),
]);
})
->requiresConfirmation()
->modalHeading('Publish Post')
->modalDescription('Are you sure you want to publish this post?')
->modalSubmitActionLabel('Yes, publish it')
->color('success')
->icon('heroicon-o-check-circle')
->hidden(fn (Post $record): bool => $record->status === 'published'),
Tables\Actions\DeleteAction::make(),
Tables\Actions\ForceDeleteAction::make(),
Tables\Actions\RestoreAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
Tables\Actions\ForceDeleteBulkAction::make(),
Tables\Actions\RestoreBulkAction::make(),
Tables\Actions\BulkAction::make('publish')
->action(function ($records) {
$records->each->update([
'status' => 'published',
'published_at' => now(),
]);
})
->requiresConfirmation()
->deselectRecordsAfterCompletion()
->color('success')
->icon('heroicon-o-check-circle'),
]),
])
->defaultSort('published_at', 'desc')
->persistSortInSession()
->persistFiltersInSession();
}
public static function getRelations(): array
{
return [
RelationManagers\TagsRelationManager::class,
RelationManagers\CommentsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListPosts::route('/'),
'create' => Pages\CreatePost::route('/create'),
'view' => Pages\ViewPost::route('/{record}'),
'edit' => Pages\EditPost::route('/{record}/edit'),
];
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->withoutGlobalScopes([
SoftDeletingScope::class,
]);
}
public static function getGloballySearchableAttributes(): array
{
return ['title', 'content', 'slug'];
}
}3. Generate Resources Lainnya:
bash
# Generate semua resources sekaligus php artisan make:filament-resource Category --generate php artisan make:filament-resource Tag --generate php artisan make:filament-resource Comment --generate php artisan make:filament-resource Portfolio --generate php artisan make:filament-resource User --generate
F. Role & Permission dengan Filament Shield (45 menit)
1. Install & Configure Shield:
bash
# Sudah diinstall sebelumnya, sekarang setup php artisan shield:install --fresh # Generate permissions untuk resources php artisan shield:generate
2. Custom Permission untuk Blog:
php
// app/Providers/Filament/AdminPanelProvider.php
->plugins([
\BezhanSalleh\FilamentShield\FilamentShieldPlugin::make()
->gridColumns([
'default' => 1,
'sm' => 2,
'lg' => 3
])
->sectionColumnSpan(1)
->checkboxListColumns([
'default' => 1,
'sm' => 2,
'lg' => 3,
])
->resourceCheckboxListColumns([
'default' => 1,
'sm' => 2,
]),
])3. Role-based Access di Resource:
php
// app/Filament/Resources/PostResource.php
class PostResource extends Resource
{
// ...
public static function canViewAny(): bool
{
return auth()->user()->can('view_post');
}
public static function canCreate(): bool
{
return auth()->user()->can('create_post');
}
// Atau gunakan middleware di panel
public static function getNavigationItems(): array
{
return [
NavigationItem::make('Posts')
->visible(fn (): bool => auth()->user()->can('view_post'))
// ...
];
}
}4. Resource Permission Policies:
php
<?php
// app/Policies/PostPolicy.php
// Pastikan sudah dibuat di Fase 2
// app/Providers/AuthServiceProvider.php
protected $policies = [
Post::class => PostPolicy::class,
// ...
];5. Custom Role untuk Author:
php
// Buat role 'author' dengan permissions terbatas
$authorPermissions = [
'view_post',
'create_post',
'update_post', // hanya post milik sendiri
'delete_post', // hanya post milik sendiri
'view_category',
'view_tag',
'view_comment',
'update_comment', // untuk moderate
];
$role = Role::create(['name' => 'author']);
$role->givePermissionTo($authorPermissions);
// Assign ke user
$user->assignRole('author');
G. Praktikum: Setup Admin Panel Lengkap (60 menit)
Tugas: Setup Filament panel dengan semua resources
Langkah-langkah:
Durasi: 3 jam (Teori: 1 jam, Praktik: 2 jam)
A. Review & Q&A (30 menit)
B. Advanced Form Schema (60 menit)
1. Conditional Fields & Logic:
php
Forms\Components\Section::make('Publishing Options')
->schema([
Forms\Components\Toggle::make('is_scheduled')
->label('Schedule Publication')
->reactive(),
Forms\Components\DateTimePicker::make('published_at')
->hidden(fn ($get) => !$get('is_scheduled'))
->required(fn ($get) => $get('is_scheduled'))
->helperText(fn ($get) => $get('is_scheduled')
? 'Post will be published at this time'
: null),
Forms\Components\Toggle::make('is_pinned')
->label('Pin to top')
->hidden(fn ($get) => $get('is_scheduled')),
]),2. Wizard Form untuk Post Creation:
php
// app/Filament/Resources/PostResource/Pages/CreatePost.php
protected function getFormSchema(): array
{
return [
Forms\Components\Wizard::make([
Forms\Components\Wizard\Step::make('Basic Info')
->icon('heroicon-o-information-circle')
->schema([
Forms\Components\TextInput::make('title')
->required()
->maxLength(200)
->live(onBlur: true)
->afterStateUpdated(function ($state, $set, $get) {
if (!$get('is_slug_customized')) {
$set('slug', \Str::slug($state));
}
}),
Forms\Components\TextInput::make('slug')
->required()
->maxLength(220)
->unique(ignorable: fn ($record) => $record)
->suffixAction(
Forms\Components\Actions\Action::make('generate')
->icon('heroicon-o-arrow-path')
->action(function ($set, $get) {
$set('slug', \Str::slug($get('title')));
})
),
Forms\Components\Toggle::make('is_slug_customized')
->label('Customize slug manually')
->default(false)
->inline(false),
]),
Forms\Components\Wizard\Step::make('Content')
->icon('heroicon-o-document-text')
->schema([
Forms\Components\Select::make('category_id')
->relationship('category', 'name')
->searchable()
->preload()
->createOptionForm([
Forms\Components\TextInput::make('name')
->required()
->maxLength(50),
Forms\Components\TextInput::make('slug')
->required()
->maxLength(60),
]),
Forms\Components\RichEditor::make('content')
->required()
->fileAttachmentsDisk('public')
->fileAttachmentsDirectory('posts')
->toolbarButtons([
'blockquote',
'bold',
'bulletList',
'codeBlock',
'h2',
'h3',
'italic',
'link',
'orderedList',
'redo',
'strike',
'undo',
]),
Forms\Components\Textarea::make('excerpt')
->rows(3)
->maxLength(500)
->helperText('If empty, will be auto-generated from content'),
]),
Forms\Components\Wizard\Step::make('Media & SEO')
->icon('heroicon-o-photo')
->schema([
Forms\Components\FileUpload::make('featured_image')
->disk('public')
->directory('posts')
->image()
->maxSize(5120)
->imageEditor()
->imageEditorAspectRatios([
null,
'16:9',
'4:3',
'1:1',
])
->panelAspectRatio('2:1'),
Forms\Components\SpatieMediaLibraryFileUpload::make('gallery')
->multiple()
->disk('public')
->directory('posts/gallery')
->maxFiles(10)
->image()
->reorderable(),
Forms\Components\Section::make('SEO Settings')
->schema([
Forms\Components\TextInput::make('meta_title')
->maxLength(200)
->helperText('If empty, will use post title'),
Forms\Components\Textarea::make('meta_description')
->rows(2)
->maxLength(500)
->helperText('If empty, will be auto-generated from excerpt'),
]),
]),
Forms\Components\Wizard\Step::make('Publishing')
->icon('heroicon-o-check-circle')
->schema([
Forms\Components\Select::make('status')
->options([
'draft' => 'Draft',
'published' => 'Published',
'archived' => 'Archived',
])
->default('draft')
->required()
->reactive(),
Forms\Components\DateTimePicker::make('published_at')
->hidden(fn ($get) => $get('status') !== 'published')
->required(fn ($get) => $get('status') === 'published')
->default(now()),
Forms\Components\Toggle::make('is_featured')
->label('Featured Post')
->default(false),
Forms\Components\Toggle::make('allow_comments')
->label('Allow Comments')
->default(true),
Forms\Components\Toggle::make('is_pinned')
->label('Pin to top of blog')
->default(false),
]),
])
->submitAction(view('filament.components.custom-submit-button'))
->skippable()
->persistStepInQueryString(),
];
}3. Custom Form Components:
php
// app/Forms/Components/ReadingTime.php
namespace App\Forms\Components;
use Filament\Forms\Components\Field;
class ReadingTime extends Field
{
protected string $view = 'forms.components.reading-time';
public function calculateReadingTime(string $content): int
{
$wordCount = str_word_count(strip_tags($content));
return ceil($wordCount / 200); // 200 words per minute
}
}
// resources/views/forms/components/reading-time.blade.php
<div>
<label class="filament-forms-field-wrapper-label" for="{{ $getId() }}">
{{ $getLabel() }}
</label>
<div class="p-3 bg-gray-50 rounded-lg">
<div class="text-sm text-gray-600">
Estimated reading time:
<span class="font-semibold">
{{ $calculateReadingTime($getState()) }} minutes
</span>
</div>
</div>
</div>
// Penggunaan di form
ReadingTime::make('reading_time')
->label('Reading Time Estimate')
->dehydrated(false) // Tidak disimpan ke database
->content(fn ($get) => $get('content')),4. Repeater untuk Meta Fields:
php
Forms\Components\Repeater::make('meta_fields')
->schema([
Forms\Components\TextInput::make('key')
->required()
->placeholder('e.g., og:image'),
Forms\Components\TextInput::make('value')
->required()
->placeholder('e.g., https://example.com/image.jpg'),
])
->defaultItems(0)
->collapsible()
->itemLabel(fn (array $state): ?string => $state['key'] ?? null)
->columnSpanFull(),
C. Advanced Table Customization (45 menit)
1. Custom Table Columns:
php
Tables\Columns\TextColumn::make('title')
->searchable()
->sortable()
->limit(50)
->tooltip(fn ($record): string => $record->title)
->description(fn ($record): string => $record->excerpt ? Str::limit($record->excerpt, 100) : '')
->extraAttributes(['class' => 'font-medium'])
->url(fn ($record): string => route('posts.show', $record))
->openUrlInNewTab(),
Tables\Columns\IconColumn::make('is_featured')
->boolean()
->trueIcon('heroicon-o-star')
->falseIcon('heroicon-o-star')
->trueColor('warning')
->falseColor('gray')
->sortable(),
Tables\Columns\BadgeColumn::make('status')
->colors([
'success' => 'published',
'warning' => 'draft',
'danger' => 'archived',
])
->icons([
'heroicon-o-check-circle' => 'published',
'heroicon-o-pencil' => 'draft',
'heroicon-o-archive-box' => 'archived',
])
->sortable(),
Tables\Columns\ViewColumn::make('preview')
->view('tables.columns.post-preview')
->label('Preview'),
// resources/views/tables/columns/post-preview.blade.php
<div class="flex items-center space-x-3">
<img
src="{{ $getRecord()->featured_image ? asset('storage/' . $getRecord()->featured_image) : '/images/default-post.jpg' }}"
alt="{{ $getRecord()->title }}"
class="w-12 h-12 rounded object-cover"
>
<div>
<div class="font-medium text-sm">{{ Str::limit($getRecord()->title, 40) }}</div>
<div class="text-xs text-gray-500">
{{ $getRecord()->category->name ?? 'Uncategorized' }}
• {{ $getRecord()->published_at?->format('M d, Y') ?? 'Draft' }}
</div>
</div>
</div>2. Table Filters dengan Custom Logic:
php
Tables\Filters\Filter::make('has_comments')
->label('Posts with Comments')
->query(fn (Builder $query): Builder => $query->has('comments')),
Tables\Filters\Filter::make('no_featured_image')
->label('Missing Featured Image')
->query(fn (Builder $query): Builder => $query->whereNull('featured_image')),
Tables\Filters\TernaryFilter::make('is_featured')
->label('Featured Posts')
->nullable(),
Tables\Filters\SelectFilter::make('tags')
->relationship('tags', 'name')
->multiple()
->preload()
->searchable(),
Tables\Filters\Filter::make('created_range')
->form([
Forms\Components\DatePicker::make('created_from')
->placeholder('From date'),
Forms\Components\DatePicker::make('created_until')
->placeholder('Until date'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when(
$data['created_from'],
fn (Builder $query, $date): Builder => $query->whereDate('created_at', '>=', $date),
)
->when(
$data['created_until'],
fn (Builder $query, $date): Builder => $query->whereDate('created_at', '<=', $date),
);
})
->indicateUsing(function (array $data): array {
$indicators = [];
if ($data['created_from']) {
$indicators['created_from'] = 'Created from ' . Carbon::parse($data['created_from'])->toFormattedDateString();
}
if ($data['created_until']) {
$indicators['created_until'] = 'Created until ' . Carbon::parse($data['created_until'])->toFormattedDateString();
}
return $indicators;
}),3. Table Actions dengan Custom Logic:
php
Tables\Actions\Action::make('view_on_site')
->label('View')
->url(fn (Post $record): string => route('posts.show', $record))
->openUrlInNewTab()
->icon('heroicon-o-eye')
->color('gray'),
Tables\Actions\Action::make('duplicate')
->label('Duplicate')
->icon('heroicon-o-document-duplicate')
->action(function (Post $record) {
$newPost = $record->replicate();
$newPost->title = $record->title . ' (Copy)';
$newPost->slug = $record->slug . '-copy';
$newPost->status = 'draft';
$newPost->published_at = null;
$newPost->view_count = 0;
$newPost->save();
// Duplicate tags
$newPost->tags()->sync($record->tags->pluck('id'));
Notification::make()
->title('Post duplicated successfully')
->success()
->send();
})
->requiresConfirmation()
->modalHeading('Duplicate Post')
->modalDescription('Are you sure you want to duplicate this post?')
->modalSubmitActionLabel('Yes, duplicate it'),
Tables\Actions\Action::make('export')
->label('Export')
->icon('heroicon-o-arrow-down-tray')
->color('success')
->action(function (Post $record) {
return response()->streamDownload(function () use ($record) {
echo json_encode([
'title' => $record->title,
'content' => $record->content,
'author' => $record->user->name,
'published_at' => $record->published_at,
'tags' => $record->tags->pluck('name'),
], JSON_PRETTY_PRINT);
}, "post-{$record->slug}.json");
}),
D. Relation Managers (45 menit)
1. Tags Relation Manager:
php
<?php
// app/Filament/Resources/PostResource/RelationManagers/TagsRelationManager.php
namespace App\Filament\Resources\PostResource\RelationManagers;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class TagsRelationManager extends RelationManager
{
protected static string $relationship = 'tags';
protected static ?string $recordTitleAttribute = 'name';
public function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(50)
->unique(ignorable: fn ($record) => $record),
Forms\Components\TextInput::make('slug')
->required()
->maxLength(60)
->unique(ignorable: fn ($record) => $record),
Forms\Components\ColorPicker::make('color')
->label('Tag Color')
->default('#6b7280'),
Forms\Components\Textarea::make('description')
->rows(2)
->maxLength(255),
]);
}
public function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\ColorColumn::make('color')
->sortable(),
Tables\Columns\TextColumn::make('posts_count')
->label('Usage')
->counts('posts')
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\Filter::make('unused')
->label('Unused Tags')
->query(fn ($query) => $query->doesntHave('posts')),
])
->headerActions([
Tables\Actions\CreateAction::make(),
Tables\Actions\AttachAction::make()
->preloadRecordSelect()
->form(fn (Tables\Actions\AttachAction $action): array => [
$action->getRecordSelect()
->searchable()
->preload(),
Forms\Components\Select::make('type')
->options([
'primary' => 'Primary Tag',
'secondary' => 'Secondary Tag',
])
->default('primary'),
]),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DetachAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DetachBulkAction::make(),
Tables\Actions\DeleteBulkAction::make(),
]),
])
->defaultSort('name');
}
}2. Comments Relation Manager:
php
<?php
// app/Filament/Resources/PostResource/RelationManagers/CommentsRelationManager.php
namespace App\Filament\Resources\PostResource\RelationManagers;
use App\Models\Comment;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class CommentsRelationManager extends RelationManager
{
protected static string $relationship = 'comments';
protected static ?string $recordTitleAttribute = 'content';
public function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Select::make('user_id')
->relationship('user', 'name')
->searchable()
->preload()
->createOptionForm([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255),
Forms\Components\TextInput::make('email')
->required()
->email()
->maxLength(255),
]),
Forms\Components\Textarea::make('content')
->required()
->rows(3)
->maxLength(1000),
Forms\Components\Select::make('status')
->options([
'pending' => 'Pending',
'approved' => 'Approved',
'spam' => 'Spam',
])
->default('pending')
->required(),
Forms\Components\Toggle::make('is_featured')
->label('Featured Comment'),
]);
}
public function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('user.name')
->label('Author')
->searchable()
->sortable()
->formatStateUsing(fn ($state, $record) =>
$record->user_id ? $state : ($record->author_name ?: 'Anonymous')
),
Tables\Columns\TextColumn::make('content')
->limit(100)
->searchable(),
Tables\Columns\BadgeColumn::make('status')
->colors([
'warning' => 'pending',
'success' => 'approved',
'danger' => 'spam',
])
->sortable(),
Tables\Columns\IconColumn::make('is_featured')
->boolean()
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable(),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->options([
'pending' => 'Pending',
'approved' => 'Approved',
'spam' => 'Spam',
]),
Tables\Filters\TernaryFilter::make('is_featured'),
Tables\Filters\Filter::make('has_replies')
->label('Has Replies')
->query(fn ($query) => $query->has('replies')),
])
->headerActions([
Tables\Actions\CreateAction::make(),
])
->actions([
Tables\Actions\Action::make('approve')
->action(fn (Comment $record) => $record->update(['status' => 'approved']))
->requiresConfirmation()
->color('success')
->icon('heroicon-o-check-circle')
->hidden(fn (Comment $record): bool => $record->status === 'approved'),
Tables\Actions\Action::make('reject')
->action(fn (Comment $record) => $record->update(['status' => 'spam']))
->requiresConfirmation()
->color('danger')
->icon('heroicon-o-x-circle')
->hidden(fn (Comment $record): bool => $record->status === 'spam'),
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\BulkAction::make('approve')
->action(fn ($records) => $records->each->update(['status' => 'approved']))
->requiresConfirmation()
->deselectRecordsAfterCompletion()
->color('success')
->icon('heroicon-o-check-circle'),
Tables\Actions\BulkAction::make('mark_as_spam')
->action(fn ($records) => $records->each->update(['status' => 'spam']))
->requiresConfirmation()
->deselectRecordsAfterCompletion()
->color('danger')
->icon('heroicon-o-x-circle'),
Tables\Actions\DeleteBulkAction::make(),
]),
])
->defaultSort('created_at', 'desc');
}
}
E. Custom Widgets untuk Dashboard (45 menit)
1. Blog Statistics Widget:
php
<?php
// app/Filament/Widgets/BlogStats.php
namespace App\Filament\Widgets;
use App\Models\Post;
use App\Models\Comment;
use App\Models\User;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class BlogStats extends BaseWidget
{
protected static ?string $pollingInterval = '30s';
protected static ?int $sort = 1;
protected function getStats(): array
{
return [
Stat::make('Total Posts', Post::count())
->description('All posts in the system')
->descriptionIcon('heroicon-o-document-text')
->color('primary')
->chart($this->getPostsChartData())
->extraAttributes(['class' => 'cursor-pointer'])
->url(route('filament.admin.resources.posts.index')),
Stat::make('Published Posts', Post::where('status', 'published')->count())
->description('Visible to public')
->descriptionIcon('heroicon-o-check-circle')
->color('success')
->chart($this->getPublishedPostsChartData()),
Stat::make('Draft Posts', Post::where('status', 'draft')->count())
->description('Waiting to be published')
->descriptionIcon('heroicon-o-pencil')
->color('warning'),
Stat::make('Total Comments', Comment::count())
->description('All comments')
->descriptionIcon('heroicon-o-chat-bubble-left-ellipsis')
->color('info')
->url(route('filament.admin.resources.comments.index')),
Stat::make('Pending Comments', Comment::where('status', 'pending')->count())
->description('Need moderation')
->descriptionIcon('heroicon-o-clock')
->color('danger')
->url(route('filament.admin.resources.comments.index', ['tableFilters[status][value]' => 'pending'])),
Stat::make('Total Users', User::count())
->description('Registered users')
->descriptionIcon('heroicon-o-users')
->color('gray')
->url(route('filament.admin.resources.users.index')),
];
}
private function getPostsChartData(): array
{
$data = [];
for ($i = 6; $i >= 0; $i--) {
$date = now()->subDays($i);
$data[] = Post::whereDate('created_at', $date)->count();
}
return $data;
}
private function getPublishedPostsChartData(): array
{
$data = [];
for ($i = 6; $i >= 0; $i--) {
$date = now()->subDays($i);
$data[] = Post::where('status', 'published')
->whereDate('published_at', $date)
->count();
}
return $data;
}
}2. Recent Posts Widget:
php
<?php
// app/Filament/Widgets/RecentPosts.php
namespace App\Filament\Widgets;
use App\Models\Post;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget as BaseWidget;
use Illuminate\Database\Eloquent\Builder;
class RecentPosts extends BaseWidget
{
protected int | string | array $columnSpan = 'full';
protected static ?int $sort = 2;
public function table(Table $table): Table
{
return $table
->query(
Post::query()
->with(['user', 'category'])
->latest()
->limit(10)
)
->columns([
Tables\Columns\ImageColumn::make('featured_image')
->label('')
->square()
->size(40),
Tables\Columns\TextColumn::make('title')
->searchable()
->sortable()
->limit(40),
Tables\Columns\TextColumn::make('user.name')
->label('Author')
->sortable()
->searchable(),
Tables\Columns\TextColumn::make('category.name')
->badge()
->color('gray'),
Tables\Columns\BadgeColumn::make('status')
->colors([
'success' => 'published',
'warning' => 'draft',
'danger' => 'archived',
]),
Tables\Columns\TextColumn::make('published_at')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('view_count')
->numeric()
->sortable()
->alignRight(),
])
->actions([
Tables\Actions\Action::make('view')
->url(fn (Post $record): string => route('filament.admin.resources.posts.view', $record))
->icon('heroicon-o-eye'),
Tables\Actions\Action::make('edit')
->url(fn (Post $record): string => route('filament.admin.resources.posts.edit', $record))
->icon('heroicon-o-pencil'),
])
->recordUrl(fn (Post $record): string => route('filament.admin.resources.posts.view', $record));
}
}3. Activity Feed Widget:
php
<?php
// app/Filament/Widgets/ActivityFeed.php
namespace App\Filament\Widgets;
use App\Models\AuditLog;
use Filament\Widgets\Widget;
use Illuminate\Database\Eloquent\Builder;
class ActivityFeed extends Widget
{
protected static string $view = 'filament.widgets.activity-feed';
protected static ?int $sort = 3;
protected int | string | array $columnSpan = 2;
public function getActivities()
{
return AuditLog::with(['user', 'subject'])
->latest()
->limit(20)
->get();
}
public function getIcon(string $action): string
{
return match($action) {
'created' => 'heroicon-o-plus-circle',
'updated' => 'heroicon-o-pencil',
'deleted' => 'heroicon-o-trash',
default => 'heroicon-o-information-circle',
};
}
public function getColor(string $action): string
{
return match($action) {
'created' => 'success',
'updated' => 'warning',
'deleted' => 'danger',
default => 'gray',
};
}
}blade
{{-- resources/views/filament/widgets/activity-feed.blade.php --}}
<x-filament-widgets::widget>
<x-filament::section heading="Recent Activity" icon="heroicon-o-clock">
<div class="space-y-4">
@foreach($this->getActivities() as $activity)
<div class="flex items-start space-x-3 p-2 rounded-lg hover:bg-gray-50">
<x-filament::icon
:icon="$this->getIcon($activity->action)"
class="h-5 w-5 text-{{ $this->getColor($activity->action) }}-500 mt-0.5"
/>
<div class="flex-1 min-w-0">
<div class="text-sm text-gray-900">
<span class="font-medium">{{ $activity->user?->name ?? 'System' }}</span>
{{ $activity->action }}
@if($activity->subject)
<a
href="{{ $this->getSubjectUrl($activity) }}"
class="font-medium text-primary-600 hover:text-primary-500"
>
{{ $this->getSubjectName($activity) }}
</a>
@else
<span class="font-medium">{{ $activity->model_type }}</span>
@endif
</div>
<div class="text-xs text-gray-500 mt-1">
{{ $activity->created_at->diffForHumans() }}
@if($activity->ip_address)
• {{ $activity->ip_address }}
@endif
</div>
@if($activity->action === 'updated' && !empty($activity->changes))
<div class="mt-2 text-xs text-gray-600">
@foreach($activity->changes as $field => $values)
<div>
<span class="font-medium">{{ $field }}:</span>
{{ $values['old'] ?? 'null' }} → {{ $values['new'] ?? 'null' }}
</div>
@endforeach
</div>
@endif
</div>
</div>
@endforeach
@if($this->getActivities()->isEmpty())
<div class="text-center py-8 text-gray-500">
<x-filament::icon icon="heroicon-o-inbox" class="h-12 w-12 mx-auto mb-2" />
<p>No activity yet</p>
</div>
@endif
</div>
</x-filament::section>
</x-filament-widgets::widget>
F. Praktikum: Advanced Admin Panel (60 menit)
Tugas: Enhance admin panel dengan advanced features
Langkah-langkah:
Durasi: 3 jam (Teori: 1 jam, Praktik: 2 jam)
A. Review & Advanced Challenges (30 menit)
B. Handling Many-to-Many dengan Tag Input (60 menit)
1. Select2-like Tag Input:
php
use Filament\Forms\Components\Select;
Forms\Components\Select::make('tags')
->relationship('tags', 'name')
->multiple()
->preload()
->searchable()
->createOptionForm([
Forms\Components\TextInput::make('name')
->required()
->maxLength(50)
->live(onBlur: true)
->afterStateUpdated(function ($state, $set) {
$set('slug', \Str::slug($state));
}),
Forms\Components\TextInput::make('slug')
->required()
->maxLength(60),
Forms\Components\ColorPicker::make('color')
->default('#6b7280'),
])
->createOptionUsing(function (array $data) {
$tag = \App\Models\Tag::create($data);
return $tag->id;
})
->suggestions(
\App\Models\Tag::query()
->whereNotIn('id', $this->getRecord()?->tags->pluck('id') ?? [])
->limit(50)
->pluck('name')
->toArray()
),2. Custom Tags Input Component:
php
<?php
// app/Forms/Components/TagsInput.php
namespace App\Forms\Components;
use Closure;
use Filament\Forms\Components\Field;
use Illuminate\Database\Eloquent\Model;
class TagsInput extends Field
{
protected string $view = 'forms.components.tags-input';
protected string | Closure | null $relationship = null;
protected function setUp(): void
{
parent::setUp();
$this->afterStateHydrated(function (TagsInput $component, $state) {
if (!$component->getRelationship()) {
return;
}
$tags = $component->getRelationship()->get();
$component->state($tags->pluck('id')->toArray());
});
$this->dehydrated(false);
$this->afterStateUpdated(function (TagsInput $component, $state) {
if (!$component->getRelationship()) {
return;
}
$component->getRelationship()->sync($state);
});
}
public function relationship(string | Closure $relationship): static
{
$this->relationship = $relationship;
return $this;
}
public function getRelationship(): ?Model
{
$model = $this->getModel();
if (!$model) {
return null;
}
return $model->{$this->evaluate($this->relationship)}();
}
public function getSuggestions(): array
{
return \App\Models\Tag::query()
->orderBy('name')
->pluck('name')
->toArray();
}
}blade
{{-- resources/views/forms/components/tags-input.blade.php --}}
<div x-data="{
tags: @if($getState()) {{ json_encode($getState()) }} @else [] @endif,
suggestions: {{ json_encode($getSuggestions()) }},
newTag: '',
showSuggestions: false,
filteredSuggestions: [],
init() {
this.filteredSuggestions = this.suggestions;
this.$watch('newTag', (value) => {
if (!value) {
this.filteredSuggestions = this.suggestions;
this.showSuggestions = false;
return;
}
this.filteredSuggestions = this.suggestions.filter(
suggestion => suggestion.toLowerCase().includes(value.toLowerCase())
);
this.showSuggestions = this.filteredSuggestions.length > 0;
});
},
addTag(tag = null) {
const tagToAdd = tag || this.newTag.trim();
if (!tagToAdd || this.tags.includes(tagToAdd)) {
this.newTag = '';
return;
}
this.tags.push(tagToAdd);
this.newTag = '';
this.showSuggestions = false;
this.$dispatch('input', this.tags);
},
removeTag(index) {
this.tags.splice(index, 1);
this.$dispatch('input', this.tags);
}
}">
<label class="filament-forms-field-wrapper-label" for="{{ $getId() }}">
{{ $getLabel() }}
</label>
<div class="mt-1">
{{-- Selected Tags --}}
<div class="flex flex-wrap gap-2 mb-2">
<template x-for="(tag, index) in tags" :key="index">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-primary-100 text-primary-800">
<span x-text="tag"></span>
<button
type="button"
@click="removeTag(index)"
class="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full hover:bg-primary-200"
>
×
</button>
</span>
</template>
</div>
{{-- Input & Suggestions --}}
<div class="relative">
<input
type="text"
x-model="newTag"
@keydown.enter.prevent="addTag()"
@blur="setTimeout(() => showSuggestions = false, 200)"
@focus="showSuggestions = true"
placeholder="Type a tag and press Enter..."
class="w-full rounded-lg border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
/>
{{-- Suggestions Dropdown --}}
<div
x-show="showSuggestions && filteredSuggestions.length"
x-transition
class="absolute z-10 w-full mt-1 bg-white rounded-lg shadow-lg border border-gray-200 max-h-60 overflow-auto"
>
<template x-for="suggestion in filteredSuggestions" :key="suggestion">
<button
type="button"
@click="addTag(suggestion)"
@mousedown.prevent
class="w-full text-left px-4 py-2 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
>
<span x-text="suggestion"></span>
</button>
</template>
</div>
</div>
<p class="mt-1 text-sm text-gray-500">
Type and press Enter to add tags. Click on a tag to remove it.
</p>
</div>
{{-- Hidden input for form submission --}}
<input type="hidden" name="{{ $getName() }}" :value="JSON.stringify(tags)" />
</div>3. Tags Management Resource:
php
<?php
// app/Filament/Resources/TagResource.php
namespace App\Filament\Resources;
use App\Filament\Resources\TagResource\Pages;
use App\Models\Tag;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class TagResource extends Resource
{
protected static ?string $model = Tag::class;
protected static ?string $navigationIcon = 'heroicon-o-hashtag';
protected static ?string $navigationGroup = 'Blog';
protected static ?int $navigationSort = 3;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make()
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(50)
->unique(ignorable: fn ($record) => $record)
->live(onBlur: true)
->afterStateUpdated(function ($state, $set, $get) {
if (!$get('is_slug_customized')) {
$set('slug', \Str::slug($state));
}
}),
Forms\Components\TextInput::make('slug')
->required()
->maxLength(60)
->unique(ignorable: fn ($record) => $record)
->suffixAction(
Forms\Components\Actions\Action::make('regenerate')
->icon('heroicon-o-arrow-path')
->action(function ($set, $get) {
$set('slug', \Str::slug($get('name')));
})
),
Forms\Components\Toggle::make('is_slug_customized')
->label('Customize slug manually')
->default(false)
->inline(false),
Forms\Components\ColorPicker::make('color')
->default('#6b7280'),
Forms\Components\Textarea::make('description')
->rows(2)
->maxLength(255),
])
->columns(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\ColorColumn::make('color')
->size(30),
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('slug')
->searchable()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('posts_count')
->label('Posts')
->counts('posts')
->sortable()
->color('gray'),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\Filter::make('unused')
->label('Unused Tags')
->query(fn ($query) => $query->doesntHave('posts')),
Tables\Filters\Filter::make('popular')
->label('Popular Tags (10+ posts)')
->query(fn ($query) => $query->has('posts', '>=', 10)),
])
->actions([
Tables\Actions\Action::make('merge')
->icon('heroicon-o-arrow-path')
->form([
Forms\Components\Select::make('target_tag_id')
->label('Merge into tag')
->options(Tag::query()->pluck('name', 'id'))
->searchable()
->required()
->disabled(fn ($record) => $record->id),
])
->action(function (Tag $record, array $data) {
$targetTag = Tag::find($data['target_tag_id']);
// Transfer all posts to target tag
$record->posts()->each(function ($post) use ($targetTag) {
$post->tags()->syncWithoutDetaching([$targetTag->id]);
});
// Detach from all posts
$record->posts()->detach();
// Delete the tag
$record->delete();
Notification::make()
->title('Tag merged successfully')
->success()
->send();
})
->requiresConfirmation()
->modalHeading('Merge Tag')
->modalDescription('This will transfer all posts from this tag to the target tag, then delete this tag.')
->modalSubmitActionLabel('Merge'),
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
Tables\Actions\BulkAction::make('merge_selected')
->icon('heroicon-o-arrow-path')
->form([
Forms\Components\Select::make('target_tag_id')
->label('Merge into tag')
->options(Tag::query()->pluck('name', 'id'))
->searchable()
->required(),
])
->action(function ($records, array $data) {
$targetTag = Tag::find($data['target_tag_id']);
$records->each(function ($tag) use ($targetTag) {
if ($tag->id === $targetTag->id) return;
$tag->posts()->each(function ($post) use ($targetTag) {
$post->tags()->syncWithoutDetaching([$targetTag->id]);
});
$tag->posts()->detach();
$tag->delete();
});
Notification::make()
->title('Tags merged successfully')
->success()
->send();
})
->requiresConfirmation()
->deselectRecordsAfterCompletion(),
]),
])
->defaultSort('name');
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListTags::route('/'),
'create' => Pages\CreateTag::route('/create'),
'edit' => Pages\EditTag::route('/{record}/edit'),
];
}
}
C. Auto-slug & Observers (45 menit)
1. Observer untuk Auto-slug:
php
<?php
// app/Observers/PostObserver.php
namespace App\Observers;
use App\Models\Post;
use Illuminate\Support\Str;
class PostObserver
{
/**
* Handle the Post "creating" event.
*/
public function creating(Post $post): void
{
if (empty($post->slug)) {
$post->slug = $this->generateUniqueSlug($post->title);
}
// Set default excerpt if empty
if (empty($post->excerpt)) {
$post->excerpt = $this->generateExcerpt($post->content);
}
// Set default meta fields if empty
if (empty($post->meta_title)) {
$post->meta_title = $post->title;
}
if (empty($post->meta_description)) {
$post->meta_description = $post->excerpt;
}
}
/**
* Handle the Post "updating" event.
*/
public function updating(Post $post): void
{
// Regenerate slug if title changed
if ($post->isDirty('title') && !$post->isDirty('slug')) {
$post->slug = $this->generateUniqueSlug($post->title, $post->id);
}
// Regenerate excerpt if content changed and excerpt is empty or auto-generated
if ($post->isDirty('content') && (empty($post->excerpt) || $post->excerpt === $this->generateExcerpt($post->getOriginal('content')))) {
$post->excerpt = $this->generateExcerpt($post->content);
}
// Update published_at based on status
if ($post->isDirty('status')) {
if ($post->status === 'published' && empty($post->published_at)) {
$post->published_at = now();
} elseif ($post->status === 'draft') {
$post->published_at = null;
}
}
}
/**
* Handle the Post "saving" event.
*/
public function saving(Post $post): void
{
// Ensure slug is unique
if ($post->isDirty('slug')) {
$post->slug = $this->makeSlugUnique($post->slug, $post->id);
}
}
/**
* Generate a unique slug.
*/
private function generateUniqueSlug(string $title, ?int $ignoreId = null): string
{
$slug = Str::slug($title);
return $this->makeSlugUnique($slug, $ignoreId);
}
/**
* Make slug unique by appending number if needed.
*/
private function makeSlugUnique(string $slug, ?int $ignoreId = null): string
{
$originalSlug = $slug;
$counter = 1;
while (Post::where('slug', $slug)
->when($ignoreId, fn($q) => $q->where('id', '!=', $ignoreId))
->exists()) {
$slug = $originalSlug . '-' . $counter;
$counter++;
}
return $slug;
}
/**
* Generate excerpt from content.
*/
private function generateExcerpt(string $content, int $length = 150): string
{
$excerpt = strip_tags($content);
$excerpt = str_replace(["\r", "\n"], ' ', $excerpt);
return Str::limit($excerpt, $length);
}
}2. Observer untuk Category & Tag:
php
<?php
// app/Observers/CategoryObserver.php
namespace App\Observers;
use App\Models\Category;
use Illuminate\Support\Str;
class CategoryObserver
{
public function creating(Category $category): void
{
if (empty($category->slug)) {
$category->slug = $this->generateUniqueSlug($category->name);
}
}
public function updating(Category $category): void
{
if ($category->isDirty('name') && !$category->isDirty('slug')) {
$category->slug = $this->generateUniqueSlug($category->name, $category->id);
}
}
private function generateUniqueSlug(string $name, ?int $ignoreId = null): string
{
$slug = Str::slug($name);
$originalSlug = $slug;
$counter = 1;
while (Category::where('slug', $slug)
->when($ignoreId, fn($q) => $q->where('id', '!=', $ignoreId))
->exists()) {
$slug = $originalSlug . '-' . $counter;
$counter++;
}
return $slug;
}
}3. Register Observers:
php
// app/Providers/EventServiceProvider.php
protected $observers = [
Post::class => [PostObserver::class],
Category::class => [CategoryObserver::class],
Tag::class => [TagObserver::class],
];
D. Integrasi Database Triggers (45 menit)
1. Migration untuk Database Triggers:
php
<?php
// database/migrations/xxxx_add_triggers.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// Trigger untuk update updated_at otomatis
DB::unprepared('
CREATE TRIGGER posts_update_timestamp
BEFORE UPDATE ON posts
FOR EACH ROW
SET NEW.updated_at = CURRENT_TIMESTAMP;
');
// Trigger untuk log perubahan
DB::unprepared('
CREATE TRIGGER posts_log_changes
AFTER UPDATE ON posts
FOR EACH ROW
BEGIN
INSERT INTO post_audit_logs
(post_id, field_name, old_value, new_value, changed_by, changed_at)
VALUES
(NEW.id, "title", OLD.title, NEW.title, @user_id, NOW()),
(NEW.id, "status", OLD.status, NEW.status, @user_id, NOW()),
(NEW.id, "content", LEFT(OLD.content, 1000), LEFT(NEW.content, 1000), @user_id, NOW());
END;
');
// Trigger untuk update view_count
DB::unprepared('
CREATE TRIGGER posts_increment_view_count
AFTER INSERT ON post_views
FOR EACH ROW
UPDATE posts SET view_count = view_count + 1 WHERE id = NEW.post_id;
');
}
public function down(): void
{
DB::unprepared('DROP TRIGGER IF EXISTS posts_update_timestamp');
DB::unprepared('DROP TRIGGER IF EXISTS posts_log_changes');
DB::unprepared('DROP TRIGGER IF EXISTS posts_increment_view_count');
}
};2. Eloquent dengan Database Triggers:
php
// app/Models/Post.php
class Post extends Model
{
// ...
/**
* Method untuk set user_id sebelum trigger
*/
public static function boot()
{
parent::boot();
static::updating(function ($model) {
// Set session variable untuk trigger
if (auth()->check()) {
DB::statement('SET @user_id = ?', [auth()->id()]);
}
});
}
/**
* Increment view count via database trigger
*/
public function incrementViewCount()
{
DB::table('post_views')->insert([
'post_id' => $this->id,
'user_id' => auth()->id(),
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
'viewed_at' => now(),
]);
// Trigger akan otomatis increment view_count
}
}3. Audit Log dengan Triggers:
php
// Migration untuk audit log table
Schema::create('post_audit_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->constrained()->onDelete('cascade');
$table->string('field_name');
$table->text('old_value')->nullable();
$table->text('new_value')->nullable();
$table->foreignId('changed_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamp('changed_at');
$table->index('post_id');
$table->index('changed_at');
});
// Model untuk audit log
class PostAuditLog extends Model
{
protected $table = 'post_audit_logs';
protected $fillable = [
'post_id',
'field_name',
'old_value',
'new_value',
'changed_by',
'changed_at',
];
public function post()
{
return $this->belongsTo(Post::class);
}
public function user()
{
return $this->belongsTo(User::class, 'changed_by');
}
}
E. Studi Kasus: Post Publishing Flow (60 menit - PRAKTIKUM)
Complete Publishing Workflow:
1. Publishing State Machine:
php
<?php
// app/Models/Enums/PostStatus.php
namespace App\Models\Enums;
enum PostStatus: string
{
case DRAFT = 'draft';
case REVIEW = 'review';
case SCHEDULED = 'scheduled';
case PUBLISHED = 'published';
case ARCHIVED = 'archived';
public function label(): string
{
return match($this) {
self::DRAFT => 'Draft',
self::REVIEW => 'In Review',
self::SCHEDULED => 'Scheduled',
self::PUBLISHED => 'Published',
self::ARCHIVED => 'Archived',
};
}
public function color(): string
{
return match($this) {
self::DRAFT => 'gray',
self::REVIEW => 'yellow',
self::SCHEDULED => 'blue',
self::PUBLISHED => 'green',
self::ARCHIVED => 'red',
};
}
public function icon(): string
{
return match($this) {
self::DRAFT => 'heroicon-o-pencil',
self::REVIEW => 'heroicon-o-clock',
self::SCHEDULED => 'heroicon-o-calendar',
self::PUBLISHED => 'heroicon-o-check-circle',
self::ARCHIVED => 'heroicon-o-archive-box',
};
}
public function transitions(): array
{
return match($this) {
self::DRAFT => [self::REVIEW, self::SCHEDULED, self::PUBLISHED],
self::REVIEW => [self::DRAFT, self::SCHEDULED, self::PUBLISHED],
self::SCHEDULED => [self::DRAFT, self::PUBLISHED, self::ARCHIVED],
self::PUBLISHED => [self::DRAFT, self::ARCHIVED],
self::ARCHIVED => [self::DRAFT, self::PUBLISHED],
};
}
public function canTransitionTo(PostStatus $status): bool
{
return in_array($status, $this->transitions());
}
}2. Post Model dengan Publishing Workflow:
php
// app/Models/Post.php
use App\Models\Enums\PostStatus;
class Post extends Model
{
// ...
protected $casts = [
'status' => PostStatus::class,
'published_at' => 'datetime',
'scheduled_for' => 'datetime',
'reviewed_at' => 'datetime',
'reviewed_by' => 'integer',
];
/**
* Transition post status
*/
public function transitionTo(PostStatus $newStatus, ?string $notes = null): bool
{
if (!$this->status->canTransitionTo($newStatus)) {
throw new \Exception("Cannot transition from {$this->status->label()} to {$newStatus->label()}");
}
$this->status = $newStatus;
// Set timestamps based on status
switch ($newStatus) {
case PostStatus::REVIEW:
$this->submitted_for_review_at = now();
break;
case PostStatus::SCHEDULED:
// Scheduled for already set by user
break;
case PostStatus::PUBLISHED:
$this->published_at = $this->scheduled_for ?? now();
$this->scheduled_for = null;
break;
case PostStatus::ARCHIVED:
$this->archived_at = now();
break;
}
$saved = $this->save();
if ($saved) {
$this->logStatusChange($newStatus, $notes);
// Dispatch events
if ($newStatus === PostStatus::PUBLISHED) {
\App\Events\PostPublished::dispatch($this);
}
}
return $saved;
}
/**
* Submit for review
*/
public function submitForReview(?string $notes = null): bool
{
return $this->transitionTo(PostStatus::REVIEW, $notes);
}
/**
* Schedule publication
*/
public function scheduleForPublishing(\DateTime $publishAt, ?string $notes = null): bool
{
$this->scheduled_for = $publishAt;
return $this->transitionTo(PostStatus::SCHEDULED, $notes);
}
/**
* Publish immediately
*/
public function publish(?string $notes = null): bool
{
return $this->transitionTo(PostStatus::PUBLISHED, $notes);
}
/**
* Archive post
*/
public function archive(?string $notes = null): bool
{
return $this->transitionTo(PostStatus::ARCHIVED, $notes);
}
/**
* Return to draft
*/
public function returnToDraft(?string $notes = null): bool
{
return $this->transitionTo(PostStatus::DRAFT, $notes);
}
/**
* Log status change
*/
private function logStatusChange(PostStatus $newStatus, ?string $notes = null): void
{
PostStatusLog::create([
'post_id' => $this->id,
'from_status' => $this->getOriginal('status'),
'to_status' => $newStatus,
'changed_by' => auth()->id(),
'notes' => $notes,
]);
}
/**
* Scope for scheduled posts that should be published
*/
public function scopeShouldBePublished($query)
{
return $query->where('status', PostStatus::SCHEDULED)
->where('scheduled_for', '<=', now());
}
}3. Publishing Actions di Filament:
php
// app/Filament/Resources/PostResource.php
public static function table(Table $table): Table
{
return $table
->actions([
// ... existing actions
Tables\Actions\ActionGroup::make([
Tables\Actions\Action::make('submit_for_review')
->label('Submit for Review')
->icon('heroicon-o-clock')
->color('warning')
->action(function (Post $record) {
$record->submitForReview();
Notification::make()
->title('Post submitted for review')
->success()
->send();
})
->requiresConfirmation()
->hidden(fn (Post $record): bool =>
$record->status !== PostStatus::DRAFT
),
Tables\Actions\Action::make('approve')
->label('Approve & Publish')
->icon('heroicon-o-check-circle')
->color('success')
->form([
Forms\Components\DateTimePicker::make('publish_at')
->label('Publish Date')
->default(now())
->required(),
Forms\Components\Textarea::make('notes')
->label('Review Notes')
->rows(2),
])
->action(function (Post $record, array $data) {
if ($data['publish_at'] > now()) {
$record->scheduleForPublishing($data['publish_at'], $data['notes']);
} else {
$record->publish($data['notes']);
}
Notification::make()
->title('Post approved successfully')
->success()
->send();
})
->hidden(fn (Post $record): bool =>
!in_array($record->status, [PostStatus::REVIEW, PostStatus::SCHEDULED])
),
Tables\Actions\Action::make('schedule')
->label('Schedule Publication')
->icon('heroicon-o-calendar')
->color('blue')
->form([
Forms\Components\DateTimePicker::make('scheduled_for')
->label('Schedule For')
->minDate(now())
->required(),
Forms\Components\Textarea::make('notes')
->label('Notes')
->rows(2),
])
->action(function (Post $record, array $data) {
$record->scheduleForPublishing($data['scheduled_for'], $data['notes']);
Notification::make()
->title('Post scheduled successfully')
->success()
->send();
})
->hidden(fn (Post $record): bool =>
!in_array($record->status, [PostStatus::DRAFT, PostStatus::REVIEW])
),
Tables\Actions\Action::make('archive')
->label('Archive')
->icon('heroicon-o-archive-box')
->color('danger')
->action(function (Post $record) {
$record->archive();
Notification::make()
->title('Post archived')
->warning()
->send();
})
->requiresConfirmation()
->hidden(fn (Post $record): bool =>
$record->status === PostStatus::ARCHIVED
),
Tables\Actions\Action::make('restore_draft')
->label('Restore to Draft')
->icon('heroicon-o-arrow-uturn-left')
->color('gray')
->action(function (Post $record) {
$record->returnToDraft();
Notification::make()
->title('Post restored to draft')
->success()
->send();
})
->requiresConfirmation()
->hidden(fn (Post $record): bool =>
$record->status === PostStatus::DRAFT
),
])
->label('Workflow')
->icon('heroicon-o-cog-6-tooth')
->color('primary')
->button(),
]);
}
// Custom page untuk publishing queue
public static function getPages(): array
{
return [
'index' => Pages\ListPosts::route('/'),
'create' => Pages\CreatePost::route('/create'),
'edit' => Pages\EditPost::route('/{record}/edit'),
'view' => Pages\ViewPost::route('/{record}'),
'publishing-queue' => Pages\PublishingQueue::route('/publishing-queue'),
];
}4. Publishing Queue Page:
php
<?php
// app/Filament/Resources/PostResource/Pages/PublishingQueue.php
namespace App\Filament\Resources\PostResource\Pages;
use App\Filament\Resources\PostResource;
use App\Models\Post;
use App\Models\Enums\PostStatus;
use Filament\Actions;
use Filament\Resources\Pages\Page;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Tables\Contracts\HasTable;
use Illuminate\Database\Eloquent\Builder;
class PublishingQueue extends Page implements HasTable
{
use Tables\Concerns\InteractsWithTable;
protected static string $resource = PostResource::class;
protected static string $view = 'filament.resources.post-resource.pages.publishing-queue';
protected static ?string $navigationIcon = 'heroicon-o-queue-list';
protected static ?string $navigationLabel = 'Publishing Queue';
protected static ?int $navigationSort = 2;
public function mount(): void
{
abort_unless(auth()->user()->can('manage_publishing'), 403);
}
public function table(Table $table): Table
{
return $table
->query(
Post::query()
->whereIn('status', [PostStatus::REVIEW, PostStatus::SCHEDULED])
->orderBy('scheduled_for')
->orderBy('submitted_for_review_at')
)
->columns([
Tables\Columns\TextColumn::make('title')
->searchable()
->limit(50),
Tables\Columns\BadgeColumn::make('status')
->colors([
'warning' => PostStatus::REVIEW->value,
'blue' => PostStatus::SCHEDULED->value,
]),
Tables\Columns\TextColumn::make('user.name')
->label('Author'),
Tables\Columns\TextColumn::make('scheduled_for')
->dateTime()
->placeholder('Not scheduled')
->sortable(),
Tables\Columns\TextColumn::make('submitted_for_review_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('time_until_publish')
->label('Time Until Publish')
->getStateUsing(function (Post $record): string {
if (!$record->scheduled_for) {
return 'Manual publish required';
}
$diff = now()->diff($record->scheduled_for);
if ($diff->invert) {
return 'Should be published';
}
return $diff->format('%d days, %h hours');
})
->color(fn (Post $record): string =>
$record->scheduled_for && $record->scheduled_for <= now()->addHour()
? 'danger'
: 'gray'
),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->options([
PostStatus::REVIEW->value => 'In Review',
PostStatus::SCHEDULED->value => 'Scheduled',
]),
Tables\Filters\Filter::make('overdue')
->label('Overdue for publishing')
->query(fn ($query) => $query->where('scheduled_for', '<=', now())),
])
->actions([
Tables\Actions\Action::make('publish_now')
->label('Publish Now')
->icon('heroicon-o-check-circle')
->color('success')
->action(function (Post $record) {
$record->publish('Published from queue');
Notification::make()
->title('Post published')
->success()
->send();
})
->hidden(fn (Post $record): bool => $record->status !== PostStatus::SCHEDULED),
Tables\Actions\Action::make('reschedule')
->label('Reschedule')
->icon('heroicon-o-calendar')
->form([
Forms\Components\DateTimePicker::make('scheduled_for')
->label('New Schedule')
->minDate(now())
->required(),
])
->action(function (Post $record, array $data) {
$record->scheduleForPublishing($data['scheduled_for'], 'Rescheduled from queue');
Notification::make()
->title('Post rescheduled')
->success()
->send();
})
->hidden(fn (Post $record): bool => $record->status !== PostStatus::SCHEDULED),
Tables\Actions\Action::make('approve')
->label('Approve')
->icon('heroicon-o-check')
->color('success')
->form([
Forms\Components\DateTimePicker::make('publish_at')
->label('Publish Date')
->default(now()),
Forms\Components\Textarea::make('notes')
->label('Review Notes'),
])
->action(function (Post $record, array $data) {
if ($data['publish_at'] > now()) {
$record->scheduleForPublishing($data['publish_at'], $data['notes']);
} else {
$record->publish($data['notes']);
}
Notification::make()
->title('Post approved')
->success()
->send();
})
->hidden(fn (Post $record): bool => $record->status !== PostStatus::REVIEW),
Tables\Actions\Action::make('reject')
->label('Reject')
->icon('heroicon-o-x-circle')
->color('danger')
->form([
Forms\Components\Textarea::make('reason')
->label('Rejection Reason')
->required(),
])
->action(function (Post $record, array $data) {
$record->returnToDraft($data['reason']);
// Notify author
\App\Events\PostRejected::dispatch($record, auth()->user(), $data['reason']);
Notification::make()
->title('Post rejected and returned to draft')
->warning()
->send();
})
->hidden(fn (Post $record): bool => $record->status !== PostStatus::REVIEW),
])
->bulkActions([
Tables\Actions\BulkAction::make('bulk_publish')
->label('Publish Selected')
->icon('heroicon-o-check-circle')
->color('success')
->action(function ($records) {
$records->each->publish('Bulk published from queue');
Notification::make()
->title($records->count() . ' posts published')
->success()
->send();
})
->requiresConfirmation()
->deselectRecordsAfterCompletion(),
Tables\Actions\BulkAction::make('bulk_approve')
->label('Approve Selected')
->icon('heroicon-o-check')
->color('success')
->action(function ($records) {
$records->each->publish('Bulk approved from queue');
Notification::make()
->title($records->count() . ' posts approved')
->success()
->send();
})
->requiresConfirmation()
->deselectRecordsAfterCompletion(),
]);
}
protected function getHeaderActions(): array
{
return [
Actions\Action::make('process_scheduled')
->label('Process Scheduled Posts')
->icon('heroicon-o-play')
->color('success')
->action(function () {
$posts = Post::shouldBePublished()->get();
$posts->each->publish('Auto-published from queue');
Notification::make()
->title($posts->count() . ' posts auto-published')
->success()
->send();
})
->requiresConfirmation(),
];
}
}5. Scheduled Task untuk Auto-publishing:
php
// app/Console/Commands/PublishScheduledPosts.php
namespace App\Console\Commands;
use App\Models\Post;
use App\Models\Enums\PostStatus;
use Illuminate\Console\Command;
class PublishScheduledPosts extends Command
{
protected $signature = 'posts:publish-scheduled';
protected $description = 'Publish scheduled posts that are due';
public function handle()
{
$posts = Post::shouldBePublished()->get();
$this->info("Found {$posts->count()} posts to publish");
foreach ($posts as $post) {
$post->publish('Auto-published by scheduler');
$this->line("Published: {$post->title}");
}
$this->info('Done!');
// Log to monitoring
if ($posts->count() > 0) {
\App\Models\SystemLog::create([
'level' => 'info',
'message' => "Auto-published {$posts->count()} scheduled posts",
'context' => ['post_ids' => $posts->pluck('id')->toArray()],
]);
}
return Command::SUCCESS;
}
}
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
// Run every minute to check for scheduled posts
$schedule->command('posts:publish-scheduled')->everyMinute();
// Clean up old logs weekly
$schedule->command('model:prune', [
'--model' => [PostStatusLog::class, SystemLog::class],
])->weekly();
}
F. Praktikum: Complete Publishing System (60 menit)
Tugas: Implementasi complete publishing workflow
Langkah-langkah:
Buat Complete Admin Panel dengan Advanced Features:
Delivery Requirements:
🚀 EXCELLENT WORK! Setelah menyelesaikan Fase 3, peserta telah memiliki: