Ini adalah website personal Selamat M. Harjono

Kata-Kata Hari Ini

"Better Late Then Never"

216
Des 17
Bootcamp HMMI dengan Tema "Personal Blog & Portfolio Management System" - Fase IV

Bootcamp HMMI dengan Tema "Personal Blog & Portfolio Management System" - Fase IV

Rabu, 17 Desember 2025 | oleh Selamat Muliyadi Harjono | Materi


FASE 4 – PROJECT IMPLEMENTATION (Pertemuan 13–15)

🔹 STRATEGI PEMBELAJARAN FASE 4

  • "Production-Ready Focus": Semua aspek aplikasi siap deploy
  • "Best Practices Enforcement": Kode quality, security, performance
  • "End-to-End Implementation": Dari database design sampai deployment
  • "Project Showcase": Presentasi dan demo final

📚 PERTEMUAN 13 – IMPLEMENTASI PROJECT

Durasi: 3 jam (Teori: 1 jam, Praktik: 2 jam)

🎯 TUJUAN PEMBELAJARAN

  1. Setup project final dengan struktur yang rapi
  2. Implementasi core CRUD lengkap untuk semua fitur
  3. Menerapkan best practices dalam struktur folder dan kode

📖 MATERI DETAIL


A. Opening & Project Kickoff (30 menit)

  • Review progress Fase 1-3
  • Introduction: "Sekarang kita akan build project final dari 0 dengan semua best practices"
  • Demo aplikasi contoh yang sudah selesai


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=null



3. 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:

  1. Create new Laravel project dengan nama blog-portfolio-pro
  2. Install semua required packages (Filament, Spatie, etc.)
  3. Setup environment configuration untuk development dan production
  4. Create semua migrations dengan struktur optimal
  5. Implement semua models dengan traits dan relationships
  6. Test database setup dengan migrations dan seeders
  7. Verify structure dengan artisan commands:
  8. bash
  9. php artisan migrate:status
    php artisan db:seed
    php artisan tinker
    >>> \App\Models\User::count()
    >>> \App\Models\Post::count()


🎯 TUGAS PERTEMUAN 13

  1. Setup project baru dari awal dengan semua best practices
  2. Implementasi semua database migrations dengan optimal indexes
  3. Buat semua models dengan:
    • Traits untuk reusable functionality
    • Proper relationships
    • Query scopes
    • Accessors/mutators
  4. Setup Filament admin panel dengan basic configuration
  5. Create seeder data untuk testing:
    • 10 users dengan berbagai roles
    • 20 categories
    • 50 tags
    • 100 posts dengan berbagai status
    • 200 comments
    • 15 portfolio items
  6. Document project structure di README.md

📚 PERTEMUAN 14 – REFINEMENT & IMPROVEMENT

Durasi: 3 jam (Teori: 1 jam, Praktik: 2 jam)

🎯 TUJUAN PEMBELAJARAN

  1. Improve UI/UX untuk admin dan frontend
  2. Implementasi error handling yang baik
  3. Maintain data consistency dengan transactions
  4. Apply basic security measures

📖 MATERI DETAIL


A. Review & Code Quality Check (30 menit)

  • Code review dengan Laravel Pint
  • Static analysis dengan PHPStan
  • Performance profiling dengan Debugbar
  • Security scan dengan Laravel Security Checker


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:

  1. Setup Blade components untuk reusable UI
  2. Implement error handling dengan custom exception handler
  3. Create service classes untuk business logic
  4. Add database transactions untuk data consistency
  5. Implement security measures:
    • Security headers middleware
    • Rate limiting
    • Input validation and sanitization
    • SQL injection prevention
  6. Test all improvements:
    • Test error pages (404, 500)
    • Test form validation
    • Test rate limiting
    • Test database transactions (rollback on failure)

🎯 TUGAS PERTEMUAN 14

  1. Improve frontend UI dengan Blade components
  2. Implement error handling dengan custom pages and logging
  3. Create service classes for:
    • Blog operations
    • Image handling
    • Caching
    • Logging
  4. Add database transactions for all critical operations
  5. Implement security measures:
    • Security headers
    • Rate limiting
    • Input validation
    • SQL injection prevention
  6. Performance optimization:
    • Database indexing
    • Query optimization
    • Caching strategy
  7. Test everything and document improvements

📚 PERTEMUAN 15 – REVIEW & PRESENTASI

Durasi: 3 jam (Presentasi: 2 jam, Diskusi: 1 jam)

🎯 TUJUAN PEMBELAJARAN

  1. Presentasi project final
  2. Review implementasi vs desain awal
  3. Diskusi trade-off desain database
  4. Menerima dan memberikan feedback teknis

📖 MATERI DETAIL


A. Project Demo & Presentasi (90 menit)

Format Presentasi (10 menit per peserta):

  1. Introduction (2 menit):
    • Nama dan background
    • Fitur unik project
  2. Live Demo (5 menit):
    • Frontend features (blog, portfolio)
    • Admin panel capabilities
    • Special features implemented
  3. Technical Highlights (3 menit):
    • Architecture decisions
    • Challenges and solutions
    • Key learnings

Demo Checklist:

  • Homepage dengan featured posts
  • Blog listing dengan filtering
  • Single post view dengan comments
  • Portfolio showcase
  • Admin panel login
  • Post management (CRUD)
  • Category/tag management
  • User management
  • Publishing workflow
  • Responsive design test


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, indexes



3. 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:

  1. Create presentation slides (max 10 slides):
    • Introduction & project overview
    • Architecture diagram
    • Key features demo
    • Technical challenges & solutions
    • Learning outcomes
  2. Prepare demo environment:
    • Ensure all features work
    • Prepare test data
    • Check responsiveness
    • Backup project
  3. Practice presentation:
    • Time yourself (10 minutes max)
    • Prepare for Q&A
    • Test demo flow
  4. Gather feedback materials:
    • GitHub repository link
    • Live demo URL (if available)
    • Documentation
    • Code samples

🎯 TUGAS FINAL FASE 4

Complete Project Submission:

  1. GitHub Repository dengan:
    • Clean commit history
    • README.md dengan:
      • Project overview
      • Installation instructions
      • Features list
      • API documentation (if any)
    • License file
  2. Project Documentation:
    • Database schema documentation
    • API documentation (if applicable)
    • Admin user manual
    • Deployment guide
  3. Presentation Materials:
    • Presentation slides (PDF/PPT)
    • Demo video (5-10 minutes)
    • Architecture diagram
    • ERD final
  4. Live Demo (jika memungkinkan):
    • Deployed application
    • Admin access for review
    • Test user accounts
  5. Self-Assessment Report:
    • What went well
    • Challenges faced
    • Lessons learned
    • Future improvements planned

Evaluation Criteria:

  1. Functionality (30%): Semua fitur bekerja dengan baik
  2. Code Quality (25%): Clean code, best practices, documentation
  3. Performance (20%): Optimized queries, caching, fast loading
  4. Security (15%): Proper validation, authentication, protection
  5. Presentation (10%): Clear demo, good communication, professional materials

🎯 EVALUASI FINAL PROJECT

Rubrik Penilaian:

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

Certification Requirements:

  • Lulus: Score ≥ 70% dengan semua core features working
  • Dengan Pujian: Score ≥ 85% dengan excellent implementation
  • Perbaikan: Score < 70%, perlu revisi dan resubmit

🎓 KELULUSAN & SERTIFIKASI

Skills Acquired:

  1. Laravel Mastery: MVC, Eloquent, Authentication, Validation
  2. Database Design: ERD, Normalization, Optimization, SQL
  3. Admin Panel Development: FilamentPHP, Role Management
  4. Frontend Development: Blade Components, Responsive Design
  5. Project Management: Version Control, Deployment, Documentation

Portfolio Project:

Peserta memiliki project portfolio yang siap untuk:

  1. Showcase to employers: Full-stack Laravel application
  2. Freelance opportunities: Blog/Portfolio CMS
  3. Business use: Actual blog/portfolio for personal use
  4. Foundation for larger projects: Scalable architecture

Next Steps Recommendation:

  1. Deploy project: DigitalOcean, Laravel Forge, Vapor
  2. Add more features: Newsletter, Membership, E-commerce
  3. Learn advanced topics: Queues, Events, API development
  4. Contribute to open source: Laravel packages, Filament plugins
  5. Build client projects: Freelance or full-time opportunities

🎉 CONGRATULATIONS ON COMPLETING THE BOOTCAMP! 🎉

Setelah menyelesaikan semua fase, peserta telah:

  • ✅ Menguasai full-stack development dengan Laravel
  • ✅ Memahami database design dan optimization
  • ✅ Membangun admin panel professional dengan Filament
  • ✅ Menerapkan best practices security dan performance
  • ✅ Menyelesaikan project portfolio yang production-ready

Tetap terhubung dengan komunitas dan terus belajar! 🚀



Dilihat

216 kali

Trending

11