Tutorial · Bahasa Indonesia
Sudah paham Laravel?
Fiber tinggal ganti sintaks.
Dua bagian. Sembilan bab konsep yang menjelaskan Go Fiber berdampingan dengan padanannya di Laravel, lalu delapan langkah praktik membangun blog MVC lengkap dengan login, CSRF, dan tiga tabel berelasi, dari folder kosong sampai jalan.
Bagian 1 · Konsep
Instalasi dari Nol
Dari folder kosong sampai aplikasi jalan: go mod init, go get, struktur folder, .env.
Bab 01Route
app.Get vs Route::get, route group, middleware global, dan method spoofing _method.
Bab 02Controller
Signature handler, ambil input form, route param, flash message, dan render view.
Bab 03Model GORM
Struct + tag gorm sebagai pengganti Eloquent, AutoMigrate, dan operasi CRUD.
Bab 04Middleware Auth
Cek session user_id, c.Locals sebagai pengganti Auth::user(), pasang per group.
Bab 05View & Template
html/template vs Blade: layout, range, if, scope variabel, dan fungsi kustom.
Bab 06Relasi & Preload
belongsTo dan hasMany di GORM, eager loading Preload, dan foreign key di MySQL.
Bab 07CSRF & Session
Token per sesi, middleware verifikasi, regenerasi setelah login, halaman 419.
Bab 08Alur MVC End-to-End
Telusuri satu request dari route, middleware, controller, model, sampai HTML.
Bagian 2 · Praktik
Fondasi Aplikasi
main.go, config, dan .env sampai server jalan dan terhubung ke MySQL.
Praktik 2Model & Migrasi
Tiga struct model, tag gorm, relasi, dan tabel yang terbentuk otomatis.
Praktik 3Helper
Session, flash, CSRF, slug, dan Render yang menyuntik data umum ke view.
Praktik 4Layout & Halaman Auth
Dua layout, halaman login dan registrasi, serta halaman error 419.
Praktik 5Tiga Middleware
MethodOverride, CSRF, dan Auth beserta urutan pemasangannya.
Praktik 6Registrasi & Login
Controller auth lengkap dengan bcrypt, session fixation, dan dashboard.
Praktik 7CRUD Tiga Resource
Controller dan view untuk user, kategori, dan post beserta aturan penjagaannya.
Praktik 8Route & Uji Coba
routes/web.go final, daftar uji manual, dan tabel masalah yang sering muncul.
Proyek ini adalah aplikasi Blog MVC berbasis Go Fiber yang dibangun dengan struktur dan pendekatan ala Laravel. Tujuannya: programmer yang sudah familiar dengan Laravel bisa belajar Fiber dengan cepat karena pola, nama file, dan alur kerjanya sengaja dibuat mirip.
Prasyarat
- Go 1.26+
- MySQL 8 (contoh: via DBngin) aktif di
127.0.0.1:3306
Setup
# 1. Buat database
mysql -h 127.0.0.1 -P 3306 -u root -e "CREATE DATABASE IF NOT EXISTS belajar_fiber CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
# 2. Siapkan env
cp .env.example .env
# ganti SESSION_KEY dengan hasil: openssl rand -base64 32
# 3. Install dependency
go mod tidy
# 4. Jalankan
go run .
Buka http://localhost:3000. Tabel dibuat otomatis saat aplikasi pertama jalan (auto migrate).
Tutorial lengkap dari folder kosong sampai install dependency ada di 00-installation.md.
Struktur Folder vs Laravel
| Folder ini | Analog di Laravel |
|---|---|
routes/web.go |
routes/web.php |
controllers/ |
app/Http/Controllers/ |
models/ |
app/Models/ |
middlewares/ |
app/Http/Middleware/ |
views/layouts/ |
resources/views/layouts/ |
views/ |
resources/views/ |
config/ |
config/ |
.env |
.env |
helpers/ |
app helper service |
Fitur
- Registrasi, login, logout (bcrypt + session)
- Middleware auth & CSRF
- CRUD lengkap 3 tabel berelasi: users, categories, posts
- Dashboard dengan statistik
- Bootstrap 5
- Flash message sukses/error
Alur Belajar yang Disarankan
- Baca
00-installation.md— setup dari nol & install dependency. - Baca
08-mvc-flow.mduntuk gambaran besar. - Pelajari
01-route.mdlalu02-controller.md. - Dalami model di
03-model-gorm.mddan relasi di06-relationship.md. - Pahami keamanan di
07-csrf-session.mddan04-middleware-auth.md. - Baca
05-view-template.mduntuk perbandingan templating. - Kerjakan bagian Praktik P1 sampai P8 untuk membangun aplikasinya sendiri dari folder kosong.
Dianjurkan untuk langsung eksekusi kodenya sambil membaca, bukan cuma baca pasif.
Bab 00
Instalasi dari Nol
Dari folder kosong sampai aplikasi jalan: go mod init, go get, struktur folder, .env.
Tutorial ini menjelaskan langkah demi langkah dari folder kosong sampai aplikasi bisa jalan — analog composer create-project laravel/laravel + php artisan serve di Laravel.
1. Prasyarat
- Go terpasang (1.26+). Cek:
bash go version - MySQL 8 aktif. Contoh: via DBngin di
127.0.0.1:3306. Cek akses:bash mysql -h 127.0.0.1 -P 3306 -u root -e "SELECT VERSION();"
2. Inisialisasi Module
Module Go = analog composer.json + autoload. Nama module menentukan import path di kode.
mkdir belajar-fiber
cd belajar-fiber
go mod init belajar-fiber
Hasil: file go.mod dibuat.
module belajar-fiber
go 1.26.3
- Laravel:
composer init→composer.json. - Fiber:
go mod init→go.mod. - Import di kode pakai path module:
import "belajar-fiber/config", dsb. Analog namespace LaravelApp\Config.
3. Install Dependency
Install semua package yang dipakai proyek:
go get github.com/gofiber/fiber/v2@latest
go get github.com/joho/godotenv@latest
go get golang.org/x/crypto@latest
go get gorm.io/gorm@latest
go get gorm.io/driver/mysql@latest
| Package | Fungsi | Analog Laravel |
|---|---|---|
gofiber/fiber/v2 |
web framework | Laravel framework |
joho/godotenv |
baca file .env |
vlucas/phpdotenv (bawaan) |
golang.org/x/crypto |
bcrypt hashing password | Hash::make() |
gorm.io/gorm |
ORM | Eloquent |
gorm.io/driver/mysql |
driver koneksi MySQL | illuminate/database + PDO MySQL |
go get juga menambahkan dependency tidak langsung (transitif) ke go.mod + go.sum — analog Composer yang menarik vendor/.
Bedanya dengan Laravel: Laravel sudah bundle semua package di
vendor/. Go tidak menyimpan dependency di folder proyek — semuanya di-cache di module cache global (go env GOMODCACHE).go mod tidymenghapus/menambah dependency yang tidak/kurang dipakai.
4. Verifikasi go.mod + go.sum
Setelah install, go.mod berisi daftar require:
module belajar-fiber
go 1.26.3
require (
github.com/gofiber/fiber/v2 v2.52.14
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.14.0
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.2
)
go.sum berisi checksum tiap dependency — analog composer.lock. Commit keduanya supaya versi konsisten di semua machine.
5. Rapikan Dependency
go mod tidy
Fungsi go mod tidy (sering dipanggil setelah menambah import baru):
- Tambah dependency yang dipakai tapi belum terdaftar
- Hapus dependency terdaftar tapi tidak dipakai
- Sinkronkan go.sum
Analog composer install + update lock. Jalankan kapan saja ada error missing go.sum entry.
6. Struktur Folder Awal
Buat struktur proyek ala Laravel:
mkdir config controllers models middlewares helpers views routes
mkdir -p views/layouts views/auth
Setelah semua file ditulis, strukturnya jadi:
belajar-fiber/
├── main.go # entry point (analog bootstrap/app.php + public/index.php)
├── go.mod / go.sum # composer.json / composer.lock
├── .env / .env.example # file env Laravel
├── config/ # config database, session, env
├── routes/web.go # routes/web.php
├── controllers/ # app/Http/Controllers
├── models/ # app/Models
├── middlewares/ # app/Http/Middleware
├── helpers/ # helper service
├── views/ # resources/views
└── docs/ # materi belajar ini7. Setup Database & Env
Buat database MySQL:
mysql -h 127.0.0.1 -P 3306 -u root -e "CREATE DATABASE IF NOT EXISTS belajar_fiber CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
Siapkan .env (analog .env Laravel):
cp .env.example .env
openssl rand -base64 32 # tempel hasilnya ke SESSION_KEY
Isi .env:
APP_NAME=BelajarFiber
APP_PORT=3000
SESSION_KEY=ganti-dengan-kunci-rahasia-acak-panjang
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=root
DB_PASSWORD=
DB_NAME=belajar_fiber
Jangan commit
.env— masuk.gitignore. Hanya.env.exampleyang di-commit, sama seperti Laravel.
8. Build & Jalankan
go build ./... # compile semua package, cek error
go vet ./... # static check (analog php artisan about --bugfix? lebih ke lint)
go run . # analog php artisan serve
Buka http://localhost:3000. Tabel users, categories, posts dibuat otomatis saat startup (auto migrate — analog php artisan migrate).
9. Urutan Membuat Aplikasi (Alur Sebenarnya)
Kalau membangun dari nol, urutan penulisannya:
main.go— entry point: load config, konek DB, init session, register route.config/— baca.env, koneksi GORM, session store.models/— struct User/Category/Post + relasi.helpers/— session, flash, CSRF, slug, render view.views/— layout + halaman auth + halaman CRUD.middlewares/— MethodOverride, Auth, CSRF.controllers/— Auth, User, Category, Post.routes/web.go— satukan semuanya.
Setiap tahap: go build ./... dulu untuk pastikan tidak error, baru lanjut.
Perbandingan Perintah Cepat
| Aksi | Laravel | Fiber (Go) |
|---|---|---|
| Init project | composer create-project laravel/laravel blog |
go mod init belajar-fiber |
| Install dep | composer require package |
go get package@latest |
| Lock file | composer.lock |
go.sum |
| Sync dep | composer install |
go mod tidy |
| Jalankan dev | php artisan serve |
go run . |
| Build produksi | (biasanya lewat deploy) | go build -o app . |
| Migrasi | php artisan migrate |
otomatis via AutoMigrate di startup |
Bab 01
Route
app.Get vs Route::get, route group, middleware global, dan method spoofing _method.
File route di Fiber analog dengan routes/web.php di Laravel. Di proyek ini ada di routes/web.go.
Perbandingan Konsep
| Konsep | Laravel | Fiber (proyek ini) |
|---|---|---|
| Definisikan route | Route::get('/users', ...) |
app.Get("/users", ...) |
| Route POST | Route::post('/login', ...) |
app.Post("/login", ...) |
| Route DELETE | Route::delete('/users/{id}', ...) |
app.Delete("/users/:id", ...) |
| Parameter route | {id} |
:id |
| Ambil parameter | $request->route('id') |
c.Params("id") |
| Route group | Route::prefix(...)->middleware('auth') |
app.Group("", middlewares.Auth) |
| Middleware global | Kernel $middleware |
app.Use(...) |
| Method spoofing | @method('PUT') |
field _method + middlewares.MethodOverride |
Kode Route Lengkap (Proyek Ini)
routes/web.go:
func Register(app *fiber.App) {
app.Use(middlewares.MethodOverride)
app.Use(middlewares.CSRF)
app.Get("/", controllers.Home)
app.Get("/login", controllers.ShowLogin)
app.Post("/login", controllers.Login)
app.Get("/register", controllers.ShowRegister)
app.Post("/register", controllers.Register)
auth := app.Group("", middlewares.Auth)
auth.Post("/logout", controllers.Logout)
auth.Get("/dashboard", controllers.Dashboard)
auth.Get("/users", controllers.UserIndex)
auth.Get("/users/create", controllers.UserCreate)
auth.Post("/users", controllers.UserStore)
auth.Get("/users/:id/edit", controllers.UserEdit)
auth.Put("/users/:id", controllers.UserUpdate)
auth.Delete("/users/:id", controllers.UserDelete)
auth.Get("/categories", controllers.CategoryIndex)
auth.Get("/categories/create", controllers.CategoryCreate)
auth.Post("/categories", controllers.CategoryStore)
auth.Get("/categories/:id/edit", controllers.CategoryEdit)
auth.Put("/categories/:id", controllers.CategoryUpdate)
auth.Delete("/categories/:id", controllers.CategoryDelete)
auth.Get("/posts", controllers.PostIndex)
auth.Get("/posts/create", controllers.PostCreate)
auth.Post("/posts", controllers.PostStore)
auth.Get("/posts/:id", controllers.PostShow)
auth.Get("/posts/:id/edit", controllers.PostEdit)
auth.Put("/posts/:id", controllers.PostUpdate)
auth.Delete("/posts/:id", controllers.PostDelete)
}
Route Group + Middleware
Di Laravel:
Route::middleware('auth')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index']);
});
Di Fiber:
auth := app.Group("", middlewares.Auth)
auth.Get("/dashboard", controllers.Dashboard)
Semua route di dalam group auth melewati middleware Auth dulu. Analog ->middleware('auth').
Middleware Global vs Per-Group
app.Use(middlewares.MethodOverride)danapp.Use(middlewares.CSRF)dipasang global — semua request lewat, analog middleware diKernel.php(global stack).app.Group("", middlewares.Auth)— middlewareAuthhanya untuk route yang didaftarkan di group itu.
Method Spoofing (_method)
Laravel menggunakan @method('PUT') di Blade untuk mengirim form PUT/DELETE (karena HTML form cuma mendukung GET/POST).
Fiber tidak punya ini secara bawaan. Di proyek ini dibuat middlewares.MethodOverride:
func MethodOverride(c *fiber.Ctx) error {
if c.Method() != fiber.MethodPost {
return c.Next()
}
method := c.FormValue("_method")
switch method {
case fiber.MethodPut, fiber.MethodDelete, fiber.MethodPatch:
c.Method(method)
}
return c.Next()
}
Di view edit:
<form method="post" action="/users/1">
<input type="hidden" name="_method" value="PUT">
...
</form>
Middleware membaca _method lalu menulis ulang method request jadi PUT sebelum masuk router — persis seperti @method('PUT') di Laravel.
Kenapa Konvensi index/create/store/edit/update/delete?
Nama method ini mengikuti pola resource controller Laravel:
| Laravel | Fiber (proyek ini) | Fungsi |
|---|---|---|
index() |
UserIndex |
Tampilkan daftar |
create() |
UserCreate |
Tampilkan form tambah |
store() |
UserStore |
Proses simpan data baru |
edit() |
UserEdit |
Tampilkan form edit |
update() |
UserUpdate |
Proses perbarui data |
destroy() |
UserDelete |
Hapus data |
Bab 02
Controller
Signature handler, ambil input form, route param, flash message, dan render view.
Controller di Fiber analog dengan app/Http/Controllers/ di Laravel. Di proyek ini ada di controllers/.
Perbandingan Struktur
| Konsep | Laravel | Fiber (proyek ini) |
|---|---|---|
| Lokasi | app/Http/Controllers/*.php |
controllers/*.go |
| Signature | public function index(Request $request) |
func UserIndex(c *fiber.Ctx) error |
| Ambil input | $request->input('name') / $request->name |
c.FormValue("name") |
| Ambil route param | $request->route('id') |
c.Params("id") |
| Redirect | return redirect('/users') |
return c.Redirect("/users") |
| Redirect back | return redirect()->back() |
c.Get("Referer") (manual) |
| Render view | return view('users.index', compact('users')) |
helpers.Render(c, "app", "views/users/index.html", data) |
Contoh Nyata: CategoryStore
controllers/category_controller.go:
func CategoryStore(c *fiber.Ctx) error {
name := c.FormValue("name")
sess, err := helpers.GetSession(c)
if err != nil {
return err
}
if name == "" {
helpers.SetFlash(sess, "error", "Nama kategori wajib diisi.")
return c.Redirect("/categories/create")
}
slug := helpers.Slugify(name)
if slug == "" {
helpers.SetFlash(sess, "error", "Nama kategori tidak valid.")
return c.Redirect("/categories/create")
}
var count int64
config.DB.Model(&models.Category{}).Where("slug = ?", slug).Count(&count)
if count > 0 {
helpers.SetFlash(sess, "error", "Kategori dengan nama serupa sudah ada.")
return c.Redirect("/categories/create")
}
category := models.Category{Name: name, Slug: slug}
if err := config.DB.Create(&category).Error; err != nil {
helpers.SetFlash(sess, "error", "Gagal menyimpan kategori: "+err.Error())
return c.Redirect("/categories/create")
}
helpers.SetFlash(sess, "success", "Kategori berhasil dibuat.")
return c.Redirect("/categories")
}
Validasi Manual vs $request->validate()
Laravel punya FormRequest + $request->validate([...]). Fiber (Go) tidak punya bawaan itu, jadi validasi ditulis manual:
if name == "" { ... }
if !emailPattern.MatchString(email) { ... }
if len(password) < 6 { ... }
Konsepnya sama: cek input, kalau gagal kembalikan error (di sini lewat flash message + redirect).
Ambil Input Form
Laravel:
$name = $request->input('name');
Fiber:
name := c.FormValue("name")
c.FormValue membaca field dari form POST. Untuk field yang belum diisi nilainya "".
Route Parameter
Laravel:
public function edit(string $id) {
$user = User::findOrFail($id);
}
Fiber — parameter didapat sebagai string, perlu diubah ke uint:
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Redirect("/users")
}
var user models.User
if err := config.DB.First(&user, id).Error; err != nil { ... }
Laravel punya route model binding (findOrFail otomatis). Go tidak; kita cek First(...).Error manual — jika error berarti tidak ditemukan.
Flash Message
Laravel:
return redirect()->back()->with('error', 'Kredensial tidak cocok.');
Fiber (proyek ini) — via helpers.SetFlash:
helpers.SetFlash(sess, "error", "Kredensial tidak cocok.")
return c.Redirect("/login")
Flash tersimpan di session, dibaca sekali, lalu dihapus. Layout menampilkannya sebagai Bootstrap alert.
Render View
Laravel:
return view('categories.create', compact('categories'));
Fiber:
return helpers.Render(c, "app", "views/categories/create.html", fiber.Map{
"Title": "Tambah Kategori",
"Categories": categories,
})
helpers.Render juga otomatis menginjeksi CSRFToken, AuthUser, dan Flash ke data view — lihat docs/05-view-template.md.
Pola Controller CRUD
Semua controller mengikuti pola resource controller Laravel:
| Resource | Index | Create | Store | Edit | Update | Delete |
|---|---|---|---|---|---|---|
| User | UserIndex |
UserCreate |
UserStore |
UserEdit |
UserUpdate |
UserDelete |
| Category | CategoryIndex |
CategoryCreate |
CategoryStore |
CategoryEdit |
CategoryUpdate |
CategoryDelete |
| Post | PostIndex |
PostCreate |
PostStore |
PostEdit |
PostUpdate |
PostDelete |
Bab 03
Model GORM
Struct + tag gorm sebagai pengganti Eloquent, AutoMigrate, dan operasi CRUD.
Di Laravel, model adalah class PHP yang extends Model. Di Go, model adalah struct + tag GORM. Lokasi: models/.
Perbandingan Konsep
| Konsep | Laravel (Eloquent) | Go (GORM) |
|---|---|---|
| Definisikan model | class User extends Model |
type User struct { ... } |
| Tabel | protected $table / otomatis plural |
Nama struct → tabel (otomatis plural) |
| Kolom | protected $fillable |
tag gorm:"..." + field struct |
| Primary key | $primaryKey |
gorm:"primaryKey" |
| Timestamps | otomatis | CreatedAt, UpdatedAt otomatis |
| Migrasi | php artisan migrate |
AutoMigrate di startup |
| Simpan baru | User::create([...]) |
config.DB.Create(&user) |
| Cari by id | User::find($id) |
config.DB.First(&user, id) |
| Query where | User::where('email', $e)->first() |
config.DB.Where("email = ?", e).First(&user) |
| Update | $user->save() |
config.DB.Save(&user) |
| Hapus | $user->delete() |
config.DB.Delete(&user, id) |
| Order | ->orderBy('created_at','desc')->get() |
.Order("created_at desc").Find(&posts) |
| Count | ->count() |
.Count(&count) |
Model di Proyek Ini
models/category.go:
type Category struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:100;not null"`
Slug string `gorm:"size:120;uniqueIndex;not null"`
Posts []Post `gorm:"foreignKey:CategoryID"`
CreatedAt time.Time
UpdatedAt time.Time
}
models/post.go:
type Post struct {
ID uint `gorm:"primaryKey"`
Title string `gorm:"size:200;not null"`
Slug string `gorm:"size:220;uniqueIndex;not null"`
Content string `gorm:"type:text;not null"`
UserID uint `gorm:"not null;index"`
User User `gorm:"foreignKey:UserID"`
CategoryID uint `gorm:"not null;index"`
Category Category `gorm:"foreignKey:CategoryID"`
CreatedAt time.Time
UpdatedAt time.Time
}
Tag gorm:"..." vs Kolom Eloquent
Tag adalah petunjuk untuk GORM — analog deklarasi kolom + $fillable/$casts:
| Tag GORM | Arti | Analog Eloquent |
|---|---|---|
primaryKey |
primary key | $primaryKey |
size:200 |
panjang kolom VARCHAR(200) | Schema::string('title', 200) |
not null |
kolom wajib | ->nullable(false) |
uniqueIndex |
indeks unik (email/slug) | ->unique() |
type:text |
tipe kolom TEXT | ->text() |
foreignKey:CategoryID |
relasi FK | $this->belongsTo(Category::class) |
Auto Migrate vs php artisan migrate
Laravel butuh migration file terpisah. Go: config/database.go menjalankan AutoMigrate saat aplikasi start:
func InitDB() error {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
App.DBUser, App.DBPassword, App.DBHost, App.DBPort, App.DBName)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return err
}
DB = db
return DB.AutoMigrate(&models.User{}, &models.Category{}, &models.Post{})
}
AutoMigrate membuat tabel + kolom + foreign key yang belum ada. Perhatikan: ini tidak menghapus kolom lama (analog migrate, bukan migrate:fresh).
Operasi CRUD (Perbandingan)
Create
Laravel:
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
Fiber (di Register, controllers/auth_controller.go):
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil { ... }
user := models.User{Name: name, Email: email, Password: string(hash)}
if err := config.DB.Create(&user).Error; err != nil { ... }
config.DB.Create(&user) analog User::create(...). Setelah Create, user.ID terisi otomatis.
Read
Laravel:
$posts = Post::with('user', 'category')->orderBy('created_at', 'desc')->get();
$user = User::find($id);
Fiber:
var posts []models.Post
config.DB.Preload("User").Preload("Category").Order("created_at desc").Find(&posts)
var user models.User
config.DB.First(&user, id)
Update
Laravel:
$user->name = $request->name;
$user->save();
Fiber:
user.Name = name
user.Email = email
config.DB.Save(&user)
Delete
Laravel:
$user->delete();
Fiber:
config.DB.Delete(&models.User{}, id)
Where + Count (cek unik)
Laravel:
$exists = User::where('email', $email)->exists();
Fiber:
var count int64
config.DB.Model(&models.User{}).Where("email = ?", email).Count(&count)
if count > 0 { ... }
Ringkasan
- Eloquent: model adalah object, method dipanggil di instance/class.
- GORM: model adalah struct, query lewat
config.DB.*.&(pointer) diperlukan karena GORM mengisi hasil ke struct tersebut. - Query builder GORM
Where(...)juga chainable seperti Eloquent (.Order().Limit().Find()). - Tag
gorm:"..."adalah sumber kebenaran untuk skema DB — padanan migration + casts dalam satu tempat.
Bab 04
Middleware Auth
Cek session user_id, c.Locals sebagai pengganti Auth::user(), pasang per group.
Middleware auth melindungi route agar hanya bisa diakses user yang sudah login. Di Laravel: middleware('auth'). Di proyek ini: middlewares.Auth.
Kode Middleware Auth
middlewares/auth.go:
func Auth(c *fiber.Ctx) error {
sess, err := helpers.GetSession(c)
if err != nil {
return err
}
uid, ok := sess.Get("user_id").(uint)
if !ok {
helpers.SetFlash(sess, "error", "Silakan login terlebih dahulu.")
return c.Redirect("/login")
}
var user models.User
if err := config.DB.First(&user, uid).Error; err != nil {
sess.Delete("user_id")
_ = sess.Save()
helpers.SetFlash(sess, "error", "Sesi tidak valid, silakan login ulang.")
return c.Redirect("/login")
}
c.Locals("authUser", &user)
c.Locals("userID", uid)
return c.Next()
}
Cara Kerja
| Langkah | Laravel | Fiber (proyek ini) |
|---|---|---|
| 1. Baca user login | Auth::user() (dari session) |
sess.Get("user_id") |
| 2. Cek sudah login? | Auth::check() |
sess.Get("user_id").(uint) ada/tidak |
| 3. Belum login | redirect ke login |
c.Redirect("/login") + flash |
| 4. Login valid | session sudah menyimpan user | ambil models.User dari DB by id |
| 5. Teruskan | $next($request) |
c.Next() |
| 6. Akses user di controller/view | Auth::user() |
c.Locals("authUser") |
c.Locals vs Auth::user()
Di Laravel, user yang login diakses kapan saja lewat Auth::user() karena disimpan di session.
Di Fiber, Auth middleware menaruh data di request locals:
c.Locals("authUser", &user)
Ini hanya berlaku untuk request yang sedang berjalan (analog helper $request->user()). Controller bisa ambil:
user := c.Locals("authUser").(*models.User)
Dan helpers.Render otomatis memasukkannya ke data view sebagai .AuthUser:
data["AuthUser"] = c.Locals("authUser")
Di view:
<span class="navbar-text text-white">{{.AuthUser.Name}}</span>
Cara Pasang
Laravel:
Route::middleware('auth')->group(function () {
Route::get('/dashboard', ...);
});
Fiber:
auth := app.Group("", middlewares.Auth)
auth.Get("/dashboard", controllers.Dashboard)
Semua route yang didaftarkan di group auth melewati Auth middleware dulu.
Middleware Global vs Per-Group
Di routes/web.go ada dua jenis:
app.Use(middlewares.MethodOverride) // GLOBAL — semua request
app.Use(middlewares.CSRF) // GLOBAL — semua request
auth := app.Group("", middlewares.Auth) // HANYA route di group ini
| Jenis | Laravel | Fiber | Contoh |
|---|---|---|---|
| Global | Kernel.php $middleware |
app.Use(...) |
CSRF, MethodOverride |
| Route/group | Route::middleware(...) |
app.Group("", mw) |
Auth |
Route /login dan /register TIDAK di group auth, jadi bisa diakses tanpa login. Route /users, /posts, /dashboard, dst. berada di group auth → wajib login.
Kenapa Middleware Auth Cek DB Lagi?
Session hanya menyimpan user_id. Setiap request, middleware ambil user dari DB untuk memastikan user masih ada (belum dihapus). Ini analog perilaku Laravel yang me-refresh user dari session/DB.
Kalau user ternyata sudah dihapus dari DB (First error), session di-reset dan diarahkan ke login — mencegah akses dengan user yang tidak valid.
Bab 05
View & Template
html/template vs Blade: layout, range, if, scope variabel, dan fungsi kustom.
Fiber memakai html/template bawaan Go — analog Blade di Laravel. Perbedaannya: Blade adalah language template kaya fitur, html/template lebih sederhana tapi aman dari XSS secara default (auto-escaping).
Layout
Laravel Blade
resources/views/layouts/app.blade.php:
<!DOCTYPE html>
<html>
<head><title>@yield('title')</title></head>
<body>
<nav>...</nav>
@yield('content')
</body>
</html>
resources/views/users/index.blade.php:
@extends('layouts.app')
@section('content')
<h1>Daftar User</h1>
@endsection
Fiber (proyek ini)
views/layouts/app.html:
{{define "app"}}
<!DOCTYPE html>
<html lang="id">
<head>
<title>{{if .Title}}{{.Title}} — {{end}}{{.AppName}}</title>
</head>
<body>
<nav>...</nav>
{{template "content" .}}
</body>
</html>
{{end}}
views/users/index.html:
{{define "content"}}
<h1>Daftar User</h1>
{{end}}
Mapping:
| Blade | html/template |
|---|---|
@extends('layouts.app') |
layout ditentukan di helpers.Render (argumen ke-2) |
@section('content') |
{{define "content"}} |
@yield('content') |
{{template "content" .}} |
. (dot) di {{template "content" .}} |
meneruskan semua data ke content |
helpers.Render menggabungkan layout + view:
func Render(c *fiber.Ctx, layout, viewPath string, data fiber.Map) error {
t, err := template.New(viewPath).Funcs(funcMap).ParseFiles(layouts[layout], viewPath)
...
return t.ExecuteTemplate(c, layout, data)
}
Layout mana yang dipakai ditentukan controller:
helpers.Render(c, "app", "views/users/index.html", data) // pakai layout app (navbar)
helpers.Render(c, "auth", "views/auth/login.html", data) // pakai layout auth (tanpa navbar)
Loop
Blade
@foreach($users as $user)
<tr><td>{{ $user->name }}</td></tr>
@endforeach
Fiber
{{range .Users}}
<tr><td>{{.Name}}</td></tr>
{{end}}
Mapping: @foreach($x as $item) → {{range .X}}, akses item → {{.NamaField}}, tutup → {{end}}.
Kondisi
Blade
@if($flash)
<div class="alert">{{ $flash }}</div>
@endif
Fiber
{{if .Flash}}
<div class="alert">{{.Flash.Message}}</div>
{{end}}
Akses Relasi di View
Blade
{{ $post->user->name }}
{{ $post->category->name }}
Fiber
{{.Post.User.Name}}
{{.Post.Category.Name}}
Syaratnya: data relasi harus di-preload dulu di controller — lihat docs/06-relationship.md.
Scope Variabel (penting!)
Di dalam {{range .Users}}, dot (.) berubah jadi item user. Untuk akses data luar (misal CSRFToken dari root data), pakai $:
{{range .Users}}
<form action="/users/{{.ID}}">
<input type="hidden" name="_token" value="{{$.CSRFToken}}">
</form>
{{end}}
$ selalu mengacu ke data root (analog data yang dikirim view(...) di Laravel).
Fungsi Template
html/template mendukung fungsi kustom lewat FuncMap. Di proyek ini (helpers/view.go):
var funcMap = template.FuncMap{
"formatDate": func(t time.Time) string {
return t.Format("02 Jan 2006 15:04")
},
"truncate": func(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "..."
},
"alertClass": func(flashType string) string {
switch flashType {
case "error":
return "danger"
case "success":
return "success"
default:
return "primary"
}
},
}
Di view:
{{formatDate .Post.CreatedAt}} {{/* 10 Aug 2026 14:05 */}}
<div class="alert alert-{{alertClass .Flash.Type}}">{{.Flash.Message}}</div>
Analog @format($post->created_at) / helper kustom Blade. alertClass dipakai untuk memetakan tipe flash "error" ke class Bootstrap alert-danger (Bootstrap tidak punya alert-error).
Render di Controller
Blade
return view('users.index', ['users' => $users]);
Fiber
return helpers.Render(c, "app", "views/users/index.html", fiber.Map{
"Title": "Daftar User",
"Users": users,
})
helpers.Render otomatis menginjeksi data umum:
data["CSRFToken"] = GetCSRFToken(sess)
data["AuthUser"] = c.Locals("authUser")
data["Flash"] = GetFlash(sess)
data["AppName"] = config.App.AppName
Jadi view tidak perlu khawatir soal token CSRF, user login, atau flash message — semuanya sudah tersedia.
Keamanan
html/template melakukan auto-escaping — nilai variabel yang dirender di HTML otomatis di-escape (mencegah XSS). Analog Blade yang juga meng-escape {{ $var }}.
Untuk konten yang sengaja berisi HTML (misal isi artikel), di Blade pakai {!! $post->content !!}. Di proyek ini isi post ditampilkan sebagai teks biasa (aman). Kalau butuh HTML mentah, siapkan template.HTML — tapi hati-hati, itu menonaktifkan escaping.
Bab 06
Relasi & Preload
belongsTo dan hasMany di GORM, eager loading Preload, dan foreign key di MySQL.
Proyek ini punya 3 tabel berelasi: users, categories, posts. Relasinya sama seperti di Laravel, dinyatakan dengan field struct + tag GORM.
Skema Relasi
| Relasi | Model | Ditandai oleh |
|---|---|---|
User hasMany Post |
User punya []Post |
field Posts []Post |
Category hasMany Post |
Category punya []Post |
field Posts []Post |
Post belongsTo User |
Post punya User |
field User User + UserID |
Post belongsTo Category |
Post punya Category |
field Category Category + CategoryID |
BelongsTo vs HasMany
Kunci penting: belongsTo = FK ada di tabel ini, hasMany = FK ada di tabel lain.
users ────────< posts >────────── categories
(id) (user_id) (category_id)
FK milik postsPost belongsTo User→ fieldUserIDada di tabelposts.User hasMany Post→ tidak adaUserIDdi tabelusers; FK ada diposts.
Di model models/post.go:
type Post struct {
ID uint `gorm:"primaryKey"`
Title string `gorm:"size:200;not null"`
Slug string `gorm:"size:220;uniqueIndex;not null"`
Content string `gorm:"type:text;not null"`
UserID uint `gorm:"not null;index"`
User User `gorm:"foreignKey:UserID"`
CategoryID uint `gorm:"not null;index"`
Category Category `gorm:"foreignKey:CategoryID"`
CreatedAt time.Time
UpdatedAt time.Time
}
gorm:"foreignKey:UserID" memberitahu GORM bahwa relasi Post.User dihubungkan lewat kolom user_id. Ini analog:
class Post extends Model {
public function user() { return $this->belongsTo(User::class); }
public function category() { return $this->belongsTo(Category::class); }
}
Di model models/user.go:
type User struct {
...
Posts []Post `gorm:"foreignKey:UserID"`
...
}
Analog:
class User extends Model {
public function posts() { return $this->hasMany(Post::class); }
}
Eager Loading: Preload vs with()
Tanpa eager loading, query relasi dilakukan per-baris (masalah N+1). Laravel memakai with(), GORM memakai Preload().
Laravel:
$posts = Post::with('user', 'category')->get();
Fiber (controllers/post_controller.go):
var posts []models.Post
config.DB.Preload("User").Preload("Category").Order("created_at desc").Find(&posts)
Setelah Preload, field post.User dan post.Category sudah terisi — view bisa langsung mengaksesnya tanpa query tambahan.
Contoh lain di dashboard (controllers/auth_controller.go):
var recentPosts []models.Post
config.DB.Preload("User").Preload("Category").Order("created_at desc").Limit(5).Find(&recentPosts)
Akses Relasi di View
Blade
<td>{{ $post->user->name }}</td>
<td>{{ $post->category->name }}</td>
Fiber
<td>{{.Post.User.Name}}</td>
<td>{{.Post.Category.Name}}</td>
Di views/posts/index.html, langsung loop posts dan akses relasinya:
{{range .Posts}}
<tr>
<td>{{.ID}}</td>
<td><a href="/posts/{{.ID}}">{{.Title}}</a></td>
<td>{{.Category.Name}}</td>
<td>{{.User.Name}}</td>
...
</tr>
{{end}}
Tanpa Preload, .Category.Name akan kosong (nil struct). Jadi jangan lupa preload di controller.
Foreign Key di MySQL
AutoMigrate membuat constraint FK asli di MySQL. Cek dengan:
SHOW CREATE TABLE posts;
Hasilnya (potongan):
CONSTRAINT `fk_users_posts` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`),
CONSTRAINT `fk_categories_posts` FOREIGN KEY (`category_id`) REFERENCES `categories` (`id`)
Dampaknya: database menolak post dengan category_id yang tidak ada. Tapi aplikasi sudah mengecek lebih dulu di controller (validasi) dan mencegah hapus user/kategori yang masih punya post:
// controllers/category_controller.go
var postCount int64
config.DB.Model(&models.Post{}).Where("category_id = ?", id).Count(&postCount)
if postCount > 0 {
helpers.SetFlash(sess, "error", "Kategori masih dipakai post, hapus post-nya dulu.")
return c.Redirect("/categories")
}
Ini analog aturan bisnis "jangan hapus induk kalau masih punya anak" yang di Laravel biasanya ditangani di destroy() method atau event.
Ringkasan Relasi
| Laravel | GORM (proyek ini) |
|---|---|
belongsTo |
field struct tipe model + foreignKey tag |
hasMany |
field slice []Model + foreignKey tag |
with('user', 'category') |
.Preload("User").Preload("Category") |
$post->user->name |
{{.Post.User.Name}} (view) |
migration foreignId() |
AutoMigrate + tag foreignKey |
| N+1 problem | solved oleh Preload |
Bab 07
CSRF & Session
Token per sesi, middleware verifikasi, regenerasi setelah login, halaman 419.
Proyek ini mengimplementasikan proteksi CSRF dan session dengan pola yang sama dengan Laravel.
Kenapa Perlu CSRF?
CSRF (Cross-Site Request Forgery): penyerang menipu browser korban untuk mengirim request (misal POST) ke aplikasi tanpa sepengetahuan korban. Kalau aplikasi hanya mengandalkan session cookie, request "bohong" ini dianggap valid.
Solusinya: setiap form menyimpan token rahasia yang unik per sesi. Saat POST, server membandingkan token dari form dengan token di session. Tidak cocok → tolak.
@csrf vs Hidden Input
Laravel Blade
<form method="POST">
@csrf
<input name="title">
</form>
@csrf menghasilkan <input type="hidden" name="_token" value="...">.
Fiber (proyek ini)
<form method="post" action="/posts">
<input type="hidden" name="_token" value="{{.CSRFToken}}">
...
</form>
Nilai .CSRFToken diinjeksi otomatis oleh helpers.Render:
data["CSRFToken"] = GetCSRFToken(sess)
Jadi controller tidak perlu memasukkan token secara manual — setiap view punya _token di form.
Middleware CSRF
middlewares/csrf.go:
func CSRF(c *fiber.Ctx) error {
if c.Method() == fiber.MethodGet ||
c.Method() == fiber.MethodHead ||
c.Method() == fiber.MethodOptions {
return c.Next()
}
sess, err := helpers.GetSession(c)
if err != nil {
return err
}
token := c.FormValue("_token")
if token == "" {
token = c.Get("X-CSRF-TOKEN")
}
if !helpers.ValidateCSRF(sess, token) {
c.Status(419)
t, err := template.ParseFiles("views/errors/419.html")
if err != nil {
return err
}
return t.Execute(c, nil)
}
helpers.RegenerateCSRF(sess)
return c.Next()
}
| Langkah | Laravel | Fiber (proyek ini) |
|---|---|---|
| Hanya cek method berbahaya | shouldValidate (POST/PUT/...) |
GET/HEAD/OPTIONS dilewati |
| Baca token | dari request | c.FormValue("_token") |
| Bandingkan dengan session | VerifyCsrfToken |
helpers.ValidateCSRF |
| Gagal | 419 Page Expired |
render views/errors/419.html status 419 |
| Sukses | lanjut | c.Next() |
Middleware ini dipasang global:
app.Use(middlewares.CSRF)
Analog VerifyCsrfToken yang otomatis aktif untuk semua route di Laravel.
Helper CSRF
helpers/csrf.go:
func GetCSRFToken(sess *session.Session) string {
if tok, ok := sess.Get(csrfSessionKey).(string); ok && tok != "" {
return tok
}
return GenerateCSRFToken(sess)
}
func GenerateCSRFToken(sess *session.Session) string {
b := make([]byte, 32)
_, _ = rand.Read(b)
tok := hex.EncodeToString(b)
sess.Set(csrfSessionKey, tok)
_ = sess.Save()
return tok
}
func ValidateCSRF(sess *session.Session, token string) bool {
stored := GetCSRFToken(sess)
if stored == "" || token == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(stored), []byte(token)) == 1
}
Poin penting:
- Token disimpan di session (bukan cookie) — sama seperti Laravel.
- 32 byte acak (
crypto/rand) — analogStr::randomLaravel. ConstantTimeCompare— membandingkan token dengan durasi konstan, mencegah timing attack. Ini alasan kenapa tidak pakai==biasa.- Jika session tidak punya token, otomatis dibuat (
GetCSRFToken→GenerateCSRFToken).
Regenerate Token (Session Rotation)
Setelah login berhasil, Laravel me-regenerate session untuk mencegah session fixation.
Di controllers/auth_controller.go:
func Login(c *fiber.Ctx) error {
...
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil {
helpers.SetFlash(sess, "error", "Kredensial tidak cocok.")
return c.Redirect("/login")
}
helpers.RegenerateCSRF(sess) // <-- token baru setelah sukses login
sess.Set("user_id", user.ID)
_ = sess.Save()
...
}
Dan middleware CSRF me-regenerate token setiap POST sukses (helpers.RegenerateCSRF(sess)) — analog perilaku Laravel yang mengganti session ID setelah aktivitas penting.
Session: Fiber vs Laravel
| Aspek | Laravel | Fiber (proyek ini) |
|---|---|---|
| Driver | file/DB/redis | middleware/session (in-memory) |
| Cookie berisi | hanya session ID | hanya session ID (belajar_fiber_session) |
| Data | tersimpan server-side | tersimpan server-side |
| Baca session | $request->session() |
helpers.GetSession(c) |
| Set data | $request->session()->put('user_id', $id) |
sess.Set("user_id", user.ID) |
| Hapus data | ->forget('user_id') |
sess.Delete("user_id") |
config/session.go:
func InitSessionStore() {
Store = session.New(session.Config{
KeyLookup: "cookie:belajar_fiber_session",
Expiration: 24 * time.Hour,
})
}
Data session (termasuk user_id, csrf_token, flash) tersimpan di memori server, cookie hanya berisi ID — persis konsep session Laravel.
Halaman 419
views/errors/419.html — halaman khusus saat token tidak valid, analog 419 Page Expired Laravel:
<h1 class="display-1 fw-bold text-muted">419</h1>
<h2 class="h4">Page Expired</h2>
<p class="text-muted">Token CSRF tidak valid atau sesi kedaluwarsa. Silakan kembali dan coba lagi.</p>
Alur Lengkap CSRF
1. User buka halaman form (GET)
→ helpers.Render memanggil GetCSRFToken(sess)
→ token dibuat (jika belum ada) & disimpan di session
→ token dirender sebagai hidden input
2. User submit form (POST)
→ middleware CSRF membaca _token dari body
→ ValidateCSRF membandingkan dengan session
→ cocok: regenerate token, lanjut ke controller
→ tidak cocok: render 419
3. Setelah sukses login
→ RegenerateCSRF menghasilkan token baru (anti session fixation)Bab 08
Alur MVC End-to-End
Telusuri satu request dari route, middleware, controller, model, sampai HTML.
Dokumen ini menjelaskan alur request lengkap dalam arsitektur MVC ala Laravel yang dipakai proyek ini. Pahami ini dulu sebelum masuk detail per-komponen.
Gambaran Besar
┌────────────────────────────────────────────────┐
│ main.go │
│ config.Load() → InitDB() → routes.Register() │
└────────────────────────────────────────────────┘
│
┌────────────────────▼───────────────────────────┐
│ ROUTES (routes/web.go) │
│ URL + Method → Controller (analog web.php) │
└────────────────────┬───────────────────────────┘
│ semua request lewat
┌────────────────────▼───────────────────────────┐
│ MIDDLEWARES (global) │
│ MethodOverride → CSRF │
└────────────────────┬───────────────────────────┘
│
┌────────────────────▼───────────────────────────┐
│ MIDDLEWARE Auth (route group) │
│ cek session user_id → c.Locals("authUser") │
└────────────────────┬───────────────────────────┘
│
┌──────────────────────────▼──────────────────────────┐
│ CONTROLLER │
│ validasi input → query model → render view / flash │
└───────┬───────────────────────────────┬──────────────┘
│ │
┌───────▼────────┐ ┌─────────▼─────────┐
│ MODEL (GORM) │ │ VIEW (template) │
│ MySQL queries │ │ layout + content │
└────────────────┘ └───────────────────┘
│
┌───────────────────────────────▼────────────────┐
│ RESPONSE ke Browser │
│ HTML rendered / redirect / flash message │
└────────────────────────────────────────────────┘Alur 1: Login
GET /login — tampilkan form
Browser → routes/web.go (app.Get("/login", controllers.ShowLogin))
→ middleware MethodOverride (method GET → dilewati)
→ middleware CSRF (GET → dilewati)
→ ShowLogin controller
→ helpers.Render(c, "auth", "views/auth/login.html", ...)
→ GetSession → GetCSRFToken (buat token jika belum ada)
→ ParseFiles(layouts/auth.html, views/auth/login.html)
→ data: CSRF token diinjeksi → HTML dikirimHasilnya: halaman login berisi <input type="hidden" name="_token" value="...">.
POST /login — proses login
Browser (submit form + _token) → routes/web.go (app.Post("/login", controllers.Login))
→ middleware MethodOverride (POST + _method? tidak ada → tetap POST)
→ middleware CSRF
→ baca _token dari form
→ ValidateCSRF vs session
→ cocok → RegenerateCSRF → lanjut
→ Login controller
1. c.FormValue("email"), c.FormValue("password")
2. cari user: config.DB.Where("email = ?").First(&user)
3. bcrypt.CompareHashAndPassword(user.Password, password)
4. sukses → RegenerateCSRF(sess) → sess.Set("user_id", user.ID)
5. helpers.SetFlash(sess, "success", ...) → c.Redirect("/dashboard")Browser diarahkan ke /dashboard dengan cookie session (belajar_fiber_session) yang berisi user_id.
Alur 2: CRUD Post
GET /posts — daftar post
Browser → routes/web.go (auth.Get("/posts", controllers.PostIndex))
→ middleware MethodOverride → dilewati (GET)
→ middleware CSRF → dilewati (GET)
→ middleware Auth
1. sess.Get("user_id") → tidak ada? → redirect /login (selesai)
2. ada → config.DB.First(&user, uid) → c.Locals("authUser", &user)
→ PostIndex controller
config.DB.Preload("User").Preload("Category")
.Order("created_at desc").Find(&posts)
→ helpers.Render(c, "app", "views/posts/index.html",
{"Title", "Posts"})
→ layout app.html: navbar + nama user (.AuthUser.Name)
→ content: loop posts, tampilkan {{.Category.Name}}, {{.User.Name}}Relasi Category dan User sudah terisi karena Preload — tanpa itu .Category.Name kosong.
POST /posts — simpan post baru
Browser (form + _token + category_id + user_id)
→ MethodOverride (tetap POST)
→ CSRF verify → RegenerateCSRF → lanjut
→ Auth (sudah login) → lanjut
→ PostStore controller
1. baca title, content, category_id, user_id
2. validasi wajib isi → gagal? flash error, redirect /posts/create
3. cek kategori & user ada (Count > 0)
4. slug = helpers.Slugify(title); jika duplikat → tambah timestamp
5. config.DB.Create(&post)
6. helpers.SetFlash(sess, "success", ...) → redirect /postsPUT /posts/:id — edit (method spoofing)
HTML form hanya mendukung GET dan POST. Form edit karena itu mengirim POST plus satu field tersembunyi:
<form method="post" action="/posts/5">
<input type="hidden" name="_method" value="PUT">
<input type="hidden" name="_token" value="{{.CSRFToken}}">
Alurnya:
Browser (POST + _method=PUT)
→ middleware MethodOverride
baca _method → c.Method("PUT")
→ router mencocokkan route dengan method PUT
→ middleware CSRF → verifikasi _token → RegenerateCSRF
→ middleware Auth → c.Locals("authUser")
→ PostUpdate controller
1. ambil post by id, tidak ada? flash error + redirect /posts
2. baca title, content, category_id, user_id
3. slug dibuat ulang, bentrok? tambah timestamp
4. config.DB.Save(&post)
5. flash success → redirect /posts/5Karena MethodOverride menulis ulang method request sebelum router mencocokkan route, route update wajib didaftarkan sebagai auth.Put. Kalau yang terdaftar hanya auth.Post("/posts/:id", ...) sementara method sudah berubah jadi PUT, tidak ada route yang cocok dan Fiber mengembalikan 404.
Karena itu route resource di proyek ini ditulis begini:
auth.Get ("/posts", controllers.PostIndex) // GET → index
auth.Get ("/posts/create", controllers.PostCreate) // GET → create
auth.Post ("/posts", controllers.PostStore) // POST → store
auth.Get ("/posts/:id", controllers.PostShow) // GET → show
auth.Get ("/posts/:id/edit", controllers.PostEdit) // GET → edit
auth.Put ("/posts/:id", controllers.PostUpdate) // PUT → update
auth.Delete("/posts/:id", controllers.PostDelete) // DELETE → destroy
Form tetap memakai _method supaya penulisannya sama dengan @method('PUT') di Blade. Yang berbeda hanya sisi route: Laravel mencocokkan Route::put, Fiber mencocokkan app.Put.
Alur 3: Akses Tanpa Login
Browser → GET /users (tanpa session)
→ MethodOverride, CSRF (GET, dilewati)
→ Auth middleware
sess.Get("user_id") → tidak ada
→ helpers.SetFlash(sess, "error", "Silakan login terlebih dahulu.")
→ c.Redirect("/login")
→ Browser diarahkan ke /login, flash tampil di layout authPeran Setiap Lapisan (Ringkasan)
| Lapisan | Tanggung jawab | Analog Laravel |
|---|---|---|
main.go |
bootstrap: config, DB, session, route | bootstrap/app.php + Kernel |
routes/web.go |
mapping URL → controller + middleware | routes/web.php |
| Middleware CSRF | proteksi form dari CSRF | VerifyCsrfToken |
| Middleware Auth | cek login, inject authUser |
middleware('auth') |
| Controller | terima request, validasi, koordinasi model & view | app/Http/Controllers |
| Model (GORM) | akses & manipulasi DB | Eloquent Model |
| View (template) | render HTML, layout, data | Blade |
Checklist Belajar
Setelah baca dokumen ini, coba telusuri sendiri:
- Buka
routes/web.go— temukan route untuk/posts/:id/edit. - Buka
controllers/post_controller.go— temukanPostEdit. - Buka
views/posts/edit.html— lihat_methoddan_token. - Cek
middlewares/csrf.go— apa yang terjadi jika_tokentidak ada? - Cek
middlewares/auth.go— apa bedanya route public vs route groupauth? - Telusuri alur "hapus post" dari tombol di
views/posts/index.htmlsampaiPostDelete— middleware apa saja yang dilewati?
Praktik 1
Fondasi Aplikasi
main.go, config, dan .env sampai server jalan dan terhubung ke MySQL.
Target langkah ini: go run . berhasil, aplikasi mendengarkan di port 3000, dan koneksi MySQL terbentuk. Belum ada halaman apa pun, tapi kerangkanya sudah berdiri.
File yang ditulis di langkah ini:
.envdan.env.exampleconfig/config.go,config/database.go,config/session.gomain.goroutes/web.goversi sementara
Kalau go.mod belum ada, kerjakan dulu Instalasi dari Nol sampai bagian install dependency.
Berkas Environment
APP_NAME=BelajarFiber
APP_PORT=3000
SESSION_KEY=ganti-dengan-kunci-rahasia-acak-panjang
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=root
DB_PASSWORD=
DB_NAME=belajar_fiber
Salin jadi .env, lalu isi SESSION_KEY dengan string acak. Yang di-commit ke git hanya .env.example, persis kebiasaan Laravel.
cp .env.example .env
openssl rand -base64 32
config/config.go
package config
import (
"os"
"github.com/joho/godotenv"
)
type Config struct {
AppName string
AppPort string
SessionKey string
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
}
var App *Config
func Load() error {
_ = godotenv.Load()
App = &Config{
AppName: getEnv("APP_NAME", "BelajarFiber"),
AppPort: getEnv("APP_PORT", "3000"),
SessionKey: getEnv("SESSION_KEY", ""),
DBHost: getEnv("DB_HOST", "127.0.0.1"),
DBPort: getEnv("DB_PORT", "3306"),
DBUser: getEnv("DB_USER", "root"),
DBPassword: getEnv("DB_PASSWORD", ""),
DBName: getEnv("DB_NAME", "belajar_fiber"),
}
return nil
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
Poin pentingnya:
godotenv.Load()membaca file.envdan memasukkannya ke environment proses. Errornya sengaja diabaikan karena di server produksi env biasanya diisi lewat systemd atau Docker, bukan lewat file.Appadalah variabel global berisi seluruh konfigurasi, jadi paket lain cukup memanggilconfig.App.DBHost. Perannya sama seperti helperconfig('database.connections.mysql.host')di Laravel.getEnvmenyediakan nilai cadangan, analogenv('APP_PORT', 3000).
config/database.go
package config
import (
"fmt"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"belajar-fiber/models"
)
var DB *gorm.DB
func InitDB() error {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
App.DBUser, App.DBPassword, App.DBHost, App.DBPort, App.DBName)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return err
}
DB = db
return DB.AutoMigrate(&models.User{}, &models.Category{}, &models.Post{})
}
- DSN adalah string koneksi MySQL.
parseTime=Truewajib ada supaya kolomDATETIMEbisa masuk ke field bertipetime.Time. DBjuga global, sehingga controller memanggilconfig.DB.Find(...)tanpa perlu dependency injection.AutoMigratedijalankan setiap aplikasi start. Tabel yang belum ada akan dibuat, kolom baru ditambahkan, tapi kolom lama tidak pernah dihapus.
File ini mengimpor paket
modelsyang baru dibuat di langkah berikutnya, jadigo buildmasih gagal untuk sementara. Kalau ingin langsung mencoba jalan, ganti dulu baris terakhir menjadireturn nildan hapus importmodels, lalu kembalikan setelah model selesai.
config/session.go
package config
import (
"time"
"github.com/gofiber/fiber/v2/middleware/session"
)
var Store *session.Store
func InitSessionStore() {
Store = session.New(session.Config{
KeyLookup: "cookie:belajar_fiber_session",
Expiration: 24 * time.Hour,
})
}
KeyLookupmenentukan nama cookie yang dipakai browser, yaitubelajar_fiber_session. Isinya hanya ID sesi, datanya tetap di server.Expiration24 jam setara'lifetime' => 1440diconfig/session.php.Storeglobal dipakai olehhelpers.GetSessionpada langkah berikutnya.
main.go
package main
import (
"log"
"github.com/gofiber/fiber/v2"
"belajar-fiber/config"
"belajar-fiber/routes"
)
func main() {
if err := config.Load(); err != nil {
log.Fatal("gagal memuat konfigurasi:", err)
}
config.InitSessionStore()
if err := config.InitDB(); err != nil {
log.Fatal("gagal koneksi database:", err)
}
app := fiber.New(fiber.Config{
AppName: config.App.AppName,
})
routes.Register(app)
log.Fatal(app.Listen(":" + config.App.AppPort))
}
Urutan pemanggilan di main menentukan segalanya:
config.Load()dulu, karenaInitDBbutuh kredensial dari.env.config.InitSessionStore()menyiapkan penyimpanan sesi.config.InitDB()konek ke MySQL sekaligus menjalankan migrasi.fiber.Newmembuat instance aplikasi, laluroutes.Register(app)mendaftarkan seluruh route.app.Listenmenjalankan server.
log.Fatal mencetak error lalu menghentikan proses. Lebih baik aplikasi mati saat start daripada jalan tanpa database.
Route Sementara
main.go mengimpor paket routes, jadi paket itu harus ada supaya bisa dikompilasi. Untuk sekarang isi seadanya dulu:
package routes
import "github.com/gofiber/fiber/v2"
func Register(app *fiber.App) {
app.Get("/", func(c *fiber.Ctx) error {
return c.SendString("Fiber jalan")
})
}
Isi asli file ini ditulis di Praktik 8 setelah semua controller siap.
Uji Langkah Ini
go mod tidy
go build ./...
go run .
Buka http://localhost:3000, harusnya muncul tulisan Fiber jalan. Cek juga database sudah terbentuk:
mysql -h 127.0.0.1 -P 3306 -u root -e "SHOW DATABASES LIKE 'belajar_fiber';"
Kalau muncul Error 1049: Unknown database, berarti databasenya belum dibuat. Ulangi perintah CREATE DATABASE di bab instalasi.
Praktik 2
Model & Migrasi
Tiga struct model, tag gorm, relasi, dan tabel yang terbentuk otomatis.
Target langkah ini: tiga tabel users, categories, dan posts terbentuk otomatis di MySQL lengkap dengan foreign key. Tidak ada file migration terpisah, struktur tabel diambil langsung dari struct.
Teorinya ada di Model GORM dan Relasi & Preload. Di sini fokus ke kodenya.
models/user.go
package models
import "time"
type User struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:100;not null"`
Email string `gorm:"size:150;uniqueIndex;not null"`
Password string `gorm:"not null"`
Posts []Post `gorm:"foreignKey:UserID"`
CreatedAt time.Time
UpdatedAt time.Time
}
EmaildiberiuniqueIndexsupaya database sendiri yang menolak email ganda, bukan hanya validasi di controller.Passwordmenyimpan hash bcrypt, bukan password asli, jadi panjangnya dibiarkan default (VARCHAR 255).Posts []Postmenyatakan relasi hasMany. Kolompoststidak ikut dibuat di tabelusers, karena foreign key ada di tabelposts.CreatedAtdanUpdatedAtdiisi GORM otomatis, sama seperti timestamps Eloquent.
models/category.go
package models
import "time"
type Category struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:100;not null"`
Slug string `gorm:"size:120;uniqueIndex;not null"`
Posts []Post `gorm:"foreignKey:CategoryID"`
CreatedAt time.Time
UpdatedAt time.Time
}
Slug juga unik, dipakai untuk URL yang ramah dibaca. Nilainya dibuat controller lewat helpers.Slugify.
models/post.go
package models
import "time"
type Post struct {
ID uint `gorm:"primaryKey"`
Title string `gorm:"size:200;not null"`
Slug string `gorm:"size:220;uniqueIndex;not null"`
Content string `gorm:"type:text;not null"`
UserID uint `gorm:"not null;index"`
User User `gorm:"foreignKey:UserID"`
CategoryID uint `gorm:"not null;index"`
Category Category `gorm:"foreignKey:CategoryID"`
CreatedAt time.Time
UpdatedAt time.Time
}
Pola belongsTo di GORM selalu terdiri dari dua field:
| Field | Fungsi |
|---|---|
UserID uint |
kolom asli user_id di tabel posts |
User User |
tempat GORM menaruh data relasi setelah Preload("User") |
Tanpa UserID, GORM tidak tahu kolom mana yang jadi foreign key. Tanpa User, tidak ada tempat menampung hasil preload dan view tidak bisa memanggil .User.Name.
index pada UserID dan CategoryID mempercepat query filter per penulis atau per kategori.
Menyambungkan ke AutoMigrate
Model tidak akan jadi tabel sebelum didaftarkan. Baris ini sudah ada di config/database.go dari langkah sebelumnya:
return DB.AutoMigrate(&models.User{}, &models.Category{}, &models.Post{})
Urutan argumen penting saat tabel pertama kali dibuat. User dan Category harus lebih dulu daripada Post, karena Post merujuk keduanya lewat foreign key.
Uji Langkah Ini
Kembalikan isi config/database.go seperti aslinya kalau tadi sempat diubah jadi return nil, lalu jalankan:
go build ./...
go run .
Cek hasilnya di MySQL:
mysql -h 127.0.0.1 -P 3306 -u root belajar_fiber -e "SHOW TABLES; DESCRIBE posts;"
Foreign key ikut terbentuk. Buktikan dengan:
SHOW CREATE TABLE posts;
Bagian CONSTRAINT fk_users_posts FOREIGN KEY (user_id) REFERENCES users (id) menandakan relasi sudah tertanam di level database.
Praktik 3
Helper
Session, flash, CSRF, slug, dan Render yang menyuntik data umum ke view.
Target langkah ini: lima file kecil di folder helpers/ yang jadi tulang punggung seluruh aplikasi. Semua controller nanti bergantung pada file-file ini.
Laravel menyediakan semua ini secara bawaan lewat session(), with(), @csrf, dan view(). Di Fiber kita menulisnya sendiri, dan justru di sinilah terlihat apa yang sebenarnya dikerjakan Laravel di balik layar.
helpers/session.go
package helpers
import (
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/session"
"belajar-fiber/config"
)
func GetSession(c *fiber.Ctx) (*session.Session, error) {
return config.Store.Get(c)
}
func SaveSession(sess *session.Session) {
_ = sess.Save()
}
Dua fungsi tipis pembungkus config.Store. Manfaatnya: kalau suatu saat penyimpanan sesi diganti ke Redis, hanya file ini yang berubah.
SaveSession perlu dipanggil manual setiap kali ada perubahan data sesi. Ini beda dengan Laravel yang menyimpan sesi otomatis di akhir request. Kalau lupa memanggilnya, flash message dan token CSRF tidak akan tersimpan.
helpers/flash.go
package helpers
import "github.com/gofiber/fiber/v2/middleware/session"
type Flash struct {
Type string
Message string
}
func SetFlash(sess *session.Session, flashType, message string) {
sess.Set("flash_type", flashType)
sess.Set("flash_message", message)
}
func GetFlash(sess *session.Session) *Flash {
flashType, _ := sess.Get("flash_type").(string)
message, _ := sess.Get("flash_message").(string)
if flashType == "" && message == "" {
return nil
}
sess.Delete("flash_type")
sess.Delete("flash_message")
return &Flash{Type: flashType, Message: message}
}
Cara kerjanya sama dengan redirect()->with('success', ...) di Laravel:
SetFlashmenaruh tipe dan pesan ke sesi sebelum redirect.- Halaman tujuan memanggil
GetFlash, yang membaca nilainya lalu langsung menghapusnya. - Karena dihapus saat dibaca, pesan hanya tampil sekali. Refresh halaman berikutnya sudah bersih.
GetFlash dipanggil di satu tempat saja, yaitu helpers.Render, jadi controller tidak perlu mengurusnya.
helpers/csrf.go
package helpers
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"github.com/gofiber/fiber/v2/middleware/session"
)
const csrfSessionKey = "csrf_token"
func GetCSRFToken(sess *session.Session) string {
if tok, ok := sess.Get(csrfSessionKey).(string); ok && tok != "" {
return tok
}
return GenerateCSRFToken(sess)
}
func GenerateCSRFToken(sess *session.Session) string {
b := make([]byte, 32)
_, _ = rand.Read(b)
tok := hex.EncodeToString(b)
sess.Set(csrfSessionKey, tok)
return tok
}
func ValidateCSRF(sess *session.Session, token string) bool {
stored := GetCSRFToken(sess)
if stored == "" || token == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(stored), []byte(token)) == 1
}
func RegenerateCSRF(sess *session.Session) {
sess.Delete(csrfSessionKey)
GenerateCSRFToken(sess)
}
GetCSRFTokenmengambil token dari sesi, dan membuatkan token baru kalau belum ada. Fungsi inilah yang dipanggilRendersupaya setiap form otomatis punya_token.GenerateCSRFTokenmemakaicrypto/rand, bukanmath/rand. Untuk urusan keamanan, generator acak biasa terlalu mudah ditebak.ValidateCSRFmemakaisubtle.ConstantTimeCompare. Perbandingan==biasa berhenti di karakter pertama yang berbeda, sehingga waktu eksekusinya bisa membocorkan seberapa banyak karakter awal yang sudah benar.RegenerateCSRFmenghapus token lama lalu membuat yang baru, dipakai setelah login, logout, dan setiap POST yang lolos verifikasi.
Perhatikan bahwa fungsi di file ini tidak memanggil sess.Save(). Penyimpanan diserahkan ke pemanggil lewat helpers.SaveSession, supaya satu request tidak menyimpan sesi berkali-kali.
helpers/slug.go
package helpers
import (
"regexp"
"strings"
)
var nonSlugChars = regexp.MustCompile(`[^a-z0-9]+`)
func Slugify(s string) string {
s = strings.ToLower(s)
s = nonSlugChars.ReplaceAllString(s, "-")
return strings.Trim(s, "-")
}
Padanan Str::slug() di Laravel. Semua karakter selain huruf kecil dan angka diubah jadi tanda hubung, lalu tanda hubung di ujung dipangkas. Judul Belajar Go Fiber! menjadi belajar-go-fiber.
Regex dikompilasi sekali di level paket lewat regexp.MustCompile, bukan di dalam fungsi. Mengompilasi ulang regex di setiap pemanggilan itu pemborosan.
helpers/view.go
package helpers
import (
"html/template"
"time"
"github.com/gofiber/fiber/v2"
"belajar-fiber/config"
)
var funcMap = template.FuncMap{
"formatDate": func(t time.Time) string {
return t.Format("02 Jan 2006 15:04")
},
"truncate": func(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "..."
},
"alertClass": func(flashType string) string {
switch flashType {
case "error":
return "danger"
case "success":
return "success"
case "warning":
return "warning"
case "info":
return "info"
default:
return "primary"
}
},
}
var layouts = map[string]string{
"app": "views/layouts/app.html",
"auth": "views/layouts/auth.html",
}
func Render(c *fiber.Ctx, layout, viewPath string, data fiber.Map) error {
sess, err := GetSession(c)
if err != nil {
return err
}
if data == nil {
data = fiber.Map{}
}
data["CSRFToken"] = GetCSRFToken(sess)
data["AuthUser"] = c.Locals("authUser")
data["Flash"] = GetFlash(sess)
data["AppName"] = config.App.AppName
SaveSession(sess)
t, err := template.New(viewPath).Funcs(funcMap).ParseFiles(layouts[layout], viewPath)
if err != nil {
return err
}
c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
return t.ExecuteTemplate(c, layout, data)
}
File terpenting di folder ini. Tiga bagiannya:
funcMap berisi fungsi yang bisa dipanggil dari dalam template, padanan directive kustom Blade. alertClass menerjemahkan tipe flash ke nama class Bootstrap, karena Bootstrap tidak mengenal alert-error melainkan alert-danger.
layouts memetakan nama pendek ke path file layout, sehingga controller cukup menulis "app" atau "auth".
Render menggabungkan semuanya. Empat data ini disuntikkan ke setiap view tanpa diminta:
| Kunci | Isi | Padanan Laravel |
|---|---|---|
CSRFToken |
token dari sesi | @csrf |
AuthUser |
user yang login | Auth::user() |
Flash |
pesan sekali tampil | session('success') |
AppName |
nama aplikasi dari .env |
config('app.name') |
Karena itu controller cukup mengirim data yang khas halaman itu saja. ParseFiles(layouts[layout], viewPath) menggabungkan layout dan halaman jadi satu set template, lalu ExecuteTemplate mengeksekusi mulai dari nama layout.
Template diparsing ulang setiap request. Cara ini memudahkan saat belajar karena perubahan file view langsung terlihat tanpa restart. Untuk produksi, template sebaiknya diparsing sekali saat startup dan disimpan di variabel.
Uji Langkah Ini
go build ./...
Belum ada yang bisa dilihat di browser karena view dan controller belum ada. Yang penting kompilasi bersih tanpa error.
Praktik 4
Layout & Halaman Auth
Dua layout, halaman login dan registrasi, serta halaman error 419.
Target langkah ini: dua layout dan tiga halaman siap dirender. Setelah ini aplikasi sudah punya wajah, tinggal diisi controller.
Perbandingan lengkap dengan Blade ada di View & Template.
views/layouts/app.html
Layout untuk halaman yang butuh login. Berisi navbar, nama user, tombol logout, dan area flash message.
{{define "app"}}
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{if .Title}}{{.Title}} — {{end}}{{.AppName}}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<nav class="navbar navbar-expand-lg navbar-dark bg-primary mb-4">
<div class="container">
<a class="navbar-brand fw-bold" href="/dashboard">{{.AppName}}</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#nav" aria-controls="nav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="nav">
<ul class="navbar-nav me-auto">
<li class="nav-item"><a class="nav-link" href="/dashboard">Dashboard</a></li>
<li class="nav-item"><a class="nav-link" href="/users">Users</a></li>
<li class="nav-item"><a class="nav-link" href="/categories">Categories</a></li>
<li class="nav-item"><a class="nav-link" href="/posts">Posts</a></li>
</ul>
<div class="d-flex align-items-center gap-3">
<span class="navbar-text text-white">{{.AuthUser.Name}}</span>
<form method="post" action="/logout">
<input type="hidden" name="_token" value="{{.CSRFToken}}">
<button type="submit" class="btn btn-outline-light btn-sm">Logout</button>
</form>
</div>
</div>
</div>
</nav>
<main class="container">
{{if .Flash}}
<div class="alert alert-{{alertClass .Flash.Type}} alert-dismissible fade show" role="alert">
{{.Flash.Message}}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{{end}}
{{template "content" .}}
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
{{end}}
Yang perlu diperhatikan:
- Seluruh isi file dibungkus
{{define "app"}} ... {{end}}. Namaappinilah yang dipanggilhelpers.Render(c, "app", ...). {{template "content" .}}adalah lubang tempat isi halaman disisipkan, padanan@yield('content'). Titik di belakangnya meneruskan seluruh data ke template anak. Kalau titik itu lupa ditulis, halaman anak tidak menerima data apa pun.{{.AuthUser.Name}}bisa dipanggil karena middleware Auth menaruh user kec.LocalsdanRendermemindahkannya ke data view.- Form logout memakai POST dan menyertakan
_token. Logout lewat link GET berbahaya, karena bisa dipicu orang lain lewat gambar atau iframe. {{alertClass .Flash.Type}}memanggil fungsi darifuncMapdihelpers/view.go.
views/layouts/auth.html
Layout untuk halaman yang belum login. Tanpa navbar, isinya kartu di tengah layar.
{{define "auth"}}
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} — {{.AppName}}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<main class="container py-5">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="text-center mb-4">
<h1 class="fw-bold">{{.AppName}}</h1>
<p class="text-muted mb-0">Blog MVC ala Laravel</p>
</div>
{{if .Flash}}
<div class="alert alert-{{alertClass .Flash.Type}} alert-dismissible fade show" role="alert">
{{.Flash.Message}}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{{end}}
<div class="card shadow-sm">
<div class="card-body">
{{template "content" .}}
</div>
</div>
</div>
</div>
</main>
</body>
</html>
{{end}}
Struktur {{define "auth"}} sama persis, hanya isinya yang berbeda. Karena halaman login belum melewati middleware Auth, layout ini tidak boleh menyentuh .AuthUser yang masih kosong.
views/auth/login.html
{{define "content"}}
<h2 class="h5 mb-4">Login</h2>
<form method="post" action="/login">
<input type="hidden" name="_token" value="{{.CSRFToken}}">
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" name="email" id="email" class="form-control" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" name="password" id="password" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary w-100">Login</button>
</form>
<p class="mt-3 mb-0 text-center">Belum punya akun? <a href="/register">Daftar sekarang</a></p>
{{end}}
Halaman anak selalu dibungkus {{define "content"}}, nama yang sama dengan yang dipanggil layout. Semua halaman memakai nama content, jadi tidak perlu diingat satu per satu.
Baris <input type="hidden" name="_token" value="{{.CSRFToken}}"> adalah padanan @csrf. Tanpa baris ini, POST akan ditolak middleware CSRF dengan halaman 419.
views/auth/register.html
{{define "content"}}
<h2 class="h5 mb-4">Registrasi</h2>
<form method="post" action="/register">
<input type="hidden" name="_token" value="{{.CSRFToken}}">
<div class="mb-3">
<label for="name" class="form-label">Nama Lengkap</label>
<input type="text" name="name" id="name" class="form-control" required autofocus>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" name="email" id="email" class="form-control" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" name="password" id="password" class="form-control" required>
</div>
<div class="mb-3">
<label for="password_confirmation" class="form-label">Konfirmasi Password</label>
<input type="password" name="password_confirmation" id="password_confirmation" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary w-100">Daftar</button>
</form>
<p class="mt-3 mb-0 text-center">Sudah punya akun? <a href="/login">Login</a></p>
{{end}}
Field password_confirmation memakai nama yang sama dengan konvensi Laravel, dan dicocokkan manual di controller nanti.
views/errors/419.html
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>419 Page Expired</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light d-flex align-items-center" style="min-height:100vh;">
<div class="container text-center">
<h1 class="display-1 fw-bold text-muted">419</h1>
<h2 class="h4">Page Expired</h2>
<p class="text-muted">Token CSRF tidak valid atau sesi kedaluwarsa. Silakan kembali dan coba lagi.</p>
<a href="/" class="btn btn-primary">Kembali ke Beranda</a>
</div>
</body>
</html>
Halaman ini berdiri sendiri tanpa {{define}}, karena dirender langsung oleh middleware CSRF dengan template.ParseFiles dan t.Execute, bukan lewat helpers.Render. Alasannya sederhana: saat token tidak valid, sesi memang sedang bermasalah, jadi jangan lagi bergantung pada data dari sesi.
Uji Langkah Ini
go build ./...
Halaman belum bisa dibuka karena belum ada controller yang merendernya. Yang bisa dicek sekarang hanya penulisan tag template. Kesalahan seperti {{define "content"}} yang lupa ditutup {{end}} baru ketahuan saat halaman dirender di Praktik 6.
Praktik 5
Tiga Middleware
MethodOverride, CSRF, dan Auth beserta urutan pemasangannya.
Target langkah ini: tiga lapis penyaring request. Dua dipasang global untuk semua route, satu dipasang khusus untuk route yang butuh login.
| File | Dipasang | Tugas |
|---|---|---|
middlewares/methodoverride.go |
global | ubah POST jadi PUT atau DELETE sesuai field _method |
middlewares/csrf.go |
global | tolak request tanpa token yang sah |
middlewares/auth.go |
grup auth |
pastikan user sudah login |
middlewares/methodoverride.go
package middlewares
import "github.com/gofiber/fiber/v2"
func MethodOverride(c *fiber.Ctx) error {
if c.Method() != fiber.MethodPost {
return c.Next()
}
method := c.FormValue("_method")
switch method {
case fiber.MethodPut, fiber.MethodDelete, fiber.MethodPatch:
c.Method(method)
}
return c.Next()
}
HTML form hanya mengenal GET dan POST. Middleware ini membaca field tersembunyi _method lalu menulis ulang method request, persis yang dilakukan @method('PUT') di Blade.
c.Method(method) mengubah method sebelum router mencocokkan route. Konsekuensinya, route update harus didaftarkan sebagai auth.Put, bukan auth.Post. Kalau salah, request tidak akan menemukan route dan berakhir 404.
Hanya POST yang diperiksa. Request GET langsung diteruskan supaya orang tidak bisa memicu penghapusan data lewat ?_method=DELETE di URL.
middlewares/csrf.go
package middlewares
import (
"html/template"
"github.com/gofiber/fiber/v2"
"belajar-fiber/helpers"
)
func CSRF(c *fiber.Ctx) error {
if c.Method() == fiber.MethodGet ||
c.Method() == fiber.MethodHead ||
c.Method() == fiber.MethodOptions {
return c.Next()
}
sess, err := helpers.GetSession(c)
if err != nil {
return err
}
token := c.FormValue("_token")
if token == "" {
token = c.Get("X-CSRF-TOKEN")
}
if !helpers.ValidateCSRF(sess, token) {
c.Status(419)
t, err := template.ParseFiles("views/errors/419.html")
if err != nil {
return err
}
return t.Execute(c, nil)
}
helpers.RegenerateCSRF(sess)
helpers.SaveSession(sess)
return c.Next()
}
Alurnya:
- GET, HEAD, dan OPTIONS dilewati karena tidak mengubah data.
- Token dibaca dari field
_token. Kalau kosong, dicoba dari headerX-CSRF-TOKENsupaya request AJAX tetap bisa dilayani. ValidateCSRFmembandingkan dengan token di sesi. Gagal berarti status 419 dan halaman Page Expired.- Berhasil berarti token diputar ulang, sesi disimpan, request lanjut ke controller.
Urutan pemasangan penting. MethodOverride harus lebih dulu daripada CSRF, supaya method sudah final saat pengecekan dilakukan.
middlewares/auth.go
package middlewares
import (
"github.com/gofiber/fiber/v2"
"belajar-fiber/config"
"belajar-fiber/helpers"
"belajar-fiber/models"
)
func Auth(c *fiber.Ctx) error {
sess, err := helpers.GetSession(c)
if err != nil {
return err
}
uid, ok := sess.Get("user_id").(uint)
if !ok {
helpers.SetFlash(sess, "error", "Silakan login terlebih dahulu.")
helpers.SaveSession(sess)
return c.Redirect("/login")
}
var user models.User
if err := config.DB.First(&user, uid).Error; err != nil {
sess.Delete("user_id")
helpers.SetFlash(sess, "error", "Sesi tidak valid, silakan login ulang.")
helpers.SaveSession(sess)
return c.Redirect("/login")
}
c.Locals("authUser", &user)
c.Locals("userID", uid)
return c.Next()
}
sess.Get("user_id").(uint)memakai type assertion. Nilai sesi bertipeinterface{}, jadi hasilnya harus dikembalikan ke tipe aslinya. Bentuk dua nilai denganokdipakai supaya program tidak panik saat nilainya kosong.- Setelah id ditemukan, user diambil ulang dari database setiap request. Ini memastikan user yang sudah dihapus admin tidak bisa terus memakai sesi lamanya.
c.Locals("authUser", &user)menaruh user di penyimpanan yang hidup selama satu request saja, padanan$request->user(). Dari sinihelpers.Rendermengambilnya jadi.AuthUser.- Setiap kegagalan berujung flash message dan redirect ke
/login, bukan error 500. Untuk pengguna, diarahkan ke halaman login jauh lebih masuk akal.
Uji Langkah Ini
go build ./...
Efeknya baru terlihat setelah route didaftarkan di Praktik 8. Yang bisa disiapkan sekarang adalah cara mengujinya nanti:
- Buka
/dashboardtanpa login, harus dilempar ke/login. - Kirim form dengan
_tokenyang diubah lewat inspect element, harus muncul halaman 419. - Klik tombol hapus, method di tab Network harus terbaca DELETE, bukan POST.
Praktik 6
Registrasi & Login
Controller auth lengkap dengan bcrypt, session fixation, dan dashboard.
Target langkah ini: alur autentikasi lengkap. Setelah ini aplikasi sudah bisa dipakai, meski baru sebatas daftar, masuk, keluar, dan melihat statistik.
controllers/auth_controller.go
package controllers
import (
"regexp"
"github.com/gofiber/fiber/v2"
"golang.org/x/crypto/bcrypt"
"belajar-fiber/config"
"belajar-fiber/helpers"
"belajar-fiber/models"
)
var emailPattern = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
func Home(c *fiber.Ctx) error {
return c.Redirect("/dashboard")
}
func ShowLogin(c *fiber.Ctx) error {
return helpers.Render(c, "auth", "views/auth/login.html", fiber.Map{"Title": "Login"})
}
func Login(c *fiber.Ctx) error {
email := c.FormValue("email")
password := c.FormValue("password")
sess, err := helpers.GetSession(c)
if err != nil {
return err
}
if email == "" || password == "" {
helpers.SetFlash(sess, "error", "Email dan password wajib diisi.")
helpers.SaveSession(sess)
return c.Redirect("/login")
}
var user models.User
if err := config.DB.Where("email = ?", email).First(&user).Error; err != nil {
helpers.SetFlash(sess, "error", "Kredensial tidak cocok.")
helpers.SaveSession(sess)
return c.Redirect("/login")
}
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil {
helpers.SetFlash(sess, "error", "Kredensial tidak cocok.")
helpers.SaveSession(sess)
return c.Redirect("/login")
}
helpers.RegenerateCSRF(sess)
sess.Set("user_id", user.ID)
helpers.SetFlash(sess, "success", "Berhasil login. Selamat datang, "+user.Name+"!")
helpers.SaveSession(sess)
return c.Redirect("/dashboard")
}
func ShowRegister(c *fiber.Ctx) error {
return helpers.Render(c, "auth", "views/auth/register.html", fiber.Map{"Title": "Registrasi"})
}
func Register(c *fiber.Ctx) error {
name := c.FormValue("name")
email := c.FormValue("email")
password := c.FormValue("password")
confirm := c.FormValue("password_confirmation")
sess, err := helpers.GetSession(c)
if err != nil {
return err
}
if name == "" || email == "" || password == "" {
helpers.SetFlash(sess, "error", "Semua field wajib diisi.")
helpers.SaveSession(sess)
return c.Redirect("/register")
}
if !emailPattern.MatchString(email) {
helpers.SetFlash(sess, "error", "Format email tidak valid.")
helpers.SaveSession(sess)
return c.Redirect("/register")
}
if len(password) < 6 {
helpers.SetFlash(sess, "error", "Password minimal 6 karakter.")
helpers.SaveSession(sess)
return c.Redirect("/register")
}
if password != confirm {
helpers.SetFlash(sess, "error", "Konfirmasi password tidak cocok.")
helpers.SaveSession(sess)
return c.Redirect("/register")
}
var count int64
config.DB.Model(&models.User{}).Where("email = ?", email).Count(&count)
if count > 0 {
helpers.SetFlash(sess, "error", "Email sudah terdaftar.")
helpers.SaveSession(sess)
return c.Redirect("/register")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
helpers.SetFlash(sess, "error", "Gagal memproses password.")
helpers.SaveSession(sess)
return c.Redirect("/register")
}
user := models.User{Name: name, Email: email, Password: string(hash)}
if err := config.DB.Create(&user).Error; err != nil {
helpers.SetFlash(sess, "error", "Gagal mendaftar: "+err.Error())
helpers.SaveSession(sess)
return c.Redirect("/register")
}
helpers.RegenerateCSRF(sess)
sess.Set("user_id", user.ID)
helpers.SetFlash(sess, "success", "Registrasi berhasil. Selamat datang, "+name+"!")
helpers.SaveSession(sess)
return c.Redirect("/dashboard")
}
func Logout(c *fiber.Ctx) error {
sess, err := helpers.GetSession(c)
if err != nil {
return err
}
sess.Delete("user_id")
helpers.RegenerateCSRF(sess)
helpers.SetFlash(sess, "success", "Anda berhasil logout.")
helpers.SaveSession(sess)
return c.Redirect("/login")
}
func Dashboard(c *fiber.Ctx) error {
var userCount, categoryCount, postCount int64
config.DB.Model(&models.User{}).Count(&userCount)
config.DB.Model(&models.Category{}).Count(&categoryCount)
config.DB.Model(&models.Post{}).Count(&postCount)
var recentPosts []models.Post
config.DB.Preload("User").Preload("Category").Order("created_at desc").Limit(5).Find(&recentPosts)
return helpers.Render(c, "app", "views/dashboard.html", fiber.Map{
"Title": "Dashboard",
"UserCount": userCount,
"CategoryCount": categoryCount,
"PostCount": postCount,
"RecentPosts": recentPosts,
})
}
File ini berisi tujuh handler. Bedah satu per satu bagian yang penting.
Validasi manual
Fiber tidak punya $request->validate(). Setiap aturan ditulis sebagai if biasa, dan setiap kegagalan mengikuti pola yang sama: set flash, simpan sesi, redirect kembali.
if len(password) < 6 {
helpers.SetFlash(sess, "error", "Password minimal 6 karakter.")
helpers.SaveSession(sess)
return c.Redirect("/register")
}
Terlihat berulang, tapi alurnya gamblang dan mudah diikuti pemula. Kalau aturan sudah banyak, barulah pindahkan ke fungsi validasi tersendiri atau pakai paket seperti go-playground/validator.
emailPattern dideklarasikan di level paket pada file ini, dan controllers/user_controller.go bisa langsung memakainya tanpa import karena keduanya berada di paket controllers yang sama.
Password
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
Padanan Hash::make(). Yang disimpan ke database adalah hash, bukan password asli, dan hash bcrypt tidak bisa dibalik. Karena itu saat login dipakai bcrypt.CompareHashAndPassword, bukan membandingkan dua hash secara langsung. Setiap hash bcrypt punya salt berbeda, jadi password sama akan menghasilkan hash berbeda.
Pesan error login sengaja disamakan
Email tidak ditemukan dan password salah sama-sama menghasilkan pesan Kredensial tidak cocok. Kalau pesannya dibedakan, orang bisa memakai form login untuk menebak email mana yang terdaftar.
Urutan saat login berhasil
helpers.RegenerateCSRF(sess)
sess.Set("user_id", user.ID)
helpers.SetFlash(sess, "success", "Berhasil login. Selamat datang, "+user.Name+"!")
helpers.SaveSession(sess)
Token diputar ulang sebelum user_id dipasang. Ini pencegahan session fixation, yaitu serangan ketika penyerang menyiapkan sesi lebih dulu lalu memakainya setelah korban login. Laravel melakukan hal setara lewat $request->session()->regenerate().
Logout bekerja terbalik: hapus user_id, putar token, beri pesan, simpan sesi.
Dashboard
Count dipanggil tiga kali untuk tiga statistik, lalu lima post terakhir diambil dengan Preload supaya nama penulis dan kategori ikut terisi. Tanpa preload, kolom penulis di dashboard akan kosong.
views/dashboard.html
{{define "content"}}
<h1 class="h3 mb-4">Dashboard</h1>
<div class="row g-3">
<div class="col-md-4">
<div class="card shadow-sm text-center">
<div class="card-body">
<h2 class="card-title display-6">{{.UserCount}}</h2>
<p class="text-muted mb-0">Total User</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm text-center">
<div class="card-body">
<h2 class="card-title display-6">{{.CategoryCount}}</h2>
<p class="text-muted mb-0">Total Kategori</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm text-center">
<div class="card-body">
<h2 class="card-title display-6">{{.PostCount}}</h2>
<p class="text-muted mb-0">Total Post</p>
</div>
</div>
</div>
</div>
<h2 class="h5 mt-5 mb-3">Post Terbaru</h2>
{{if .RecentPosts}}
<ul class="list-group shadow-sm">
{{range .RecentPosts}}
<li class="list-group-item d-flex justify-content-between align-items-center">
<a href="/posts/{{.ID}}" class="text-decoration-none">{{.Title}}</a>
<span class="text-muted small">{{.User.Name}} • {{.Category.Name}} • {{formatDate .CreatedAt}}</span>
</li>
{{end}}
</ul>
{{else}}
<div class="alert alert-info">Belum ada post. <a href="/posts/create">Buat post pertama</a>.</div>
{{end}}
{{end}}
{{range .RecentPosts}}dibungkus{{if .RecentPosts}}supaya ada pesan khusus saat data masih kosong. Sebuah halaman kosong sebaiknya mengajak bertindak, bukan sekadar diam.{{formatDate .CreatedAt}}memanggil fungsi darifuncMap. Tanpa itu,time.Timeakan tercetak panjang lengkap dengan zona waktu.- Di dalam
range, titik berubah makna menjadi item yang sedang diulang. Untuk mengakses data root, pakai$.
Uji Langkah Ini
Route belum ada, jadi tambahkan sementara ke routes/web.go versi stub:
app.Get("/register", controllers.ShowRegister)
app.Post("/register", controllers.Register)
app.Get("/login", controllers.ShowLogin)
app.Post("/login", controllers.Login)
Jangan lupa app.Use(middlewares.MethodOverride) dan app.Use(middlewares.CSRF) di baris paling atas fungsi Register, karena tanpa middleware CSRF token tidak pernah diverifikasi.
go run .
Buka http://localhost:3000/register, daftar satu akun, lalu cek database:
mysql -h 127.0.0.1 -P 3306 -u root belajar_fiber -e "SELECT id, name, email, LEFT(password, 7) AS hash FROM users;"
Kolom hash harus diawali $2a$10$, tanda password tersimpan dalam bentuk bcrypt.
Praktik 7
CRUD Tiga Resource
Controller dan view untuk user, kategori, dan post beserta aturan penjagaannya.
Target langkah ini: tiga resource lengkap beserta halamannya. Ketiganya mengikuti pola resource controller Laravel, yaitu index, create, store, edit, update, dan delete.
Bab ini paling panjang, tapi begitu pola user dipahami, dua sisanya tinggal mengulang dengan variasi kecil.
CRUD User
controllers/user_controller.go
package controllers
import (
"strconv"
"github.com/gofiber/fiber/v2"
"golang.org/x/crypto/bcrypt"
"belajar-fiber/config"
"belajar-fiber/helpers"
"belajar-fiber/models"
)
func UserIndex(c *fiber.Ctx) error {
var users []models.User
config.DB.Order("created_at desc").Find(&users)
return helpers.Render(c, "app", "views/users/index.html", fiber.Map{
"Title": "Daftar User",
"Users": users,
})
}
func UserCreate(c *fiber.Ctx) error {
return helpers.Render(c, "app", "views/users/create.html", fiber.Map{"Title": "Tambah User"})
}
func UserStore(c *fiber.Ctx) error {
name := c.FormValue("name")
email := c.FormValue("email")
password := c.FormValue("password")
sess, err := helpers.GetSession(c)
if err != nil {
return err
}
if name == "" || email == "" || password == "" {
helpers.SetFlash(sess, "error", "Semua field wajib diisi.")
helpers.SaveSession(sess)
return c.Redirect("/users/create")
}
if !emailPattern.MatchString(email) {
helpers.SetFlash(sess, "error", "Format email tidak valid.")
helpers.SaveSession(sess)
return c.Redirect("/users/create")
}
if len(password) < 6 {
helpers.SetFlash(sess, "error", "Password minimal 6 karakter.")
helpers.SaveSession(sess)
return c.Redirect("/users/create")
}
var count int64
config.DB.Model(&models.User{}).Where("email = ?", email).Count(&count)
if count > 0 {
helpers.SetFlash(sess, "error", "Email sudah terdaftar.")
helpers.SaveSession(sess)
return c.Redirect("/users/create")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
helpers.SetFlash(sess, "error", "Gagal memproses password.")
helpers.SaveSession(sess)
return c.Redirect("/users/create")
}
user := models.User{Name: name, Email: email, Password: string(hash)}
if err := config.DB.Create(&user).Error; err != nil {
helpers.SetFlash(sess, "error", "Gagal menyimpan user: "+err.Error())
helpers.SaveSession(sess)
return c.Redirect("/users/create")
}
helpers.SetFlash(sess, "success", "User berhasil dibuat.")
helpers.SaveSession(sess)
return c.Redirect("/users")
}
func UserEdit(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Redirect("/users")
}
var user models.User
if err := config.DB.First(&user, id).Error; err != nil {
sess, _ := helpers.GetSession(c)
helpers.SetFlash(sess, "error", "User tidak ditemukan.")
helpers.SaveSession(sess)
return c.Redirect("/users")
}
return helpers.Render(c, "app", "views/users/edit.html", fiber.Map{
"Title": "Edit User",
"User": user,
})
}
func UserUpdate(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Redirect("/users")
}
sess, err := helpers.GetSession(c)
if err != nil {
return err
}
var user models.User
if err := config.DB.First(&user, id).Error; err != nil {
helpers.SetFlash(sess, "error", "User tidak ditemukan.")
helpers.SaveSession(sess)
return c.Redirect("/users")
}
name := c.FormValue("name")
email := c.FormValue("email")
password := c.FormValue("password")
if name == "" || email == "" {
helpers.SetFlash(sess, "error", "Nama dan email wajib diisi.")
helpers.SaveSession(sess)
return c.Redirect("/users/" + c.Params("id") + "/edit")
}
if !emailPattern.MatchString(email) {
helpers.SetFlash(sess, "error", "Format email tidak valid.")
helpers.SaveSession(sess)
return c.Redirect("/users/" + c.Params("id") + "/edit")
}
var count int64
config.DB.Model(&models.User{}).Where("email = ? AND id <> ?", email, id).Count(&count)
if count > 0 {
helpers.SetFlash(sess, "error", "Email sudah dipakai user lain.")
helpers.SaveSession(sess)
return c.Redirect("/users/" + c.Params("id") + "/edit")
}
user.Name = name
user.Email = email
if password != "" {
if len(password) < 6 {
helpers.SetFlash(sess, "error", "Password minimal 6 karakter.")
helpers.SaveSession(sess)
return c.Redirect("/users/" + c.Params("id") + "/edit")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
helpers.SetFlash(sess, "error", "Gagal memproses password.")
helpers.SaveSession(sess)
return c.Redirect("/users/" + c.Params("id") + "/edit")
}
user.Password = string(hash)
}
if err := config.DB.Save(&user).Error; err != nil {
helpers.SetFlash(sess, "error", "Gagal memperbarui user: "+err.Error())
helpers.SaveSession(sess)
return c.Redirect("/users/" + c.Params("id") + "/edit")
}
helpers.SetFlash(sess, "success", "User berhasil diperbarui.")
helpers.SaveSession(sess)
return c.Redirect("/users")
}
func UserDelete(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Redirect("/users")
}
sess, err := helpers.GetSession(c)
if err != nil {
return err
}
if uid, ok := sess.Get("user_id").(uint); ok && uint(id) == uid {
helpers.SetFlash(sess, "error", "Tidak dapat menghapus akun sendiri.")
helpers.SaveSession(sess)
return c.Redirect("/users")
}
var postCount int64
config.DB.Model(&models.Post{}).Where("user_id = ?", id).Count(&postCount)
if postCount > 0 {
helpers.SetFlash(sess, "error", "User masih memiliki post, hapus post-nya dulu.")
helpers.SaveSession(sess)
return c.Redirect("/users")
}
if err := config.DB.Delete(&models.User{}, id).Error; err != nil {
helpers.SetFlash(sess, "error", "Gagal menghapus user: "+err.Error())
helpers.SaveSession(sess)
return c.Redirect("/users")
}
helpers.SetFlash(sess, "success", "User berhasil dihapus.")
helpers.SaveSession(sess)
return c.Redirect("/users")
}
Tiga hal yang membedakannya dari controller lain:
Cek email unik saat update mengecualikan diri sendiri.
config.DB.Model(&models.User{}).Where("email = ? AND id <> ?", email, id).Count(&count)
Tanpa id <> ?, user tidak akan pernah bisa menyimpan perubahan nama karena emailnya sendiri terhitung duplikat. Padanan Rule::unique('users')->ignore($user->id) di Laravel.
Password kosong berarti tidak diubah. Di form edit, kolom password sengaja dibiarkan kosong. Hash baru hanya dibuat kalau kolom itu diisi.
Dua penjaga sebelum menghapus. User tidak boleh menghapus akunnya sendiri, dan user yang masih punya post tidak boleh dihapus. Penjagaan kedua sebenarnya juga dilakukan foreign key di database, tapi menangkapnya lebih awal menghasilkan pesan yang bisa dimengerti pengguna, bukan error SQL mentah.
views/users/index.html
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">Daftar User</h1>
<a href="/users/create" class="btn btn-primary">+ Tambah User</a>
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Nama</th>
<th>Email</th>
<th>Dibuat</th>
<th class="text-end">Aksi</th>
</tr>
</thead>
<tbody>
{{range .Users}}
<tr>
<td>{{.ID}}</td>
<td>{{.Name}}</td>
<td>{{.Email}}</td>
<td>{{formatDate .CreatedAt}}</td>
<td class="text-end">
<a href="/users/{{.ID}}/edit" class="btn btn-sm btn-warning">Edit</a>
<form method="post" action="/users/{{.ID}}" class="d-inline" onsubmit="return confirm('Yakin hapus user ini?');">
<input type="hidden" name="_method" value="DELETE">
<input type="hidden" name="_token" value="{{$.CSRFToken}}">
<button type="submit" class="btn btn-sm btn-danger">Hapus</button>
</form>
</td>
</tr>
{{else}}
<tr>
<td colspan="5" class="text-center text-muted py-4">Belum ada user.</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
{{range .Users}} ... {{else}} ... {{end}}punya cabangelseyang dijalankan saat slice kosong. Padanan@forelsedi Blade.- Tombol hapus dibungkus form POST dengan
_method=DELETE, bukan link. Aksi yang mengubah data tidak boleh lewat GET. - Perhatikan
{{$.CSRFToken}}memakai$, bukan titik. Di dalamrange, titik sudah berubah jadi item user, sedangkan token ada di data root.
views/users/create.html
{{define "content"}}
<h1 class="h3 mb-4">Tambah User</h1>
<div class="card shadow-sm">
<div class="card-body">
<form method="post" action="/users">
<input type="hidden" name="_token" value="{{.CSRFToken}}">
<div class="mb-3">
<label for="name" class="form-label">Nama</label>
<input type="text" name="name" id="name" class="form-control" required autofocus>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" name="email" id="email" class="form-control" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" name="password" id="password" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary">Simpan</button>
<a href="/users" class="btn btn-secondary">Batal</a>
</form>
</div>
</div>
{{end}}
views/users/edit.html
{{define "content"}}
<h1 class="h3 mb-4">Edit User</h1>
<div class="card shadow-sm">
<div class="card-body">
<form method="post" action="/users/{{.User.ID}}">
<input type="hidden" name="_method" value="PUT">
<input type="hidden" name="_token" value="{{.CSRFToken}}">
<div class="mb-3">
<label for="name" class="form-label">Nama</label>
<input type="text" name="name" id="name" class="form-control" value="{{.User.Name}}" required>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" name="email" id="email" class="form-control" value="{{.User.Email}}" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" name="password" id="password" class="form-control" placeholder="Kosongkan jika tidak ingin diubah">
</div>
<button type="submit" class="btn btn-primary">Simpan Perubahan</button>
<a href="/users" class="btn btn-secondary">Batal</a>
</form>
</div>
</div>
{{end}}
Form edit mengarah ke /users/{{.User.ID}} dengan method POST plus _method=PUT. Middleware MethodOverride yang mengubahnya jadi PUT sebelum sampai router.
CRUD Kategori
controllers/category_controller.go
package controllers
import (
"strconv"
"github.com/gofiber/fiber/v2"
"belajar-fiber/config"
"belajar-fiber/helpers"
"belajar-fiber/models"
)
func CategoryIndex(c *fiber.Ctx) error {
var categories []models.Category
config.DB.Order("created_at desc").Find(&categories)
return helpers.Render(c, "app", "views/categories/index.html", fiber.Map{
"Title": "Daftar Kategori",
"Categories": categories,
})
}
func CategoryCreate(c *fiber.Ctx) error {
return helpers.Render(c, "app", "views/categories/create.html", fiber.Map{"Title": "Tambah Kategori"})
}
func CategoryStore(c *fiber.Ctx) error {
name := c.FormValue("name")
sess, err := helpers.GetSession(c)
if err != nil {
return err
}
if name == "" {
helpers.SetFlash(sess, "error", "Nama kategori wajib diisi.")
helpers.SaveSession(sess)
return c.Redirect("/categories/create")
}
slug := helpers.Slugify(name)
if slug == "" {
helpers.SetFlash(sess, "error", "Nama kategori tidak valid.")
helpers.SaveSession(sess)
return c.Redirect("/categories/create")
}
var count int64
config.DB.Model(&models.Category{}).Where("slug = ?", slug).Count(&count)
if count > 0 {
helpers.SetFlash(sess, "error", "Kategori dengan nama serupa sudah ada.")
helpers.SaveSession(sess)
return c.Redirect("/categories/create")
}
category := models.Category{Name: name, Slug: slug}
if err := config.DB.Create(&category).Error; err != nil {
helpers.SetFlash(sess, "error", "Gagal menyimpan kategori: "+err.Error())
helpers.SaveSession(sess)
return c.Redirect("/categories/create")
}
helpers.SetFlash(sess, "success", "Kategori berhasil dibuat.")
helpers.SaveSession(sess)
return c.Redirect("/categories")
}
func CategoryEdit(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Redirect("/categories")
}
var category models.Category
if err := config.DB.First(&category, id).Error; err != nil {
sess, _ := helpers.GetSession(c)
helpers.SetFlash(sess, "error", "Kategori tidak ditemukan.")
helpers.SaveSession(sess)
return c.Redirect("/categories")
}
return helpers.Render(c, "app", "views/categories/edit.html", fiber.Map{
"Title": "Edit Kategori",
"Category": category,
})
}
func CategoryUpdate(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Redirect("/categories")
}
sess, err := helpers.GetSession(c)
if err != nil {
return err
}
var category models.Category
if err := config.DB.First(&category, id).Error; err != nil {
helpers.SetFlash(sess, "error", "Kategori tidak ditemukan.")
helpers.SaveSession(sess)
return c.Redirect("/categories")
}
name := c.FormValue("name")
if name == "" {
helpers.SetFlash(sess, "error", "Nama kategori wajib diisi.")
helpers.SaveSession(sess)
return c.Redirect("/categories/" + c.Params("id") + "/edit")
}
slug := helpers.Slugify(name)
if slug == "" {
helpers.SetFlash(sess, "error", "Nama kategori tidak valid.")
helpers.SaveSession(sess)
return c.Redirect("/categories/" + c.Params("id") + "/edit")
}
var count int64
config.DB.Model(&models.Category{}).Where("slug = ? AND id <> ?", slug, id).Count(&count)
if count > 0 {
helpers.SetFlash(sess, "error", "Kategori dengan nama serupa sudah ada.")
helpers.SaveSession(sess)
return c.Redirect("/categories/" + c.Params("id") + "/edit")
}
category.Name = name
category.Slug = slug
if err := config.DB.Save(&category).Error; err != nil {
helpers.SetFlash(sess, "error", "Gagal memperbarui kategori: "+err.Error())
helpers.SaveSession(sess)
return c.Redirect("/categories/" + c.Params("id") + "/edit")
}
helpers.SetFlash(sess, "success", "Kategori berhasil diperbarui.")
helpers.SaveSession(sess)
return c.Redirect("/categories")
}
func CategoryDelete(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Redirect("/categories")
}
sess, err := helpers.GetSession(c)
if err != nil {
return err
}
var postCount int64
config.DB.Model(&models.Post{}).Where("category_id = ?", id).Count(&postCount)
if postCount > 0 {
helpers.SetFlash(sess, "error", "Kategori masih dipakai post, hapus post-nya dulu.")
helpers.SaveSession(sess)
return c.Redirect("/categories")
}
if err := config.DB.Delete(&models.Category{}, id).Error; err != nil {
helpers.SetFlash(sess, "error", "Gagal menghapus kategori: "+err.Error())
helpers.SaveSession(sess)
return c.Redirect("/categories")
}
helpers.SetFlash(sess, "success", "Kategori berhasil dihapus.")
helpers.SaveSession(sess)
return c.Redirect("/categories")
}
Yang khas di sini adalah slug. Nama kategori diubah jadi slug lewat helpers.Slugify, lalu keunikannya dicek sebelum disimpan. Slug kosong ditolak, karena nama yang seluruhnya berisi simbol seperti ??? akan menghasilkan slug kosong.
Kategori yang masih dipakai post tidak boleh dihapus, sama seperti aturan pada user.
views/categories/index.html
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">Daftar Kategori</h1>
<a href="/categories/create" class="btn btn-primary">+ Tambah Kategori</a>
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Nama</th>
<th>Slug</th>
<th>Dibuat</th>
<th class="text-end">Aksi</th>
</tr>
</thead>
<tbody>
{{range .Categories}}
<tr>
<td>{{.ID}}</td>
<td>{{.Name}}</td>
<td><code>{{.Slug}}</code></td>
<td>{{formatDate .CreatedAt}}</td>
<td class="text-end">
<a href="/categories/{{.ID}}/edit" class="btn btn-sm btn-warning">Edit</a>
<form method="post" action="/categories/{{.ID}}" class="d-inline" onsubmit="return confirm('Yakin hapus kategori ini?');">
<input type="hidden" name="_method" value="DELETE">
<input type="hidden" name="_token" value="{{$.CSRFToken}}">
<button type="submit" class="btn btn-sm btn-danger">Hapus</button>
</form>
</td>
</tr>
{{else}}
<tr>
<td colspan="5" class="text-center text-muted py-4">Belum ada kategori.</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
views/categories/create.html
{{define "content"}}
<h1 class="h3 mb-4">Tambah Kategori</h1>
<div class="card shadow-sm">
<div class="card-body">
<form method="post" action="/categories">
<input type="hidden" name="_token" value="{{.CSRFToken}}">
<div class="mb-3">
<label for="name" class="form-label">Nama</label>
<input type="text" name="name" id="name" class="form-control" required autofocus>
</div>
<div class="form-text mb-3">Slug dibuat otomatis dari nama.</div>
<button type="submit" class="btn btn-primary">Simpan</button>
<a href="/categories" class="btn btn-secondary">Batal</a>
</form>
</div>
</div>
{{end}}
views/categories/edit.html
{{define "content"}}
<h1 class="h3 mb-4">Edit Kategori</h1>
<div class="card shadow-sm">
<div class="card-body">
<form method="post" action="/categories/{{.Category.ID}}">
<input type="hidden" name="_method" value="PUT">
<input type="hidden" name="_token" value="{{.CSRFToken}}">
<div class="mb-3">
<label for="name" class="form-label">Nama</label>
<input type="text" name="name" id="name" class="form-control" value="{{.Category.Name}}" required>
</div>
<div class="form-text mb-3">Slug saat ini: <code>{{.Category.Slug}}</code> — akan diperbarui otomatis.</div>
<button type="submit" class="btn btn-primary">Simpan Perubahan</button>
<a href="/categories" class="btn btn-secondary">Batal</a>
</form>
</div>
</div>
{{end}}
CRUD Post
controllers/post_controller.go
package controllers
import (
"fmt"
"strconv"
"time"
"github.com/gofiber/fiber/v2"
"belajar-fiber/config"
"belajar-fiber/helpers"
"belajar-fiber/models"
)
func PostIndex(c *fiber.Ctx) error {
var posts []models.Post
config.DB.Preload("User").Preload("Category").Order("created_at desc").Find(&posts)
return helpers.Render(c, "app", "views/posts/index.html", fiber.Map{
"Title": "Daftar Post",
"Posts": posts,
})
}
func PostCreate(c *fiber.Ctx) error {
var categories []models.Category
var users []models.User
config.DB.Order("name asc").Find(&categories)
config.DB.Order("name asc").Find(&users)
return helpers.Render(c, "app", "views/posts/create.html", fiber.Map{
"Title": "Tambah Post",
"Categories": categories,
"Users": users,
})
}
func PostStore(c *fiber.Ctx) error {
title := c.FormValue("title")
content := c.FormValue("content")
categoryID, _ := strconv.ParseUint(c.FormValue("category_id"), 10, 64)
userID, _ := strconv.ParseUint(c.FormValue("user_id"), 10, 64)
sess, err := helpers.GetSession(c)
if err != nil {
return err
}
if title == "" || content == "" || categoryID == 0 || userID == 0 {
helpers.SetFlash(sess, "error", "Semua field wajib diisi.")
helpers.SaveSession(sess)
return c.Redirect("/posts/create")
}
var catCount int64
config.DB.Model(&models.Category{}).Where("id = ?", categoryID).Count(&catCount)
var userCount int64
config.DB.Model(&models.User{}).Where("id = ?", userID).Count(&userCount)
if catCount == 0 || userCount == 0 {
helpers.SetFlash(sess, "error", "Kategori atau penulis tidak valid.")
helpers.SaveSession(sess)
return c.Redirect("/posts/create")
}
slug := helpers.Slugify(title)
if slug == "" {
helpers.SetFlash(sess, "error", "Judul tidak valid.")
helpers.SaveSession(sess)
return c.Redirect("/posts/create")
}
var count int64
config.DB.Model(&models.Post{}).Where("slug = ?", slug).Count(&count)
if count > 0 {
slug = fmt.Sprintf("%s-%d", slug, time.Now().Unix())
}
post := models.Post{
Title: title,
Slug: slug,
Content: content,
UserID: uint(userID),
CategoryID: uint(categoryID),
}
if err := config.DB.Create(&post).Error; err != nil {
helpers.SetFlash(sess, "error", "Gagal menyimpan post: "+err.Error())
helpers.SaveSession(sess)
return c.Redirect("/posts/create")
}
helpers.SetFlash(sess, "success", "Post berhasil dibuat.")
helpers.SaveSession(sess)
return c.Redirect("/posts")
}
func PostShow(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Redirect("/posts")
}
var post models.Post
if err := config.DB.Preload("User").Preload("Category").First(&post, id).Error; err != nil {
sess, _ := helpers.GetSession(c)
helpers.SetFlash(sess, "error", "Post tidak ditemukan.")
helpers.SaveSession(sess)
return c.Redirect("/posts")
}
return helpers.Render(c, "app", "views/posts/show.html", fiber.Map{
"Title": post.Title,
"Post": post,
})
}
func PostEdit(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Redirect("/posts")
}
var post models.Post
if err := config.DB.First(&post, id).Error; err != nil {
sess, _ := helpers.GetSession(c)
helpers.SetFlash(sess, "error", "Post tidak ditemukan.")
helpers.SaveSession(sess)
return c.Redirect("/posts")
}
var categories []models.Category
var users []models.User
config.DB.Order("name asc").Find(&categories)
config.DB.Order("name asc").Find(&users)
return helpers.Render(c, "app", "views/posts/edit.html", fiber.Map{
"Title": "Edit Post",
"Post": post,
"Categories": categories,
"Users": users,
})
}
func PostUpdate(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Redirect("/posts")
}
sess, err := helpers.GetSession(c)
if err != nil {
return err
}
var post models.Post
if err := config.DB.First(&post, id).Error; err != nil {
helpers.SetFlash(sess, "error", "Post tidak ditemukan.")
helpers.SaveSession(sess)
return c.Redirect("/posts")
}
title := c.FormValue("title")
content := c.FormValue("content")
categoryID, _ := strconv.ParseUint(c.FormValue("category_id"), 10, 64)
userID, _ := strconv.ParseUint(c.FormValue("user_id"), 10, 64)
editURL := "/posts/" + c.Params("id") + "/edit"
if title == "" || content == "" || categoryID == 0 || userID == 0 {
helpers.SetFlash(sess, "error", "Semua field wajib diisi.")
helpers.SaveSession(sess)
return c.Redirect(editURL)
}
slug := helpers.Slugify(title)
if slug == "" {
helpers.SetFlash(sess, "error", "Judul tidak valid.")
helpers.SaveSession(sess)
return c.Redirect(editURL)
}
var count int64
config.DB.Model(&models.Post{}).Where("slug = ? AND id <> ?", slug, id).Count(&count)
if count > 0 {
slug = fmt.Sprintf("%s-%d", slug, time.Now().Unix())
}
post.Title = title
post.Slug = slug
post.Content = content
post.CategoryID = uint(categoryID)
post.UserID = uint(userID)
if err := config.DB.Save(&post).Error; err != nil {
helpers.SetFlash(sess, "error", "Gagal memperbarui post: "+err.Error())
helpers.SaveSession(sess)
return c.Redirect(editURL)
}
helpers.SetFlash(sess, "success", "Post berhasil diperbarui.")
helpers.SaveSession(sess)
return c.Redirect("/posts/" + c.Params("id"))
}
func PostDelete(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Redirect("/posts")
}
sess, err := helpers.GetSession(c)
if err != nil {
return err
}
if err := config.DB.Delete(&models.Post{}, id).Error; err != nil {
helpers.SetFlash(sess, "error", "Gagal menghapus post: "+err.Error())
helpers.SaveSession(sess)
return c.Redirect("/posts")
}
helpers.SetFlash(sess, "success", "Post berhasil dihapus.")
helpers.SaveSession(sess)
return c.Redirect("/posts")
}
Ini controller paling kompleks karena melibatkan dua relasi.
Preload di index dan show. config.DB.Preload("User").Preload("Category") membuat GORM mengambil data relasi dalam satu putaran query tambahan, bukan satu query per baris. Ini penyelesaian masalah N+1, padanan with('user', 'category').
Create dan edit menyiapkan dropdown. Sebelum merender form, controller mengambil seluruh kategori dan user untuk mengisi <select>.
Foreign key divalidasi manual.
config.DB.Model(&models.Category{}).Where("id = ?", categoryID).Count(&catCount)
Nilai category_id datang dari form, dan form bisa dimanipulasi. Database memang akan menolak id yang tidak ada, tapi menangkapnya lebih dulu memberi pesan yang jauh lebih ramah.
Slug bentrok diberi akhiran waktu.
if count > 0 {
slug = fmt.Sprintf("%s-%d", slug, time.Now().Unix())
}
Dua post berjudul sama tetap bisa disimpan, hasilnya judul-sama dan judul-sama-1754899200.
Post boleh langsung dihapus tanpa penjagaan tambahan, karena tidak ada tabel lain yang bergantung padanya.
views/posts/index.html
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">Daftar Post</h1>
<a href="/posts/create" class="btn btn-primary">+ Tambah Post</a>
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Judul</th>
<th>Kategori</th>
<th>Penulis</th>
<th>Dibuat</th>
<th class="text-end">Aksi</th>
</tr>
</thead>
<tbody>
{{range .Posts}}
<tr>
<td>{{.ID}}</td>
<td><a href="/posts/{{.ID}}" class="text-decoration-none">{{.Title}}</a></td>
<td>{{.Category.Name}}</td>
<td>{{.User.Name}}</td>
<td>{{formatDate .CreatedAt}}</td>
<td class="text-end">
<a href="/posts/{{.ID}}" class="btn btn-sm btn-info">Lihat</a>
<a href="/posts/{{.ID}}/edit" class="btn btn-sm btn-warning">Edit</a>
<form method="post" action="/posts/{{.ID}}" class="d-inline" onsubmit="return confirm('Yakin hapus post ini?');">
<input type="hidden" name="_method" value="DELETE">
<input type="hidden" name="_token" value="{{$.CSRFToken}}">
<button type="submit" class="btn btn-sm btn-danger">Hapus</button>
</form>
</td>
</tr>
{{else}}
<tr>
<td colspan="6" class="text-center text-muted py-4">Belum ada post.</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
{{.Category.Name}} dan {{.User.Name}} hanya berisi nilai kalau controller memanggil Preload. Kalau kolomnya tampak kosong di browser, hampir pasti preload-nya yang tertinggal.
views/posts/create.html
{{define "content"}}
<h1 class="h3 mb-4">Tambah Post</h1>
<div class="card shadow-sm">
<div class="card-body">
<form method="post" action="/posts">
<input type="hidden" name="_token" value="{{.CSRFToken}}">
<div class="mb-3">
<label for="title" class="form-label">Judul</label>
<input type="text" name="title" id="title" class="form-control" required autofocus>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="category_id" class="form-label">Kategori</label>
<select name="category_id" id="category_id" class="form-select" required>
<option value="">-- Pilih Kategori --</option>
{{range .Categories}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
</div>
<div class="col-md-6 mb-3">
<label for="user_id" class="form-label">Penulis</label>
<select name="user_id" id="user_id" class="form-select" required>
<option value="">-- Pilih Penulis --</option>
{{range .Users}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
</div>
</div>
<div class="mb-3">
<label for="content" class="form-label">Konten</label>
<textarea name="content" id="content" class="form-control" rows="8" required></textarea>
</div>
<button type="submit" class="btn btn-primary">Simpan</button>
<a href="/posts" class="btn btn-secondary">Batal</a>
</form>
</div>
</div>
{{end}}
views/posts/edit.html
{{define "content"}}
<h1 class="h3 mb-4">Edit Post</h1>
<div class="card shadow-sm">
<div class="card-body">
<form method="post" action="/posts/{{.Post.ID}}">
<input type="hidden" name="_method" value="PUT">
<input type="hidden" name="_token" value="{{.CSRFToken}}">
<div class="mb-3">
<label for="title" class="form-label">Judul</label>
<input type="text" name="title" id="title" class="form-control" value="{{.Post.Title}}" required>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="category_id" class="form-label">Kategori</label>
<select name="category_id" id="category_id" class="form-select" required>
{{range .Categories}}
<option value="{{.ID}}" {{if eq $.Post.CategoryID .ID}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
<div class="col-md-6 mb-3">
<label for="user_id" class="form-label">Penulis</label>
<select name="user_id" id="user_id" class="form-select" required>
{{range .Users}}
<option value="{{.ID}}" {{if eq $.Post.UserID .ID}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
</div>
<div class="mb-3">
<label for="content" class="form-label">Konten</label>
<textarea name="content" id="content" class="form-control" rows="8" required>{{.Post.Content}}</textarea>
</div>
<button type="submit" class="btn btn-primary">Simpan Perubahan</button>
<a href="/posts/{{.Post.ID}}" class="btn btn-secondary">Batal</a>
</form>
</div>
</div>
{{end}}
Baris ini menandai pilihan yang sedang aktif di dropdown:
<option value="{{.ID}}" {{if eq $.Post.CategoryID .ID}}selected{{end}}>{{.Name}}</option>
eq adalah fungsi bawaan template untuk membandingkan dua nilai, karena html/template tidak mengenal operator ==. Perhatikan lagi bedanya $.Post.CategoryID yang menunjuk data root dengan .ID yang menunjuk kategori yang sedang diulang.
views/posts/show.html
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">{{.Post.Title}}</h1>
<a href="/posts" class="btn btn-secondary btn-sm">Kembali</a>
</div>
<p class="text-muted">
Oleh <strong>{{.Post.User.Name}}</strong> •
Kategori <a href="#">{{.Post.Category.Name}}</a> •
{{formatDate .Post.CreatedAt}}
</p>
<div class="card shadow-sm">
<div class="card-body">
<p class="mb-0" style="white-space: pre-wrap;">{{.Post.Content}}</p>
</div>
</div>
<div class="mt-3">
<a href="/posts/{{.Post.ID}}/edit" class="btn btn-warning">Edit Post</a>
</div>
{{end}}
Konten ditampilkan dengan style="white-space: pre-wrap;" supaya baris baru dari textarea tetap terlihat. Isinya tetap di-escape otomatis oleh html/template, jadi tag HTML yang diketik pengguna akan tampil sebagai teks biasa dan tidak dieksekusi browser.
Uji Langkah Ini
go build ./...
Seluruh handler sudah siap. Yang tersisa hanya menyambungkannya ke URL di Praktik 8.
Praktik 8
Route & Uji Coba
routes/web.go final, daftar uji manual, dan tabel masalah yang sering muncul.
Target langkah ini: seluruh potongan disambungkan, aplikasi jalan penuh, dan setiap fitur diuji satu per satu.
routes/web.go
Ganti isi file stub dari Praktik 1 dengan versi lengkapnya:
package routes
import (
"github.com/gofiber/fiber/v2"
"belajar-fiber/controllers"
"belajar-fiber/middlewares"
)
func Register(app *fiber.App) {
app.Use(middlewares.MethodOverride)
app.Use(middlewares.CSRF)
app.Get("/", controllers.Home)
app.Get("/login", controllers.ShowLogin)
app.Post("/login", controllers.Login)
app.Get("/register", controllers.ShowRegister)
app.Post("/register", controllers.Register)
auth := app.Group("", middlewares.Auth)
auth.Post("/logout", controllers.Logout)
auth.Get("/dashboard", controllers.Dashboard)
auth.Get("/users", controllers.UserIndex)
auth.Get("/users/create", controllers.UserCreate)
auth.Post("/users", controllers.UserStore)
auth.Get("/users/:id/edit", controllers.UserEdit)
auth.Put("/users/:id", controllers.UserUpdate)
auth.Delete("/users/:id", controllers.UserDelete)
auth.Get("/categories", controllers.CategoryIndex)
auth.Get("/categories/create", controllers.CategoryCreate)
auth.Post("/categories", controllers.CategoryStore)
auth.Get("/categories/:id/edit", controllers.CategoryEdit)
auth.Put("/categories/:id", controllers.CategoryUpdate)
auth.Delete("/categories/:id", controllers.CategoryDelete)
auth.Get("/posts", controllers.PostIndex)
auth.Get("/posts/create", controllers.PostCreate)
auth.Post("/posts", controllers.PostStore)
auth.Get("/posts/:id", controllers.PostShow)
auth.Get("/posts/:id/edit", controllers.PostEdit)
auth.Put("/posts/:id", controllers.PostUpdate)
auth.Delete("/posts/:id", controllers.PostDelete)
}
Empat hal yang menentukan file ini berfungsi atau tidak.
Urutan middleware global
app.Use(middlewares.MethodOverride)
app.Use(middlewares.CSRF)
MethodOverride harus lebih dulu. Kalau dibalik, CSRF memeriksa request yang methodnya belum final.
Route publik di luar grup auth
/, /login, dan /register didaftarkan langsung ke app, sedangkan sisanya lewat auth := app.Group("", middlewares.Auth). Prefix grup sengaja dikosongkan karena yang dibutuhkan hanya middlewarenya, bukan awalan URL. Padanan Route::middleware('auth')->group(...).
Route statis sebelum route berparameter
auth.Get("/posts/create", controllers.PostCreate)
auth.Get("/posts/:id", controllers.PostShow)
Fiber mencocokkan route sesuai urutan pendaftaran. Kalau /posts/:id didaftarkan lebih dulu, membuka /posts/create akan ditangkap sebagai PostShow dengan id bernilai create, dan halaman tambah post tidak akan pernah muncul.
Update memakai Put, bukan Post
auth.Put("/posts/:id", controllers.PostUpdate)
auth.Delete("/posts/:id", controllers.PostDelete)
Form di browser tetap mengirim POST dengan _method, tapi MethodOverride sudah mengubahnya menjadi PUT atau DELETE sebelum router bekerja. Karena itu yang didaftarkan adalah method hasil perubahan tersebut.
Peta route lengkap
| Method | URL | Handler | Perlu login |
|---|---|---|---|
| GET | / |
Home |
tidak |
| GET, POST | /login |
ShowLogin, Login |
tidak |
| GET, POST | /register |
ShowRegister, Register |
tidak |
| POST | /logout |
Logout |
ya |
| GET | /dashboard |
Dashboard |
ya |
| GET | /users |
UserIndex |
ya |
| GET | /users/create |
UserCreate |
ya |
| POST | /users |
UserStore |
ya |
| GET | /users/:id/edit |
UserEdit |
ya |
| PUT | /users/:id |
UserUpdate |
ya |
| DELETE | /users/:id |
UserDelete |
ya |
| GET | /posts/:id |
PostShow |
ya |
Kategori mengikuti pola user, dan post menambahkan satu route show.
Jalankan
go mod tidy
go vet ./...
go build ./...
go run .
Buka http://localhost:3000. Karena belum login, Home mengarahkan ke /dashboard, middleware Auth menolak, dan browser berakhir di /login. Rantai pengalihan itu justru bukti middleware bekerja.
Daftar Uji Manual
Kerjakan berurutan, semuanya lewat browser:
- Daftar akun baru di
/register, lalu pastikan langsung masuk ke dashboard. - Logout, lalu login kembali dengan akun yang sama.
- Isi password salah, pastikan muncul pesan
Kredensial tidak cocok. - Buat dua kategori di
/categories, cek kolom slug terisi otomatis. - Buat satu post, pastikan nama kategori dan penulis tampil benar di daftar post.
- Edit post tersebut, cek dropdown kategori sudah memilih nilai yang sekarang.
- Coba hapus kategori yang dipakai post, pastikan ditolak dengan pesan yang jelas.
- Coba hapus akun yang sedang dipakai login, pastikan ditolak.
- Buka
/usersdi jendela penyamaran, pastikan dilempar ke/login. - Buka inspect element, ubah nilai
_tokendi salah satu form, lalu submit. Harus muncul halaman 419.
Kalau kesepuluhnya lolos, aplikasi sudah utuh.
Masalah yang Sering Muncul
| Gejala | Penyebab | Penyelesaian |
|---|---|---|
Error 1045: Access denied |
user atau password MySQL di .env salah |
cek DB_USER dan DB_PASSWORD |
Error 1049: Unknown database |
database belum dibuat | jalankan CREATE DATABASE belajar_fiber |
dial tcp 127.0.0.1:3306: connect: connection refused |
MySQL belum jalan | nyalakan servis MySQL atau DBngin |
open views/layouts/app.html: no such file or directory |
aplikasi dijalankan dari folder lain | jalankan go run . dari root proyek |
missing go.sum entry |
dependency belum tersinkron | jalankan go mod tidy |
| Halaman selalu 419 | form tidak memuat _token, atau SaveSession tidak dipanggil setelah mengubah sesi |
cek hidden input dan pemanggilan helpers.SaveSession |
| Kolom kategori dan penulis kosong | controller lupa Preload |
tambahkan .Preload("User").Preload("Category") |
| Tombol hapus tidak bekerja | route didaftarkan Post, bukan Delete |
samakan method route dengan nilai _method |
address already in use |
port 3000 dipakai proses lain | ganti APP_PORT di .env |
Langkah Selanjutnya
Aplikasi ini sengaja dibuat sederhana supaya pola MVCnya kelihatan. Beberapa hal yang layak dikerjakan sendiri sebagai latihan:
- Pindahkan validasi berulang ke fungsi tersendiri, atau pakai
go-playground/validator. - Parsing template sekali saat startup, bukan setiap request.
- Ganti penyimpanan sesi in-memory dengan Redis supaya sesi tidak hilang saat aplikasi restart.
- Batasi agar user hanya bisa mengedit postnya sendiri, padanan policy di Laravel.
- Tambahkan pagination di daftar post memakai
LimitdanOffset. - Tulis test dengan
app.Test(req)bawaan Fiber.
Belajar Fiber ala Laravel — materi tutorial Go Fiber berbahasa Indonesia.