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 & Project Kickoff (30 menit)
B. Setup Project Final dengan Best Practices (60 menit)
1. Project Initialization:
bash
# Buat project baru dengan Laravel composer create-project laravel/laravel blog-portfolio-pro cd blog-portfolio-pro # Install package essentials composer require filament/filament:"^3.0" -W composer require spatie/laravel-permission composer require spatie/laravel-medialibrary composer require spatie/laravel-query-builder composer require intervention/image # Development packages composer require barryvdh/laravel-debugbar --dev composer require laravel/pint --dev composer require nunomaduro/larastan --dev
2. Environment Configuration:
env
# .env
APP_NAME="Blog & Portfolio Pro"
APP_ENV=production
APP_DEBUG=false
APP_URL=http://localhost:8000
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=blog_portfolio_pro
DB_USERNAME=root
DB_PASSWORD=
# Cache
CACHE_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
# Session
SESSION_DRIVER=redis
SESSION_LIFETIME=120
# Queue
QUEUE_CONNECTION=redis
# File Storage
FILESYSTEM_DISK=public
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_URL=
# Mail
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
# Security
SANCTUM_STATEFUL_DOMAINS=localhost:8000
SESSION_DOMAIN=null3. Directory Structure Final:
text
blog-portfolio-pro/ ├── app/ │ ├── Console/ │ │ ├── Commands/ │ │ │ ├── PublishScheduledPosts.php │ │ │ └── GenerateSitemap.php │ │ └── Kernel.php │ ├── Events/ │ │ ├── PostPublished.php │ │ ├── CommentCreated.php │ │ └── UserRegistered.php │ ├── Exceptions/ │ │ └── Handler.php │ ├── Http/ │ │ ├── Controllers/ │ │ │ ├── Admin/ # Admin controllers │ │ │ ├── Api/ # API controllers │ │ │ ├── Auth/ # Authentication │ │ │ └── Front/ # Frontend controllers │ │ ├── Middleware/ │ │ │ ├── AdminAccess.php │ │ │ ├── TrackVisitor.php │ │ │ └── ValidateSignature.php │ │ ├── Requests/ │ │ │ ├── PostRequest.php │ │ │ ├── CommentRequest.php │ │ │ └── ProfileRequest.php │ │ └── Kernel.php │ ├── Models/ │ │ ├── Enums/ # Enumerations │ │ │ ├── PostStatus.php │ │ │ ├── UserRole.php │ │ │ └── CommentStatus.php │ │ ├── Traits/ # Model traits │ │ │ ├── HasSlug.php │ │ │ ├── HasAuthor.php │ │ │ └── HasMeta.php │ │ ├── Post.php │ │ ├── Category.php │ │ ├── Tag.php │ │ ├── Comment.php │ │ ├── Portfolio.php │ │ └── User.php │ ├── Observers/ │ │ ├── PostObserver.php │ │ ├── CommentObserver.php │ │ └── UserObserver.php │ ├── Policies/ │ │ ├── PostPolicy.php │ │ ├── CommentPolicy.php │ │ └── PortfolioPolicy.php │ ├── Providers/ │ │ ├── AppServiceProvider.php │ │ ├── AuthServiceProvider.php │ │ ├── EventServiceProvider.php │ │ ├── Filament/ # Filament providers │ │ │ └── AdminPanelProvider.php │ │ └── RepositoryServiceProvider.php │ ├── Services/ │ │ ├── BlogService.php │ │ ├── ImageService.php │ │ ├── SitemapService.php │ │ └── AnalyticsService.php │ └── View/ │ └── Components/ # Blade components │ ├── Layout/ │ ├── Blog/ │ └── Portfolio/ ├── bootstrap/ │ └── app.php ├── config/ │ ├── app.php │ ├── auth.php │ ├── cache.php │ ├── database.php │ ├── filesystems.php │ ├── permission.php │ ├── queue.php │ └── services.php ├── database/ │ ├── migrations/ │ │ ├── 2024_01_01_000001_create_users_table.php │ │ ├── 2024_01_01_000002_create_posts_table.php │ │ ├── 2024_01_01_000003_create_categories_table.php │ │ ├── 2024_01_01_000004_create_tags_table.php │ │ ├── 2024_01_01_000005_create_post_tag_table.php │ │ ├── 2024_01_01_000006_create_comments_table.php │ │ ├── 2024_01_01_000007_create_portfolios_table.php │ │ ├── 2024_01_01_000008_create_media_table.php │ │ ├── 2024_01_01_000009_create_audit_logs_table.php │ │ └── 2024_01_01_000010_create_jobs_table.php │ ├── seeders/ │ │ ├── DatabaseSeeder.php │ │ ├── UserSeeder.php │ │ ├── CategorySeeder.php │ │ ├── TagSeeder.php │ │ └── PostSeeder.php │ └── factories/ │ ├── UserFactory.php │ ├── PostFactory.php │ ├── CategoryFactory.php │ └── CommentFactory.php ├── public/ │ ├── index.php │ ├── .htaccess │ ├── robots.txt │ └── sitemap.xml ├── resources/ │ ├── css/ │ │ ├── app.css │ │ └── filament.css │ ├── js/ │ │ ├── app.js │ │ └── bootstrap.js │ └── views/ │ ├── layouts/ │ │ ├── app.blade.php │ │ ├── admin.blade.php │ │ └── guest.blade.php │ ├── components/ │ │ ├── alert.blade.php │ │ ├── card.blade.php │ │ └── pagination.blade.php │ ├── front/ │ │ ├── blog/ │ │ │ ├── index.blade.php │ │ │ ├── show.blade.php │ │ │ └── category.blade.php │ │ └── portfolio/ │ │ ├── index.blade.php │ │ └── show.blade.php │ └── admin/ │ └── dashboard.blade.php ├── routes/ │ ├── web.php │ ├── api.php │ └── console.php ├── storage/ │ ├── app/ │ │ ├── public/ │ │ └── framework/ │ └── logs/ ├── tests/ │ ├── Feature/ │ │ ├── BlogTest.php │ │ ├── CommentTest.php │ │ └── AuthenticationTest.php │ └── Unit/ │ ├── PostTest.php │ └── UserTest.php └── vendor/
4. Core Configuration Files:
php
// config/app.php
return [
'name' => env('APP_NAME', 'Blog & Portfolio Pro'),
'env' => env('APP_ENV', 'production'),
'debug' => (bool) env('APP_DEBUG', false),
'url' => env('APP_URL', 'http://localhost'),
'timezone' => 'Asia/Jakarta',
'locale' => 'id',
'fallback_locale' => 'en',
'faker_locale' => 'id_ID',
'key' => env('APP_KEY'),
'cipher' => 'AES-256-CBC',
'providers' => [
/*
* Laravel Framework Service Providers...
*/
Illuminate\Auth\AuthServiceProvider::class,
Illuminate\Broadcasting\BroadcastServiceProvider::class,
// ...
/*
* Package Service Providers...
*/
Spatie\Permission\PermissionServiceProvider::class,
Spatie\MediaLibrary\MediaLibraryServiceProvider::class,
/*
* Application Service Providers...
*/
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
App\Providers\RepositoryServiceProvider::class,
],
];php
// config/permission.php
return [
'models' => [
'permission' => Spatie\Permission\Models\Permission::class,
'role' => Spatie\Permission\Models\Role::class,
],
'table_names' => [
'roles' => 'roles',
'permissions' => 'permissions',
'model_has_permissions' => 'model_has_permissions',
'model_has_roles' => 'model_has_roles',
'role_has_permissions' => 'role_has_permissions',
],
'column_names' => [
'model_morph_key' => 'model_id',
],
'display_permission_in_exception' => false,
'cache' => [
'expiration_time' => \DateInterval::createFromDateString('24 hours'),
'key' => 'spatie.permission.cache',
'store' => 'default',
],
];
C. Implementasi Core Models dengan Traits (60 menit)
1. Model Traits untuk Reusability:
php
<?php
// app/Models/Traits/HasSlug.php
namespace App\Models\Traits;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
trait HasSlug
{
public static function bootHasSlug(): void
{
static::creating(function (Model $model) {
if (empty($model->slug)) {
$model->slug = $model->generateSlug();
}
});
static::updating(function (Model $model) {
if ($model->isDirty('slug')) {
$model->slug = $model->makeSlugUnique($model->slug, $model->id);
}
});
}
public function generateSlug(): string
{
$source = $this->slugSource ?? 'name';
$slug = Str::slug($this->{$source});
return $this->makeSlugUnique($slug);
}
protected function makeSlugUnique(string $slug, ?int $ignoreId = null): string
{
$originalSlug = $slug;
$counter = 1;
while ($this->slugExists($slug, $ignoreId)) {
$slug = $originalSlug . '-' . $counter;
$counter++;
}
return $slug;
}
protected function slugExists(string $slug, ?int $ignoreId = null): bool
{
$query = static::where('slug', $slug);
if ($ignoreId) {
$query->where('id', '!=', $ignoreId);
}
return $query->exists();
}
}php
<?php
// app/Models/Traits/HasAuthor.php
namespace App\Models\Traits;
use App\Models\User;
trait HasAuthor
{
public function author()
{
return $this->belongsTo(User::class, 'user_id');
}
public function scopeByAuthor($query, $userId)
{
return $query->where('user_id', $userId);
}
public function isOwnedBy(User $user): bool
{
return $this->user_id === $user->id;
}
public function scopeByCurrentUser($query)
{
return $query->byAuthor(auth()->id());
}
}php
<?php
// app/Models/Traits/HasMeta.php
namespace App\Models\Traits;
use Illuminate\Database\Eloquent\Casts\Attribute;
trait HasMeta
{
public function initializeHasMeta(): void
{
$this->casts['meta'] = 'array';
$this->fillable[] = 'meta';
}
public function meta(): Attribute
{
return Attribute::make(
get: fn ($value) => json_decode($value ?? '{}', true),
set: fn ($value) => json_encode($value ?? []),
);
}
public function getMeta(string $key, $default = null)
{
return data_get($this->meta, $key, $default);
}
public function setMeta(string $key, $value): self
{
$meta = $this->meta;
data_set($meta, $key, $value);
$this->meta = $meta;
return $this;
}
public function hasMeta(string $key): bool
{
return data_get($this->meta, $key) !== null;
}
}2. Enhanced Post Model:
php
<?php
// app/Models/Post.php
namespace App\Models;
use App\Models\Traits\HasSlug;
use App\Models\Traits\HasAuthor;
use App\Models\Traits\HasMeta;
use App\Models\Enums\PostStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\Image\Manipulations;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class Post extends Model implements HasMedia
{
use HasFactory, SoftDeletes, HasSlug, HasAuthor, HasMeta, InteractsWithMedia;
protected $fillable = [
'title',
'slug',
'excerpt',
'content',
'featured_image',
'status',
'published_at',
'user_id',
'category_id',
'meta_title',
'meta_description',
'meta_keywords',
'view_count',
'is_featured',
'is_pinned',
'allow_comments',
'meta',
];
protected $casts = [
'status' => PostStatus::class,
'published_at' => 'datetime',
'view_count' => 'integer',
'is_featured' => 'boolean',
'is_pinned' => 'boolean',
'allow_comments' => 'boolean',
'meta' => 'array',
];
protected $attributes = [
'status' => PostStatus::DRAFT,
'view_count' => 0,
'is_featured' => false,
'is_pinned' => false,
'allow_comments' => true,
'meta' => '{}',
];
protected string $slugSource = 'title';
public function registerMediaCollections(): void
{
$this->addMediaCollection('featured')
->singleFile()
->registerMediaConversions(function (Media $media) {
$this->addMediaConversion('thumbnail')
->width(400)
->height(300)
->sharpen(10)
->quality(80);
$this->addMediaConversion('large')
->width(1200)
->height(800)
->sharpen(10)
->quality(90);
});
$this->addMediaCollection('gallery')
->registerMediaConversions(function (Media $media) {
$this->addMediaConversion('thumbnail')
->width(300)
->height(200)
->sharpen(10)
->quality(80);
});
}
public function category()
{
return $this->belongsTo(Category::class);
}
public function tags()
{
return $this->belongsToMany(Tag::class);
}
public function comments()
{
return $this->hasMany(Comment::class);
}
public function approvedComments()
{
return $this->comments()->where('status', 'approved');
}
public function scopePublished($query)
{
return $query->where('status', PostStatus::PUBLISHED)
->where('published_at', '<=', now());
}
public function scopeDraft($query)
{
return $query->where('status', PostStatus::DRAFT);
}
public function scopeScheduled($query)
{
return $query->where('status', PostStatus::SCHEDULED)
->where('published_at', '>', now());
}
public function scopeFeatured($query)
{
return $query->where('is_featured', true);
}
public function scopePinned($query)
{
return $query->where('is_pinned', true);
}
public function scopeByCategory($query, $categoryId)
{
return $query->where('category_id', $categoryId);
}
public function scopeByTag($query, $tagId)
{
return $query->whereHas('tags', function ($q) use ($tagId) {
$q->where('id', $tagId);
});
}
public function scopeSearch($query, $searchTerm)
{
return $query->where(function ($q) use ($searchTerm) {
$q->where('title', 'like', "%{$searchTerm}%")
->orWhere('content', 'like', "%{$searchTerm}%")
->orWhere('excerpt', 'like', "%{$searchTerm}%");
});
}
public function getReadingTimeAttribute(): int
{
$wordCount = str_word_count(strip_tags($this->content));
return ceil($wordCount / 200); // 200 words per minute
}
public function getFeaturedImageUrlAttribute(): ?string
{
if ($this->hasMedia('featured')) {
return $this->getFirstMediaUrl('featured', 'large');
}
return $this->featured_image ? asset('storage/' . $this->featured_image) : null;
}
public function getThumbnailUrlAttribute(): ?string
{
if ($this->hasMedia('featured')) {
return $this->getFirstMediaUrl('featured', 'thumbnail');
}
return $this->featured_image ? asset('storage/' . $this->featured_image) : null;
}
public function incrementViewCount(): void
{
$this->increment('view_count');
// Store view in session to prevent multiple counts
session()->put("viewed_post_{$this->id}", now());
}
public function shouldIncrementViewCount(): bool
{
$lastViewed = session()->get("viewed_post_{$this->id}");
if (!$lastViewed) {
return true;
}
// Only count once per hour
return $lastViewed->diffInHours(now()) >= 1;
}
public function getUrlAttribute(): string
{
return route('posts.show', $this->slug);
}
public function getEditUrlAttribute(): string
{
return route('admin.posts.edit', $this);
}
public function publish(): bool
{
$this->status = PostStatus::PUBLISHED;
$this->published_at = $this->published_at ?? now();
return $this->save();
}
public function unpublish(): bool
{
$this->status = PostStatus::DRAFT;
return $this->save();
}
}3. Complete Category Model:
php
<?php
// app/Models/Category.php
namespace App\Models;
use App\Models\Traits\HasSlug;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Category extends Model
{
use HasFactory, SoftDeletes, HasSlug;
protected $fillable = [
'name',
'slug',
'description',
'parent_id',
'meta_title',
'meta_description',
'is_active',
'order',
];
protected $casts = [
'is_active' => 'boolean',
'order' => 'integer',
];
protected $attributes = [
'is_active' => true,
'order' => 0,
];
protected string $slugSource = 'name';
public function parent()
{
return $this->belongsTo(Category::class, 'parent_id');
}
public function children()
{
return $this->hasMany(Category::class, 'parent_id');
}
public function posts()
{
return $this->hasMany(Post::class);
}
public function publishedPosts()
{
return $this->posts()->published();
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeRoot($query)
{
return $query->whereNull('parent_id');
}
public function scopeOrdered($query)
{
return $query->orderBy('order')->orderBy('name');
}
public function getPostsCountAttribute(): int
{
return $this->publishedPosts()->count();
}
public function getUrlAttribute(): string
{
return route('categories.show', $this->slug);
}
public function getBreadcrumbAttribute(): array
{
$breadcrumb = [];
$current = $this;
while ($current) {
$breadcrumb[] = [
'name' => $current->name,
'url' => $current->url,
];
$current = $current->parent;
}
return array_reverse($breadcrumb);
}
}4. Complete Comment Model:
php
<?php
// app/Models/Comment.php
namespace App\Models;
use App\Models\Enums\CommentStatus;
use App\Models\Traits\HasAuthor;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Comment extends Model
{
use HasFactory, SoftDeletes, HasAuthor;
protected $fillable = [
'content',
'status',
'user_id',
'post_id',
'parent_id',
'author_name',
'author_email',
'author_website',
'author_ip',
'is_featured',
'likes_count',
'dislikes_count',
];
protected $casts = [
'status' => CommentStatus::class,
'is_featured' => 'boolean',
'likes_count' => 'integer',
'dislikes_count' => 'integer',
];
protected $attributes = [
'status' => CommentStatus::PENDING,
'is_featured' => false,
'likes_count' => 0,
'dislikes_count' => 0,
];
public function post()
{
return $this->belongsTo(Post::class);
}
public function parent()
{
return $this->belongsTo(Comment::class, 'parent_id');
}
public function replies()
{
return $this->hasMany(Comment::class, 'parent_id');
}
public function scopeApproved($query)
{
return $query->where('status', CommentStatus::APPROVED);
}
public function scopePending($query)
{
return $query->where('status', CommentStatus::PENDING);
}
public function scopeSpam($query)
{
return $query->where('status', CommentStatus::SPAM);
}
public function scopeRoot($query)
{
return $query->whereNull('parent_id');
}
public function scopeFeatured($query)
{
return $query->where('is_featured', true);
}
public function getAuthorNameAttribute(): string
{
return $this->user_id ? $this->user->name : ($this->attributes['author_name'] ?? 'Anonymous');
}
public function getAuthorEmailAttribute(): ?string
{
return $this->user_id ? $this->user->email : $this->attributes['author_email'];
}
public function getAuthorAvatarAttribute(): ?string
{
if ($this->user_id) {
return $this->user->avatar_url;
}
// Generate gravatar or default avatar
$email = $this->attributes['author_email'] ?? '';
$hash = md5(strtolower(trim($email)));
return "https://www.gravatar.com/avatar/{$hash}?d=identicon&s=100";
}
public function approve(): bool
{
$this->status = CommentStatus::APPROVED;
return $this->save();
}
public function markAsSpam(): bool
{
$this->status = CommentStatus::SPAM;
return $this->save();
}
public function isApproved(): bool
{
return $this->status === CommentStatus::APPROVED;
}
public function isPending(): bool
{
return $this->status === CommentStatus::PENDING;
}
public function isSpam(): bool
{
return $this->status === CommentStatus::SPAM;
}
}
D. Database Migrations Lengkap (60 menit)
1. Users Migration:
php
<?php
// database/migrations/2024_01_01_000001_create_users_table.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('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->string('username')->unique()->nullable();
$table->text('bio')->nullable();
$table->string('avatar')->nullable();
$table->string('website')->nullable();
$table->string('twitter')->nullable();
$table->string('github')->nullable();
$table->string('linkedin')->nullable();
$table->enum('role', ['admin', 'author', 'editor', 'subscriber'])->default('subscriber');
$table->boolean('is_active')->default(true);
$table->json('preferences')->nullable();
$table->rememberToken();
$table->timestamps();
$table->softDeletes();
$table->index('role');
$table->index('is_active');
$table->index('created_at');
});
}
public function down(): void
{
Schema::dropIfExists('users');
}
};2. Posts Migration dengan Advanced Features:
php
<?php
// database/migrations/2024_01_01_000002_create_posts_table.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', 200);
$table->string('slug', 220)->unique();
$table->text('excerpt')->nullable();
$table->longText('content');
$table->string('featured_image')->nullable();
$table->enum('status', ['draft', 'review', 'scheduled', 'published', 'archived'])->default('draft');
$table->timestamp('published_at')->nullable();
$table->timestamp('scheduled_for')->nullable();
$table->timestamp('reviewed_at')->nullable();
$table->foreignId('reviewed_by')->nullable()->constrained('users')->onDelete('set null');
// Foreign keys
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('category_id')->nullable()->constrained()->onDelete('set null');
// Meta fields
$table->string('meta_title', 200)->nullable();
$table->text('meta_description')->nullable();
$table->text('meta_keywords')->nullable();
// Statistics
$table->integer('view_count')->default(0);
$table->integer('share_count')->default(0);
$table->integer('comment_count')->default(0);
// Flags
$table->boolean('is_featured')->default(false);
$table->boolean('is_pinned')->default(false);
$table->boolean('allow_comments')->default(true);
$table->boolean('allow_sharing')->default(true);
// JSON fields
$table->json('meta')->nullable();
$table->json('settings')->nullable();
// Timestamps
$table->timestamps();
$table->softDeletes();
$table->timestamp('featured_until')->nullable();
// Indexes
$table->index('slug');
$table->index('status');
$table->index('published_at');
$table->index('scheduled_for');
$table->index(['status', 'published_at']);
$table->index(['is_featured', 'published_at']);
$table->index(['is_pinned', 'published_at']);
$table->index(['user_id', 'status']);
$table->index(['category_id', 'status', 'published_at']);
$table->fullText(['title', 'content', 'excerpt']);
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};3. Categories Migration:
php
<?php
// database/migrations/2024_01_01_000003_create_categories_table.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('categories', function (Blueprint $table) {
$table->id();
$table->string('name', 100);
$table->string('slug', 120)->unique();
$table->text('description')->nullable();
$table->foreignId('parent_id')->nullable()->constrained('categories')->onDelete('cascade');
$table->string('meta_title', 200)->nullable();
$table->text('meta_description')->nullable();
$table->boolean('is_active')->default(true);
$table->integer('order')->default(0);
$table->string('color', 7)->default('#6b7280');
$table->string('icon', 50)->nullable();
$table->json('meta')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index('slug');
$table->index('parent_id');
$table->index('is_active');
$table->index('order');
$table->index(['parent_id', 'order']);
});
}
public function down(): void
{
Schema::dropIfExists('categories');
}
};4. Tags & Post Tag Junction:
php
<?php
// database/migrations/2024_01_01_000004_create_tags_table.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('tags', function (Blueprint $table) {
$table->id();
$table->string('name', 50)->unique();
$table->string('slug', 60)->unique();
$table->string('color', 7)->default('#6b7280');
$table->text('description')->nullable();
$table->integer('usage_count')->default(0);
$table->boolean('is_featured')->default(false);
$table->json('meta')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index('slug');
$table->index('usage_count');
$table->index('is_featured');
});
Schema::create('post_tag', function (Blueprint $table) {
$table->foreignId('post_id')->constrained()->onDelete('cascade');
$table->foreignId('tag_id')->constrained()->onDelete('cascade');
$table->integer('order')->default(0);
$table->timestamps();
$table->primary(['post_id', 'tag_id']);
$table->index('tag_id');
$table->index('order');
});
}
public function down(): void
{
Schema::dropIfExists('post_tag');
Schema::dropIfExists('tags');
}
};5. Comments Migration:
php
<?php
// database/migrations/2024_01_01_000006_create_comments_table.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('comments', function (Blueprint $table) {
$table->id();
$table->text('content');
$table->enum('status', ['pending', 'approved', 'spam', 'trash'])->default('pending');
// Relationships
$table->foreignId('user_id')->nullable()->constrained()->onDelete('cascade');
$table->foreignId('post_id')->constrained()->onDelete('cascade');
$table->foreignId('parent_id')->nullable()->constrained('comments')->onDelete('cascade');
// Guest information
$table->string('author_name', 100)->nullable();
$table->string('author_email', 100)->nullable();
$table->string('author_website', 200)->nullable();
$table->string('author_ip', 45);
$table->string('user_agent')->nullable();
// Moderation
$table->boolean('is_featured')->default(false);
$table->integer('likes_count')->default(0);
$table->integer('dislikes_count')->default(0);
$table->foreignId('approved_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamp('approved_at')->nullable();
// Metadata
$table->json('meta')->nullable();
$table->timestamps();
$table->softDeletes();
// Indexes
$table->index('post_id');
$table->index('status');
$table->index('parent_id');
$table->index(['post_id', 'status']);
$table->index(['post_id', 'parent_id']);
$table->index(['user_id', 'status']);
$table->index('created_at');
$table->index(['status', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('comments');
}
};6. Portfolios Migration:
php
<?php
// database/migrations/2024_01_01_000007_create_portfolios_table.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('portfolios', function (Blueprint $table) {
$table->id();
$table->string('title', 200);
$table->string('slug', 220)->unique();
$table->text('description');
$table->longText('content')->nullable();
// Project details
$table->string('client_name', 100)->nullable();
$table->string('project_url')->nullable();
$table->date('project_date')->nullable();
$table->string('project_type', 50)->nullable();
$table->json('technologies')->nullable();
// Media
$table->string('featured_image')->nullable();
$table->json('gallery')->nullable();
// Status and visibility
$table->boolean('is_published')->default(false);
$table->boolean('is_featured')->default(false);
$table->integer('order')->default(0);
$table->integer('view_count')->default(0);
// Metadata
$table->string('meta_title', 200)->nullable();
$table->text('meta_description')->nullable();
$table->json('meta')->nullable();
// Relationships
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('category_id')->nullable()->constrained('portfolio_categories')->onDelete('set null');
$table->timestamps();
$table->softDeletes();
// Indexes
$table->index('slug');
$table->index('is_published');
$table->index('is_featured');
$table->index('project_date');
$table->index(['is_published', 'project_date']);
$table->index(['is_featured', 'project_date']);
$table->fullText(['title', 'description', 'content']);
});
}
public function down(): void
{
Schema::dropIfExists('portfolios');
}
};7. Audit Logs Migration:
php
<?php
// database/migrations/2024_01_01_000009_create_audit_logs_table.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('audit_logs', function (Blueprint $table) {
$table->id();
$table->string('log_name', 100)->nullable()->index();
$table->text('description');
$table->nullableMorphs('subject', 'subject');
$table->nullableMorphs('causer', 'causer');
$table->json('properties')->nullable();
$table->string('ip_address', 45)->nullable();
$table->string('user_agent')->nullable();
$table->string('url')->nullable();
$table->string('method', 10)->nullable();
$table->timestamps();
$table->index('created_at');
$table->index(['log_name', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('audit_logs');
}
};
E. Praktikum: Setup Project Lengkap (60 menit)
Tugas: Setup project dari awal dengan semua best practices
Langkah-langkah:
php artisan migrate:status php artisan db:seed php artisan tinker >>> \App\Models\User::count() >>> \App\Models\Post::count()
Durasi: 3 jam (Teori: 1 jam, Praktik: 2 jam)
A. Review & Code Quality Check (30 menit)
B. UI/UX Improvement untuk Frontend (60 menit)
1. Blade Components untuk Reusability:
php
<?php
// app/View/Components/Layout/AppLayout.php
namespace App\View\Components\Layout;
use Illuminate\View\Component;
class AppLayout extends Component
{
public function __construct(
public ?string $title = null,
public ?string $description = null,
public ?string $keywords = null,
public ?string $canonical = null,
public bool $showHeader = true,
public bool $showFooter = true,
public array $breadcrumbs = [],
public array $meta = []
) {}
public function render()
{
return view('components.layout.app');
}
}blade
{{-- resources/views/components/layout/app.blade.php --}}
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="scroll-smooth">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
{{-- Meta Tags --}}
<title>{{ $title ? "{$title} | " : '' }}{{ config('app.name') }}</title>
@if($description)
<meta name="description" content="{{ $description }}">
@endif
@if($keywords)
<meta name="keywords" content="{{ $keywords }}">
@endif
{{-- Open Graph --}}
<meta property="og:title" content="{{ $title ?? config('app.name') }}">
<meta property="og:type" content="website">
<meta property="og:url" content="{{ $canonical ?? url()->current() }}">
@if($description)
<meta property="og:description" content="{{ $description }}">
@endif
<meta property="og:site_name" content="{{ config('app.name') }}">
{{-- Twitter Card --}}
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $title ?? config('app.name') }}">
@if($description)
<meta name="twitter:description" content="{{ $description }}">
@endif
{{-- Canonical URL --}}
@if($canonical)
<link rel="canonical" href="{{ $canonical }}">
@endif
{{-- Favicon --}}
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
{{-- Styles --}}
@vite(['resources/css/app.css', 'resources/js/app.js'])
{{-- Additional Head --}}
{{ $head ?? '' }}
</head>
<body class="bg-gray-50 text-gray-900 font-sans antialiased">
{{-- Skip to content --}}
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 bg-primary-600 text-white px-4 py-2 rounded">
Skip to content
</a>
{{-- Header --}}
@if($showHeader)
<x-layout.header :breadcrumbs="$breadcrumbs" />
@endif
{{-- Main Content --}}
<main id="main-content" class="min-h-screen">
{{ $slot }}
</main>
{{-- Footer --}}
@if($showFooter)
<x-layout.footer />
@endif
{{-- Scripts --}}
@stack('scripts')
{{-- Analytics --}}
@production
<!-- Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
</script>
@endproduction
</body>
</html>2. Blog Post Card Component:
php
<?php
// app/View/Components/Blog/PostCard.php
namespace App\View\Components\Blog;
use App\Models\Post;
use Illuminate\View\Component;
class PostCard extends Component
{
public function __construct(
public Post $post,
public bool $showExcerpt = true,
public bool $showMeta = true,
public bool $showCategory = true,
public bool $showAuthor = true,
public bool $showDate = true,
public bool $showTags = false,
public bool $vertical = false,
public string $size = 'medium' // small, medium, large
) {}
public function render()
{
return view('components.blog.post-card');
}
public function imageSize(): array
{
return match($this->size) {
'small' => ['w' => 300, 'h' => 200],
'large' => ['w' => 800, 'h' => 500],
default => ['w' => 600, 'h' => 400],
};
}
}blade
{{-- resources/views/components/blog/post-card.blade.php --}}
@props([
'post',
'showExcerpt' => true,
'showMeta' => true,
'showCategory' => true,
'showAuthor' => true,
'showDate' => true,
'showTags' => false,
'vertical' => false,
'size' => 'medium'
])
@php
$imageSize = $imageSize();
$classes = $vertical
? 'flex flex-col h-full overflow-hidden rounded-lg shadow-lg bg-white hover:shadow-xl transition-shadow duration-300'
: 'flex flex-col md:flex-row gap-6 p-6 rounded-xl bg-white shadow-md hover:shadow-lg transition-all duration-300';
@endphp
<article class="{{ $classes }}">
{{-- Featured Image --}}
@if($post->featured_image_url)
<div class="{{ $vertical ? '' : 'md:w-2/5' }}">
<a href="{{ $post->url }}" class="block overflow-hidden rounded-lg">
<img
src="{{ $post->thumbnail_url }}"
alt="{{ $post->title }}"
width="{{ $imageSize['w'] }}"
height="{{ $imageSize['h'] }}"
class="w-full h-48 md:h-64 object-cover transition-transform duration-500 hover:scale-105"
loading="lazy"
>
</a>
</div>
@endif
{{-- Content --}}
<div class="{{ $vertical ? 'p-6' : 'md:w-3/5' }} flex flex-col flex-1">
{{-- Category --}}
@if($showCategory && $post->category)
<div class="mb-2">
<a
href="{{ $post->category->url }}"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold {{ $post->category->color ? "bg-{$post->category->color}-100 text-{$post->category->color}-800" : 'bg-gray-100 text-gray-800' }} hover:opacity-90 transition-opacity"
>
{{ $post->category->name }}
</a>
</div>
@endif
{{-- Title --}}
<h3 class="text-xl font-bold text-gray-900 mb-3 line-clamp-2">
<a href="{{ $post->url }}" class="hover:text-primary-600 transition-colors">
{{ $post->title }}
</a>
</h3>
{{-- Excerpt --}}
@if($showExcerpt && $post->excerpt)
<p class="text-gray-600 mb-4 line-clamp-3 flex-1">
{{ $post->excerpt }}
</p>
@endif
{{-- Meta Information --}}
@if($showMeta)
<div class="mt-auto pt-4 border-t border-gray-100">
<div class="flex flex-wrap items-center justify-between text-sm text-gray-500">
@if($showAuthor)
<div class="flex items-center space-x-2">
@if($post->author->avatar_url)
<img
src="{{ $post->author->avatar_url }}"
alt="{{ $post->author->name }}"
class="w-6 h-6 rounded-full"
>
@endif
<span>{{ $post->author->name }}</span>
</div>
@endif
@if($showDate)
<time datetime="{{ $post->published_at->toIso8601String() }}">
{{ $post->published_at->format('M d, Y') }}
</time>
@endif
{{-- Reading Time --}}
<div class="flex items-center">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
</svg>
{{ $post->reading_time }} min read
</div>
</div>
{{-- Tags --}}
@if($showTags && $post->tags->isNotEmpty())
<div class="mt-3 flex flex-wrap gap-2">
@foreach($post->tags->take(3) as $tag)
<a
href="{{ route('tags.show', $tag->slug) }}"
class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors"
>
#{{ $tag->name }}
</a>
@endforeach
@if($post->tags->count() > 3)
<span class="text-xs text-gray-500 self-center">
+{{ $post->tags->count() - 3 }} more
</span>
@endif
</div>
@endif
</div>
@endif
</div>
</article>3. Blog Layout dengan Sidebar:
blade
{{-- resources/views/front/blog/layout.blade.php --}}
@props([
'sidebar' => true,
'sidebarPosition' => 'right', // left or right
'containerClass' => 'max-w-7xl mx-auto px-4 sm:px-6 lg:px-8'
])
<x-layout.app
:title="$title ?? null"
:description="$description ?? null"
:breadcrumbs="$breadcrumbs ?? []"
>
<div class="{{ $containerClass }} py-8">
<div class="flex flex-col {{ $sidebar ? 'lg:flex-row' : '' }} gap-8">
{{-- Sidebar Left --}}
@if($sidebar && $sidebarPosition === 'left')
<aside class="lg:w-1/4 lg:order-1">
{{ $sidebar }}
</aside>
@endif
{{-- Main Content --}}
<main class="{{ $sidebar ? 'lg:w-3/4' : 'w-full' }}">
{{ $slot }}
</main>
{{-- Sidebar Right --}}
@if($sidebar && $sidebarPosition === 'right')
<aside class="lg:w-1/4">
{{ $sidebar }}
</aside>
@endif
</div>
</div>
</x-layout.app>4. Blog Index Page:
php
<?php
// app/Http/Controllers/Front/BlogController.php
namespace App\Http\Controllers\Front;
use App\Http\Controllers\Controller;
use App\Models\Post;
use App\Models\Category;
use App\Models\Tag;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BlogController extends Controller
{
public function index(Request $request): View
{
$query = Post::published()
->with(['author', 'category', 'tags'])
->select(['id', 'title', 'slug', 'excerpt', 'featured_image', 'published_at', 'user_id', 'category_id', 'view_count'])
->latest('published_at');
// Apply filters
if ($request->filled('category')) {
$query->whereHas('category', function ($q) use ($request) {
$q->where('slug', $request->category);
});
}
if ($request->filled('tag')) {
$query->whereHas('tags', function ($q) use ($request) {
$q->where('slug', $request->tag);
});
}
if ($request->filled('search')) {
$query->search($request->search);
}
// Featured posts
$featuredPosts = Post::published()
->featured()
->limit(3)
->get();
// Pagination
$posts = $query->paginate(12)
->withQueryString();
// Sidebar data
$categories = Category::active()
->withCount(['publishedPosts'])
->orderBy('order')
->get();
$popularTags = Tag::withCount(['posts'])
->orderBy('usage_count', 'desc')
->limit(20)
->get();
return view('front.blog.index', [
'posts' => $posts,
'featuredPosts' => $featuredPosts,
'categories' => $categories,
'popularTags' => $popularTags,
'title' => 'Blog Articles',
'description' => 'Read our latest blog posts about web development, design, and technology.',
]);
}
public function show(string $slug): View
{
$post = Post::where('slug', $slug)
->published()
->with(['author', 'category', 'tags', 'approvedComments' => function ($query) {
$query->with(['author', 'replies'])->root();
}])
->firstOrFail();
// Increment view count (with rate limiting)
if ($post->shouldIncrementViewCount()) {
$post->incrementViewCount();
}
// Related posts
$relatedPosts = Post::published()
->where('id', '!=', $post->id)
->where('category_id', $post->category_id)
->with(['author', 'category'])
->limit(3)
->get();
return view('front.blog.show', [
'post' => $post,
'relatedPosts' => $relatedPosts,
'title' => $post->title,
'description' => $post->excerpt,
'canonical' => $post->url,
]);
}
public function category(string $slug): View
{
$category = Category::where('slug', $slug)
->active()
->firstOrFail();
$posts = $category->publishedPosts()
->with(['author', 'category'])
->latest('published_at')
->paginate(12);
return view('front.blog.category', [
'category' => $category,
'posts' => $posts,
'title' => $category->name,
'description' => $category->description,
'breadcrumbs' => $category->breadcrumb,
]);
}
public function tag(string $slug): View
{
$tag = Tag::where('slug', $slug)->firstOrFail();
$posts = $tag->posts()
->published()
->with(['author', 'category'])
->latest('published_at')
->paginate(12);
return view('front.blog.tag', [
'tag' => $tag,
'posts' => $posts,
'title' => "Posts tagged with '{$tag->name}'",
'description' => "Browse all posts tagged with {$tag->name}",
]);
}
}
C. Error Handling & Logging (45 menit)
1. Custom Exception Handler:
php
<?php
// app/Exceptions/Handler.php
namespace App\Exceptions;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
class Handler extends ExceptionHandler
{
/**
* A list of exception types with their corresponding custom log levels.
*
* @var array<class-string<\Throwable>, \Psr\Log\LogLevel::*>
*/
protected $levels = [
//
];
/**
* A list of the exception types that are not reported.
*
* @var array<int, class-string<\Throwable>>
*/
protected $dontReport = [
//
];
/**
* A list of the inputs that are never flashed to the session on validation exceptions.
*
* @var array<int, string>
*/
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];
/**
* Register the exception handling callbacks for the application.
*/
public function register(): void
{
$this->reportable(function (Throwable $e) {
if (app()->bound('sentry')) {
app('sentry')->captureException($e);
}
});
// Custom 404 handling
$this->renderable(function (NotFoundHttpException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json([
'message' => 'The requested resource was not found.',
'error' => 'Not Found'
], 404);
}
return response()->view('errors.404', [
'message' => 'The page you are looking for could not be found.',
'title' => 'Page Not Found'
], 404);
});
// Custom validation exception handling
$this->renderable(function (ValidationException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json([
'message' => 'The given data was invalid.',
'errors' => $e->errors(),
], 422);
}
return redirect()->back()
->withInput()
->withErrors($e->errors());
});
// Custom authentication exception handling
$this->renderable(function (AuthenticationException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json([
'message' => 'Unauthenticated.',
'error' => 'Authentication Required'
], 401);
}
return redirect()->guest(route('login'))
->with('error', 'Please login to access this page.');
});
}
/**
* Prepare exception for rendering.
*/
protected function prepareException(Throwable $e): Throwable
{
// Log all exceptions with context
if ($this->shouldReport($e)) {
\Log::error($e->getMessage(), [
'exception' => get_class($e),
'file' => $e->getFile(),
'line' => $e->getLine(),
'url' => request()->fullUrl(),
'method' => request()->method(),
'ip' => request()->ip(),
'user_id' => auth()->id(),
'user_agent' => request()->userAgent(),
'trace' => $e->getTraceAsString(),
]);
}
return parent::prepareException($e);
}
}2. Custom Error Views:
blade
{{-- resources/views/errors/404.blade.php --}}
<x-layout.app
title="Page Not Found | {{ config('app.name') }}"
description="The page you are looking for could not be found."
:showHeader="true"
:showFooter="true"
>
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 text-center">
<div>
<h1 class="text-9xl font-bold text-primary-600">404</h1>
<h2 class="mt-6 text-3xl font-extrabold text-gray-900">
Page Not Found
</h2>
<p class="mt-2 text-sm text-gray-600">
{{ $message ?? 'The page you are looking for could not be found.' }}
</p>
</div>
<div class="mt-8 space-y-4">
<a
href="{{ url()->previous() }}"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
← Go Back
</a>
<a
href="{{ route('home') }}"
class="ml-3 inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
Go Home
</a>
</div>
<div class="mt-8">
<p class="text-sm text-gray-500">
If you believe this is an error, please
<a href="{{ route('contact') }}" class="font-medium text-primary-600 hover:text-primary-500">
contact support
</a>.
</p>
</div>
</div>
</div>
</x-layout.app>3. Application Logging Configuration:
php
// config/logging.php
return [
'default' => env('LOG_CHANNEL', 'stack'),
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['daily', 'slack'],
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 14,
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => 'Laravel Log',
'emoji' => ':boom:',
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'formatter' => env('LOG_STDERR_FORMATTER'),
'with' => [
'stream' => 'php://stderr',
],
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => LOG_USER,
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
// Custom channel for audit logs
'audit' => [
'driver' => 'daily',
'path' => storage_path('logs/audit.log'),
'level' => 'info',
'days' => 30,
],
// Custom channel for security logs
'security' => [
'driver' => 'daily',
'path' => storage_path('logs/security.log'),
'level' => 'warning',
'days' => 90,
],
],
];4. Logging Service:
php
<?php
// app/Services/LoggingService.php
namespace App\Services;
use Illuminate\Support\Facades\Log;
class LoggingService
{
public static function audit(string $action, array $context = []): void
{
Log::channel('audit')->info($action, array_merge([
'user_id' => auth()->id(),
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
'url' => request()->fullUrl(),
'method' => request()->method(),
'timestamp' => now()->toISOString(),
], $context));
}
public static function security(string $event, array $context = []): void
{
Log::channel('security')->warning($event, array_merge([
'user_id' => auth()->id(),
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
'url' => request()->fullUrl(),
'method' => request()->method(),
'timestamp' => now()->toISOString(),
], $context));
}
public static function activity(string $description, array $context = []): void
{
Log::info($description, array_merge([
'user_id' => auth()->id(),
'ip_address' => request()->ip(),
'timestamp' => now()->toISOString(),
], $context));
}
public static function error(string $message, \Throwable $exception = null): void
{
$context = [
'exception' => $exception ? get_class($exception) : null,
'file' => $exception ? $exception->getFile() : null,
'line' => $exception ? $exception->getLine() : null,
'trace' => $exception ? $exception->getTraceAsString() : null,
'user_id' => auth()->id(),
'ip_address' => request()->ip(),
'url' => request()->fullUrl(),
'timestamp' => now()->toISOString(),
];
Log::error($message, array_filter($context));
}
}
D. Data Consistency dengan Database Transactions (45 menit)
1. Repository Pattern dengan Transactions:
php
<?php
// app/Repositories/PostRepository.php
namespace App\Repositories;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class PostRepository
{
public function create(array $data, array $tagIds = []): Post
{
return DB::transaction(function () use ($data, $tagIds) {
try {
// Create post
$post = Post::create($data);
// Attach tags
if (!empty($tagIds)) {
$post->tags()->sync($tagIds);
// Update tag usage counts
Tag::whereIn('id', $tagIds)->increment('usage_count');
}
// Log activity
LoggingService::activity('Post created', [
'post_id' => $post->id,
'title' => $post->title,
]);
return $post;
} catch (\Exception $e) {
LoggingService::error('Failed to create post', $e);
throw $e;
}
});
}
public function update(Post $post, array $data, array $tagIds = []): Post
{
return DB::transaction(function () use ($post, $data, $tagIds) {
try {
$originalTags = $post->tags->pluck('id')->toArray();
// Update post
$post->update($data);
// Sync tags if provided
if (!empty($tagIds) || $tagIds === []) {
$post->tags()->sync($tagIds);
// Update tag usage counts
$addedTags = array_diff($tagIds, $originalTags);
$removedTags = array_diff($originalTags, $tagIds);
if (!empty($addedTags)) {
Tag::whereIn('id', $addedTags)->increment('usage_count');
}
if (!empty($removedTags)) {
Tag::whereIn('id', $removedTags)->decrement('usage_count');
}
}
// Log activity
LoggingService::activity('Post updated', [
'post_id' => $post->id,
'title' => $post->title,
'changes' => $post->getChanges(),
]);
return $post->fresh();
} catch (\Exception $e) {
LoggingService::error('Failed to update post', $e);
throw $e;
}
});
}
public function delete(Post $post): bool
{
return DB::transaction(function () use ($post) {
try {
// Store data for logging
$postData = $post->toArray();
$tagIds = $post->tags->pluck('id')->toArray();
// Detach tags and decrement counts
$post->tags()->detach();
Tag::whereIn('id', $tagIds)->decrement('usage_count');
// Delete comments
$post->comments()->delete();
// Delete media
$post->clearMediaCollection('featured');
$post->clearMediaCollection('gallery');
// Delete post
$deleted = $post->delete();
// Log activity
LoggingService::activity('Post deleted', [
'post_id' => $postData['id'],
'title' => $postData['title'],
]);
return $deleted;
} catch (\Exception $e) {
LoggingService::error('Failed to delete post', $e);
throw $e;
}
});
}
public function publish(Post $post): Post
{
return DB::transaction(function () use ($post) {
try {
$oldStatus = $post->status;
$post->publish();
// Log status change
LoggingService::activity('Post published', [
'post_id' => $post->id,
'title' => $post->title,
'old_status' => $oldStatus,
'new_status' => $post->status,
]);
return $post;
} catch (\Exception $e) {
LoggingService::error('Failed to publish post', $e);
throw $e;
}
});
}
}2. Service Class untuk Complex Operations:
php
<?php
// app/Services/BlogService.php
namespace App\Services;
use App\Models\Post;
use App\Models\Category;
use App\Models\Tag;
use App\Repositories\PostRepository;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Cache;
class BlogService
{
public function __construct(
private PostRepository $postRepository
) {}
public function getFeaturedPosts(int $limit = 3): Collection
{
return Cache::remember("featured_posts_{$limit}", 3600, function () use ($limit) {
return Post::published()
->featured()
->with(['author', 'category'])
->latest('published_at')
->limit($limit)
->get();
});
}
public function getPopularPosts(int $limit = 5, int $days = 7): Collection
{
$key = "popular_posts_{$limit}_{$days}";
return Cache::remember($key, 1800, function () use ($limit, $days) {
return Post::published()
->where('published_at', '>=', now()->subDays($days))
->with(['author', 'category'])
->orderBy('view_count', 'desc')
->limit($limit)
->get();
});
}
public function getRecentPosts(int $limit = 10): Collection
{
return Cache::remember("recent_posts_{$limit}", 300, function () use ($limit) {
return Post::published()
->with(['author', 'category'])
->latest('published_at')
->limit($limit)
->get();
});
}
public function getCategoriesWithCounts(): Collection
{
return Cache::remember('categories_with_counts', 3600, function () {
return Category::active()
->withCount(['publishedPosts'])
->orderBy('order')
->get();
});
}
public function getPopularTags(int $limit = 20): Collection
{
return Cache::remember('popular_tags', 3600, function () use ($limit) {
return Tag::withCount(['posts'])
->orderBy('usage_count', 'desc')
->limit($limit)
->get();
});
}
public function searchPosts(string $query, int $perPage = 12)
{
return Post::published()
->search($query)
->with(['author', 'category'])
->latest('published_at')
->paginate($perPage);
}
public function getRelatedPosts(Post $post, int $limit = 3): Collection
{
$cacheKey = "related_posts_{$post->id}_{$limit}";
return Cache::remember($cacheKey, 3600, function () use ($post, $limit) {
return Post::published()
->where('id', '!=', $post->id)
->where(function ($query) use ($post) {
$query->where('category_id', $post->category_id)
->orWhereHas('tags', function ($q) use ($post) {
$q->whereIn('id', $post->tags->pluck('id'));
});
})
->with(['author', 'category'])
->limit($limit)
->get();
});
}
public function incrementPostViews(Post $post): void
{
if ($post->shouldIncrementViewCount()) {
$post->incrementViewCount();
// Clear popular posts cache
Cache::forget('popular_posts_5_7');
Cache::forget('popular_posts_10_30');
}
}
public function clearCache(): void
{
Cache::flush();
}
}
E. Basic Security Measures (45 menit)
1. Security Middleware:
php
<?php
// app/Http/Middleware/SecurityHeaders.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class SecurityHeaders
{
public function handle(Request $request, Closure $next)
{
$response = $next($request);
// Add security headers
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-XSS-Protection', '1; mode=block');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
// Content Security Policy
$csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://www.google-analytics.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"img-src 'self' data: https:",
"font-src 'self' https://fonts.gstatic.com",
"connect-src 'self' https://www.google-analytics.com",
"frame-ancestors 'self'",
"form-action 'self'",
];
$response->headers->set('Content-Security-Policy', implode('; ', $csp));
return $response;
}
}php
<?php
// app/Http/Middleware/PreventSpam.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class PreventSpam
{
public function handle(Request $request, Closure $next)
{
// Check for comment spam
if ($request->is('comments') && $request->method() === 'POST') {
$key = 'comment_attempt_' . $request->ip();
if (Cache::has($key)) {
return response()->json([
'message' => 'Please wait before submitting another comment.'
], 429);
}
Cache::put($key, true, 60); // 1 minute cooldown
}
// Check for contact form spam
if ($request->is('contact') && $request->method() === 'POST') {
// Honeypot field
if (!empty($request->input('website'))) {
return response()->json([
'message' => 'Submission rejected.'
], 400);
}
// Time-based check (form should take at least 3 seconds)
$formStartTime = $request->input('_timestamp');
if ($formStartTime && (time() - $formStartTime) < 3) {
return response()->json([
'message' => 'Please take your time filling out the form.'
], 400);
}
}
return $next($request);
}
}2. Rate Limiting:
php
// app/Http/Kernel.php
protected $middlewareAliases = [
// ...
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
];
// app/Providers/RouteServiceProvider.php
protected function configureRateLimiting(): void
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
RateLimiter::for('comments', function (Request $request) {
return Limit::perHour(5)->by($request->ip());
});
RateLimiter::for('contact', function (Request $request) {
return Limit::perHour(3)->by($request->ip());
});
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
});
RateLimiter::for('registration', function (Request $request) {
return Limit::perHour(10)->by($request->ip());
});
}
// routes/web.php
Route::middleware(['throttle:comments'])->group(function () {
Route::post('/comments', [CommentController::class, 'store']);
});
Route::middleware(['throttle:contact'])->group(function () {
Route::post('/contact', [ContactController::class, 'send']);
});3. Input Validation & Sanitization:
php
<?php
// app/Http/Requests/PostRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Support\Str;
class PostRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->check() && auth()->user()->can('create', \App\Models\Post::class);
}
public function rules(): array
{
$rules = [
'title' => 'required|string|max:200|min:3',
'slug' => [
'required',
'string',
'max:220',
'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/',
Rule::unique('posts')->ignore($this->route('post')),
],
'content' => 'required|string|min:100|max:50000',
'excerpt' => 'nullable|string|max:500',
'category_id' => 'nullable|exists:categories,id',
'status' => 'required|in:draft,review,published,archived',
'published_at' => 'nullable|date|after_or_equal:now',
'meta_title' => 'nullable|string|max:200',
'meta_description' => 'nullable|string|max:500',
'meta_keywords' => 'nullable|string|max:200',
'is_featured' => 'boolean',
'is_pinned' => 'boolean',
'allow_comments' => 'boolean',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id|integer',
];
// Add featured image validation if present
if ($this->hasFile('featured_image')) {
$rules['featured_image'] = 'image|mimes:jpeg,png,jpg,gif,webp|max:5120|dimensions:min_width=400,min_height=300';
}
return $rules;
}
public function messages(): array
{
return [
'title.required' => 'Judul artikel wajib diisi.',
'title.min' => 'Judul minimal 3 karakter.',
'slug.regex' => 'Slug hanya boleh berisi huruf kecil, angka, dan tanda hubung.',
'content.min' => 'Konten minimal 100 karakter.',
'featured_image.dimensions' => 'Gambar minimal berukuran 400x300 piksel.',
'tags.*.exists' => 'Tag yang dipilih tidak valid.',
];
}
public function prepareForValidation(): void
{
// Sanitize input
$this->merge([
'title' => strip_tags($this->title),
'excerpt' => strip_tags($this->excerpt),
'meta_title' => strip_tags($this->meta_title),
'meta_description' => strip_tags($this->meta_description),
'meta_keywords' => strip_tags($this->meta_keywords),
]);
// Auto-generate slug if empty
if (!$this->has('slug') && $this->has('title')) {
$this->merge([
'slug' => Str::slug($this->title)
]);
}
// Auto-generate excerpt if empty
if (!$this->has('excerpt') && $this->has('content')) {
$excerpt = strip_tags($this->content);
$excerpt = Str::limit($excerpt, 150);
$this->merge(['excerpt' => $excerpt]);
}
// Auto-generate meta fields if empty
if (!$this->has('meta_title')) {
$this->merge(['meta_title' => $this->title]);
}
if (!$this->has('meta_description') && $this->has('excerpt')) {
$this->merge(['meta_description' => $this->excerpt]);
}
}
public function validated($key = null, $default = null)
{
$validated = parent::validated($key, $default);
// Add user_id
$validated['user_id'] = auth()->id();
// Clean HTML content (allow only safe tags)
if (isset($validated['content'])) {
$validated['content'] = $this->cleanHtml($validated['content']);
}
return $validated;
}
private function cleanHtml(string $html): string
{
// Allow only safe HTML tags
$allowedTags = '<p><br><strong><b><em><i><u><s><a><code><pre><blockquote><ul><ol><li><h1><h2><h3><h4><h5><h6><img><table><thead><tbody><tr><th><td>';
$cleaned = strip_tags($html, $allowedTags);
// Remove dangerous attributes
$cleaned = preg_replace('/on\w+\s*=\s*".*?"/', '', $cleaned);
$cleaned = preg_replace('/on\w+\s*=\s*\'.*?\'/', '', $cleaned);
$cleaned = preg_replace('/javascript:/i', '', $cleaned);
return $cleaned;
}
}4. SQL Injection Prevention:
php
// Always use Eloquent or Query Builder with parameter binding
// BAD:
$posts = DB::select("SELECT * FROM posts WHERE title = '{$request->title}'");
// GOOD:
$posts = Post::where('title', $request->title)->get();
// OR
$posts = DB::table('posts')->where('title', $request->title)->get();
// For raw queries, use parameter binding
$posts = DB::select("SELECT * FROM posts WHERE title = ?", [$request->title]);
F. Praktikum: Implementasi Refinement (60 menit)
Tugas: Implementasi semua refinement dan improvement
Langkah-langkah:
Durasi: 3 jam (Presentasi: 2 jam, Diskusi: 1 jam)
A. Project Demo & Presentasi (90 menit)
Format Presentasi (10 menit per peserta):
Demo Checklist:
B. Review ERD vs Implementasi Aktual (45 menit)
1. Original ERD Review:
sql
-- ERD yang didesain di Fase 1: -- users (1) --- (N) posts -- categories (1) --- (N) posts -- posts (N) --- (M) tags (via post_tag) -- posts (1) --- (N) comments -- users (1) --- (N) comments -- users (1) --- (N) portfolios
2. Actual Implementation Review:
php
// Check apakah semua relationships diimplementasi dengan benar
class User {
hasMany(Post::class);
hasMany(Comment::class);
hasMany(Portfolio::class);
}
class Post {
belongsTo(User::class);
belongsTo(Category::class);
belongsToMany(Tag::class);
hasMany(Comment::class);
}
// Check apakah semua constraints diimplementasi
// Foreign keys, cascading, indexes3. Comparison Table:
FeatureDesain AwalImplementasi AktualPerubahan & AlasanUser roles | admin, author, guest | admin, author, editor, subscriber | Ditambah editor untuk moderation
Post status | draft, published, archived | draft, review, scheduled, published, archived | Ditambah workflow publishing
Comment system | basic | dengan reply, moderation, spam detection | Enhanced untuk user experience
Media handling | featured image only | gallery, multiple formats, optimization | Untuk portfolio needs
Audit logging | tidak ada | complete audit trail | Untuk security & debugging
Caching strategy | basic | multi-layer caching | Untuk performance
C. Diskusi Trade-off Desain Database (45 menit)
1. Denormalization untuk Performance:
sql
-- Trade-off: Menyimpan calculated fields di posts table -- Desain normal: hitung count setiap query -- Implementasi: simpan view_count, comment_count di posts ALTER TABLE posts ADD COLUMN comment_count INT DEFAULT 0, ADD COLUMN view_count INT DEFAULT 0; -- Pro: Query lebih cepat -- Con: Data redundancy, perlu maintain consistency
2. JSON Fields untuk Flexibility:
sql
-- Trade-off: JSON fields vs additional tables -- Desain normal: buat table post_meta -- Implementasi: gunakan JSON field ALTER TABLE posts ADD COLUMN meta JSON; -- Pro: Flexible, mudah extend -- Con: Tidak bisa query secara efficient, tidak ada type safety
3. Soft Delete vs Hard Delete:
sql
-- Trade-off: deleted_at vs physical deletion -- Desain: soft delete untuk semua major tables -- Implementasi: dengan cascade consideration -- Pro: Data recovery possible, audit trail -- Con: Storage growth, query complexity
4. Indexing Strategy:
sql
-- Trade-off: More indexes vs write performance -- Indexes yang diimplementasi: -- Single column indexes untuk filtering -- Composite indexes untuk common queries -- Full-text indexes untuk search -- Pro: Read performance optimal -- Con: Write performance lebih lambat, storage lebih besar
5. Partitioning Consideration:
sql
-- Untuk large datasets (1M+ rows)
-- Partition by date untuk posts table
PARTITION BY RANGE (YEAR(published_at)) (
PARTITION p2023 VALUES LESS THAN (2024),
PARTITION p2024 VALUES LESS THAN (2025)
);
-- Pro: Query performance, maintenance
-- Con: Complexity, tidak semua queries benefit
D. Technical Feedback Session (60 menit)
1. Code Review Checklist:
markdown
## Code Quality - [ ] Consistent coding style (Pint/PSR) - [ ] Meaningful variable/function names - [ ] Proper comments and documentation - [ ] No duplicate code (DRY) ## Laravel Best Practices - [ ] Proper use of Eloquent relationships - [ ] Query optimization (N+1 prevention) - [ ] Service classes for business logic - [ ] Form Request validation - [ ] Proper error handling ## Security - [ ] SQL injection prevention - [ ] XSS protection - [ ] CSRF protection - [ ] Input validation/sanitization - [ ] Rate limiting - [ ] Proper authentication/authorization ## Performance - [ ] Database indexing - [ ] Query optimization - [ ] Caching strategy - [ ] Asset optimization - [ ] Lazy loading where appropriate ## Architecture - [ ] Proper separation of concerns - [ ] SOLID principles applied - [ ] Design patterns where appropriate - [ ] Testable code structure
2. Common Issues & Solutions:
php
// Issue: N+1 Query Problem
// Before:
foreach ($posts as $post) {
echo $post->author->name; // Query setiap loop
}
// Solution: Eager Loading
$posts = Post::with('author')->get();
// Issue: Business logic in controllers
// Before:
public function store(Request $request) {
// 50 lines of business logic
}
// Solution: Service Class
public function store(PostRequest $request) {
$post = $this->postService->create($request->validated());
return redirect()->route('posts.show', $post);
}
// Issue: Direct file upload handling
// Before:
$request->file('image')->store('public');
// Solution: Use intervention/image for processing
$image = Image::make($request->file('image'))
->resize(1200, 800)
->save(storage_path('app/public/' . $filename));3. Performance Optimization Tips:
php
// 1. Use select() untuk mengambil kolom yang diperlukan saja
Post::select('id', 'title', 'slug')->get();
// 2. Cache expensive queries
Cache::remember('popular_posts', 3600, function () {
return Post::popular()->limit(10)->get();
});
// 3. Use database indexes effectively
Schema::table('posts', function ($table) {
$table->index(['status', 'published_at']);
});
// 4. Paginate large datasets
Post::paginate(15); // Instead of Post::all()
// 5. Use chunk() untuk batch processing
Post::chunk(200, function ($posts) {
foreach ($posts as $post) {
// Process
}
});4. Security Checklist:
markdown
## Authentication & Authorization - [ ] Password hashing (bcrypt) - [ ] Role-based access control - [ ] Session management - [ ] Remember me functionality secure ## Input Validation - [ ] Server-side validation - [ ] XSS protection (Blade escaping) - [ ] SQL injection prevention - [ ] File upload validation ## API Security - [ ] Rate limiting - [ ] CORS configuration - [ ] API authentication (if applicable) - [ ] Input sanitization ## Server Security - [ ] HTTPS enforcement - [ ] Security headers - [ ] CSRF protection - [ ] Secure cookies
E. Final Project Evaluation & Next Steps (30 menit)
1. Project Completion Checklist:
markdown
## Core Features - [ ] User authentication & registration - [ ] Blog CRUD operations - [ ] Category & tag management - [ ] Comment system - [ ] Portfolio management - [ ] Admin panel (Filament) - [ ] Responsive design ## Advanced Features - [ ] Publishing workflow - [ ] Search functionality - [ ] RSS feed - [ ] Sitemap generation - [ ] Analytics integration - [ ] Social sharing ## Quality Assurance - [ ] Error handling - [ ] Logging - [ ] Testing (unit/feature) - [ ] Performance optimization - [ ] Security measures - [ ] Documentation ## Deployment Ready - [ ] Environment configuration - [ ] Database migrations - [ ] Asset compilation - [ ] Backup strategy - [ ] Monitoring setup
2. Deployment Preparation:
bash
# Production setup checklist 1. Update .env for production 2. Generate application key 3. Run migrations 4. Seed necessary data 5. Compile assets 6. Set up queue worker 7. Configure cron jobs 8. Set up monitoring 9. Configure backups 10. SSL certificate # Deployment commands php artisan config:cache php artisan route:cache php artisan view:cache php artisan optimize npm run production
3. Next Learning Path:
text
Level Up Path: 1. Advanced Laravel - Queues & Jobs - Events & Listeners - API development - Microservices 2. Frontend Deep Dive - Vue.js/React integration - SPA development - Progressive Web Apps 3. DevOps & Deployment - Docker containerization - CI/CD pipelines - Server management - Scaling strategies 4. Specializations - E-commerce - SaaS development - Real-time applications - Mobile app backends
F. Praktikum: Final Presentation Preparation (60 menit)
Tugas: Prepare and practice project presentation
Preparation Steps:
Complete Project Submission:
Evaluation Criteria:
CriteriaExcellent (90-100)Good (70-89)Satisfactory (50-69)Needs Improvement (<50)Functionality | Semua fitur bekerja sempurna, termasuk advanced features | Semua core features bekerja, beberapa advanced features incomplete | Core features bekerja dengan minor bugs | Major features tidak bekerja
Code Quality | Clean code, excellent structure, full documentation | Good structure, adequate documentation | Basic structure, minimal documentation | Poor structure, no documentation
Performance | Highly optimized, excellent caching strategy | Good optimization, basic caching | Some optimization, no caching | Performance issues evident
Security | Comprehensive security measures implemented | Basic security implemented | Minimal security measures | Security vulnerabilities present
UI/UX | Professional, responsive, excellent user experience | Good design, responsive, decent UX | Basic design, some responsive issues | Poor design, not responsive
Database Design | Optimal schema, proper indexes, good normalization | Good schema, some indexes, basic normalization | Basic schema, few indexes | Poor schema design
Peserta memiliki project portfolio yang siap untuk:
🎉 CONGRATULATIONS ON COMPLETING THE BOOTCAMP! 🎉
Setelah menyelesaikan semua fase, peserta telah:
Tetap terhubung dengan komunitas dan terus belajar! 🚀