Sakuci Framework
Panduan lengkap: CLI, Routing, Model, Controller, View, Bootstrap UI, Validation, Database, Session, Middleware, dan Multi-Role Login.
Dokumentasi Sakuci
Kerangka PHP ringan bergaya Laravel tanpa Composer. Fitur: Route, Model, View, Controller, Validation, Session, Middleware, CSRF protection, Query Builder dengan eager loading, dan Bootstrap 5.3.8 built-in.
Instalasi cepat: Clone dari GitHub atau salin folder ke web server Anda.
# Clone dari GitHub
git clone https://github.com/indrabsus/sakuci-framework.git
cd sakuci-framework
# Atau download dan ekstrak folder
# Salin .env.example jadi .env
cp .env.example .env
# Jalankan server
php sakuci serve
# Buka di browser
http://127.0.0.1:8000
Coba akun demo: admin / rahasia123 — satu-satunya akun bawaan. Role lain (staff, user, dst.) dibuat sendiri lewat /admin/roles setelah login.
CLI -- Perintah sakuci
Jalankan semua perintah dari folder project:
php sakuci serve # Jalankan server (127.0.0.1:8000)
php sakuci migrate # Jalankan migrasi
php sakuci migrate:fresh # Hapus semua tabel
php sakuci db:check # Uji koneksi database
php sakuci route:list # Lihat semua route
php sakuci make:model Nama # Buat model
php sakuci make:model Nama -m # Buat model + migrasi
php sakuci make:controller Nama # Buat controller
php sakuci make:view nama.view # Buat view
php sakuci make:migration nama # Buat migrasi
php sakuci view:clear # Bersihkan cache view
Routing
Daftarkan route di routes/web.php:
use App\Controllers\PostController;
use Sakuci\Route;
// Route dasar
Route::get('/', function () { return view('welcome'); })->name('home');
Route::get('/posts', [PostController::class, 'index'])->name('posts.index');
Route::post('/posts', [PostController::class, 'store'])->name('posts.store');
// Parameter
Route::get('/posts/{id}', [PostController::class, 'show'])->name('posts.show');
Route::get('/posts/{id}/edit', [PostController::class, 'edit'])->name('posts.edit');
// 7 route CRUD sekaligus
Route::resource('posts', PostController::class);
// Group dengan middleware
Route::group(['prefix' => 'admin', 'middleware' => 'admin'], function () {
Route::get('/', [AdminController::class, 'index'])->name('admin.dashboard');
});
Route dapat diakses lewat nama:
route('home') # /
route('posts.index') # /posts
route('posts.show', ['id' => 1]) # /posts/1
route('admin.dashboard') # /admin/
Model & Query Builder
Model mewakili satu tabel di database. Buat dengan:
php sakuci make:model Post -m
<?php
namespace App\Models;
use Sakuci\Database\Model;
class Post extends Model
{
protected static ?string $table = 'posts'; // opsional, ditebak dari nama
protected array $fillable = ['title', 'body', 'status'];
protected array $hidden = []; // tidak tampil saat JSON
}
Query dasar:
Post::all(); // Semua post
Post::find(1); // Post id=1
Post::findOrFail(1); // Atau 404
Post::where('status', 'published')->get();
Post::where('status', 'published')->first();
Post::latest()->limit(5)->get();
Post::count();
// Join
Post::leftJoin('users', 'users.id', '=', 'posts.user_id')
->select('posts.title', 'users.nama')
->get();
// Eager loading (cegah N+1)
Post::with('user', 'comments')->get();
// Paging
Post::paginate(15); // 15 per halaman
CRUD:
// Create
Post::create(['title' => 'Hello', 'body' => '...', 'status' => 'published']);
// Update
$post = Post::find(1);
$post->update(['title' => 'New title']);
// Delete
$post->delete();
Controller
Buat dengan: php sakuci make:controller PostController
<?php
namespace App\Controllers;
use App\Models\Post;
use Sakuci\Controller;
use Sakuci\Http\Request;
class PostController extends Controller
{
// GET /posts
public function index()
{
$posts = Post::latest()->paginate(10);
return view('posts.index', ['posts' => $posts]);
}
// GET /posts/create
public function create()
{
return view('posts.create');
}
// POST /posts
public function store(Request $request)
{
$data = $request->validate([
'title' => 'required|min:3|max:100',
'body' => 'required|min:10',
]);
Post::create($data);
return redirect(route('posts.index'))
->with('success', 'Post berhasil dibuat.');
}
// GET /posts/{id} -- Route model binding
public function show(Post $post)
{
return view('posts.show', ['post' => $post]);
}
// GET /posts/{id}/edit
public function edit(Post $post)
{
return view('posts.edit', ['post' => $post]);
}
// PUT /posts/{id}
public function update(Request $request, Post $post)
{
$data = $request->validate([
'title' => 'required|min:3|max:100',
'body' => 'required|min:10',
]);
$post->update($data);
return redirect(route('posts.index'))
->with('success', 'Post berhasil diubah.');
}
// DELETE /posts/{id}
public function destroy(Post $post)
{
$post->delete();
return redirect(route('posts.index'))
->with('success', 'Post berhasil dihapus.');
}
}
Penjelasan: Parameter bertipe (Post $post) otomatis diambil dari database berdasarkan {id}. Ini namanya route model binding.
View (Template)
File view ada di resources/views/ dengan akhiran .sakuci.php. Pakai sintaks Blade-like:
@extends('layouts.app')
@section('title', 'Daftar Post')
@section('content')
<h1>Daftar Post</h1>
{{-- Output aman (escape) --}}
<h2>{{ $post->title }}</h2>
{{-- Output tanpa escape --}}
{!! $html !!}
{{-- Kondisi --}}
@if ($posts->count() > 0)
Ada {{ $posts->count() }} post
@else
Belum ada post
@endif
{{-- Loop --}}
@foreach ($posts as $post)
<h3>{{ $post->title }}</h3>
@endforeach
{{-- Loop dengan else --}}
@forelse ($posts as $post)
<article>{{ $post->title }}</article>
@empty
<p>Belum ada post</p>
@endforelse
{{-- Include --}}
@include('components.post-card', ['post' => $post])
{{-- CSRF (wajib di form) --}}
<form method="POST" action="/posts">
@csrf
<input type="text" name="title">
</form>
{{-- Flash message --}}
@if (session('success'))
<div class="alert">{{ session('success') }}</div>
@endif
{{-- Validation error --}}
@error('title')
<span class="error">{{ $message }}</span>
@enderror
{{-- Old input --}}
<input type="text" value="{{ old('title') }}">
@endsection
Validation
Di controller, gunakan $request->validate():
$data = $request->validate([
'nama' => 'required|min:3|max:100',
'email' => 'required|email|unique:users,email',
'umur' => 'nullable|integer|min:18',
'status' => 'required|in:aktif,tidak-aktif',
]);
// Kalau validasi gagal, auto-redirect dengan error & old input
// Kalau validasi lulus, $data berisi nilai yang sudah validated
Rule tersedia: required, nullable, email, url, numeric, integer, min, max, between, in, not_in, regex, alpha, alpha_num, alpha_dash, confirmed, same, different, unique, exists, date, size, array, boolean, string
Bootstrap 5.3.8 -- Grid, Card & Tabel
Bootstrap 5.3.8 sudah built-in (file lokal di public/vendor/bootstrap, tidak butuh internet). Ganti warna khas di public/css/app.css, otomatis berlaku ke seluruh tombol, badge, dan aksen brand:
:root {
--brand: #c2410c; /* Warna utama */
--brand-dark: #9a3412; /* Saat hover */
--brand-subtle: #fff7ed; /* Latar lembut, mis. badge */
}
Grid System -- container, row, col
Layout Bootstrap berbasis 12 kolom. Tiga lapis yang selalu dipakai bersamaan: .container (bungkus lebar & center konten, sudah otomatis ada di layouts/app.sakuci.php), .row (bungkus satu baris kolom), lalu .col-* (kolom di dalamnya). Angka di belakang col menyatakan lebar dari 12, misal col-md-6 = separuh lebar mulai breakpoint md ke atas.
| Breakpoint | Lebar layar | Kegunaan |
|---|---|---|
| (tanpa akhiran) col-6 | < 576px (HP) | Berlaku di semua ukuran layar |
| col-sm-6 | >= 576px | HP landscape ke atas |
| col-md-6 | >= 768px | Tablet ke atas |
| col-lg-6 | >= 992px | Laptop ke atas |
| col-xl-6 | >= 1200px | Layar besar |
| col-xxl-6 | >= 1400px | Layar sangat besar |
Kolom otomatis (tanpa angka): kalau semua col di satu row ditulis tanpa angka (cukup class="col"), Bootstrap membagi rata lebarnya secara otomatis -- cocok untuk jumlah kolom yang dinamis.
<div class="container">
{{-- 3 kolom sama lebar, otomatis dibagi rata --}}
<div class="row g-3">
<div class="col">Kolom 1</div>
<div class="col">Kolom 2</div>
<div class="col">Kolom 3</div>
</div>
{{-- 2 kolom: penuh di HP, separuh-separuh mulai tablet (md) --}}
<div class="row g-3 mt-2">
<div class="col-md-6">Separuh kiri</div>
<div class="col-md-6">Separuh kanan</div>
</div>
</div>
Offset & order: offset-md-4 menggeser kolom ke kanan sejauh 4/12 (dipakai untuk "menengahkan" kolom yang lebih sempit dari 12). order-1, order-2, dst mengubah urutan tampil tanpa mengubah urutan HTML -- berguna supaya urutan di HP beda dari di desktop.
{{-- col-md-4 di tengah, karena sisa 8/12 dibagi rata kiri-kanan --}}
<div class="row">
<div class="col-md-4 offset-md-4">Kolom di tengah</div>
</div>
{{-- urutan tampil: B, C, A -- meski di HTML urutannya A, B, C --}}
<div class="row g-2">
<div class="col order-3">A (order-3)</div>
<div class="col order-1">B (order-1)</div>
<div class="col order-2">C (order-2)</div>
</div>
Spacing Utilities -- Margin & Padding
Pola nama kelasnya: {property}{sisi}-{ukuran}. Tidak perlu nulis CSS custom untuk jarak antar elemen -- tinggal tempel kelasnya di HTML.
| Bagian | Kode | Contoh |
|---|---|---|
| property | m = margin, p = padding | m-3, p-2 |
| sisi | t=top, b=bottom, s=start(kiri), e=end(kanan), x=kiri&kanan, y=atas&bawah, (kosong)=4 sisi | mt-3, px-2, mb-0 |
| ukuran | 0, 1(.25rem), 2(.5rem), 3(1rem), 4(1.5rem), 5(3rem), auto(khusus margin) | mt-4, mx-auto |
<div class="card p-4 mb-3"> {{-- padding 1rem semua sisi, jarak bawah 1rem --}}
<h5 class="mb-2">Judul</h5> {{-- jarak bawah kecil ke teks berikutnya --}}
<p class="mb-0">Paragraf ini menempel ke elemen di bawahnya (mb-0).</p>
</div>
<div class="mx-auto" style="max-width: 400px;">Elemen ini di-center horizontal</div>
<div class="d-flex gap-2"> {{-- jarak antar item flex, tanpa margin manual --}}
<button class="btn btn-brand" type="button">Simpan</button>
<button class="btn btn-outline-secondary" type="button">Batal</button>
</div>
Card Component
Card adalah kotak konten serba-guna. Bagian umum: card-header, card-body (berisi card-title, card-subtitle, card-text), dan card-footer -- semuanya opsional, pakai sesuai kebutuhan.
<div class="card" style="max-width: 380px;">
<div class="card-header">Header Card</div>
<div class="card-body">
<h5 class="card-title">Judul Card</h5>
<h6 class="card-subtitle mb-2 text-secondary">Subjudul</h6>
<p class="card-text">Teks isi card, biasanya deskripsi singkat.</p>
<a href="#" class="btn btn-brand btn-sm">Aksi</a>
</div>
<div class="card-footer text-secondary small">Diperbarui 2 menit lalu</div>
</div>
Card di dalam grid (mis. daftar produk/post): bungkus tiap card dengan col-md-4, tambahkan h-100 pada card supaya tinggi antar card sejajar meski isinya beda panjang.
<div class="row g-3">
@foreach ($posts as $post)
<div class="col-md-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">{{ $post->title }}</h5>
<p class="card-text">{{ Str::limit($post->body, 80) }}</p>
</div>
</div>
</div>
@endforeach
</div>
Card A
Deskripsi pendek.
Card B
Deskripsi yang jauh lebih panjang dari card lain di sebelahnya, tapi tinggi tetap sejajar berkat h-100.
Card C
Deskripsi pendek.
Tabel (Table)
Semua varian ini tinggal ditumpuk di satu tag <table>, tidak saling menggantikan.
| Kelas | Fungsi |
|---|---|
| table-striped | Warna selang-seling tiap baris |
| table-bordered | Garis di semua sisi sel |
| table-hover | Highlight baris saat kursor lewat |
| table-borderless | Tanpa garis sama sekali |
| table-sm | Padding sel lebih rapat |
| table-dark / table-light | Tema gelap/terang untuk seluruh tabel atau thead |
| table-success, table-warning, table-danger, dst | Warna kontekstual di baris/sel tertentu (tr atau td) |
| table-responsive | Bungkus <table> agar bisa di-scroll horizontal di layar kecil |
<div class="table-responsive">
<table class="table table-striped table-hover table-bordered align-middle">
<caption>Daftar pengguna terbaru</caption>
<thead class="table-dark">
<tr>
<th>#</th>
<th>Nama</th>
<th>Role</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach ($users as $i => $user)
<tr class="{{ $user->status === 'pending' ? 'table-warning' : '' }}">
<td>{{ $i + 1 }}</td>
<td>{{ $user->nama }}</td>
<td>{{ $user->role }}</td>
<td>
<span class="badge text-bg-{{ $user->status === 'aktif' ? 'success' : 'secondary' }}">
{{ $user->status }}
</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
| # | Nama | Role | Status |
|---|---|---|---|
| 1 | Budi | Admin | aktif |
| 2 | Sari | Staff | pending |
| 3 | Andi | User | nonaktif |
Flexbox -- Alternatif Mengatur Posisi
Untuk posisi yang bukan grid kolom (mis. toolbar, header card, sejajarkan judul & tombol), pakai utility flex: d-flex mengaktifkan flexbox, justify-content-* mengatur sumbu horizontal (start/center/end/between/around), align-items-* mengatur sumbu vertikal (start/center/end), flex-wrap supaya item turun baris kalau sempit, dan gap-* untuk jarak antar item.
<div class="d-flex justify-content-between align-items-center bg-light border rounded p-3">
<span class="fw-semibold">Daftar Post</span>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-secondary" type="button">Filter</button>
<button class="btn btn-sm btn-brand" type="button">Tambah</button>
</div>
</div>
Form
form-control untuk input, form-label untuk label, is-invalid + invalid-feedback untuk menampilkan error validasi tepat di bawah field-nya.
<form method="POST">
@csrf
<div class="mb-3">
<label class="form-label" for="nama">Nama</label>
<input type="text" id="nama" name="nama"
class="form-control {{ errors()->has('nama') ? 'is-invalid' : '' }}"
value="{{ old('nama') }}">
@error('nama')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<button class="btn btn-brand" type="submit">Simpan</button>
</form>
Ringkasan Cepat Kelas Lain
| Kelas | Fungsi |
|---|---|
| btn btn-primary / btn btn-brand | Tombol biru bawaan / tombol warna khas Sakuci |
| btn-outline-*, btn-sm, btn-lg | Tombol garis tepi & ukuran |
| alert alert-success/danger/warning/info | Notifikasi berwarna |
| badge text-bg-* | Label kecil berwarna (status, jumlah) |
| shadow-sm, border-0, rounded-3 | Bayangan, hilangkan garis tepi, sudut membulat |
| text-secondary, fw-bold, small | Warna teks abu, tebal, ukuran kecil |
| navbar navbar-expand-lg | Navigation bar responsif |
| list-group, list-group-item | Daftar bertumpuk (mis. menu di dalam card) |
Database Setup
Edit .env:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=sakuci_belajar
DB_USERNAME=root
DB_PASSWORD=
Lalu buat database (lewat phpMyAdmin atau SQL):
CREATE DATABASE sakuci_belajar CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
Uji koneksi:
php sakuci db:check
Untuk SQLite (tanpa setup), cukup ubah .env:
DB_CONNECTION=sqlite
Session & Flash Message
Di controller atau view:
use Sakuci\Session;
// Simpan
Session::put('user_id', 123);
Session::put('cart', ['item1', 'item2']);
// Baca
$id = Session::get('user_id');
$cart = Session::get('cart', []); // Default []
// Cek ada
if (Session::has('user_id')) { ... }
// Hapus
Session::forget('user_id');
// Flash (sekali pakai, auto-hapus setelah ditampilkan)
return redirect('/')->with('success', 'Berhasil login!');
Di view:
{{ session('success') }} {{-- atau null --}}
@if (session('error'))
<div class="alert alert-danger">{{ session('error') }}</div>
@endif
Middleware
Middleware adalah filter yang menjalankan kode sebelum/sesudah request masuk ke controller. Daftarkan di config/app.php:
'middleware' => [
'auth' => App\Middleware\Authenticate::class,
'guest' => App\Middleware\RedirectIfAuthenticated::class,
'admin' => App\Middleware\AdminOnly::class,
],
Pakai di route:
Route::get('/dashboard', [DashboardController::class, 'index'])
->middleware('auth');
Route::group(['middleware' => 'admin'], function () {
Route::get('/admin', [AdminController::class, 'index']);
});
Buat middleware:
<?php
namespace App\Middleware;
use Sakuci\Http\Request;
use Sakuci\Middleware;
class MyMiddleware extends Middleware
{
public function handle(Request $request, \Closure $next): mixed
{
// Sebelum masuk controller
if (/* kondisi gagal */) {
abort(403, 'Tidak diizinkan');
}
$response = $next($request);
// Setelah controller selesai
return $response;
}
}
Multi-Role Login System
Instalasi baru hanya punya satu akun & satu role: admin. Role lain (staff, user, editor, dst) dibuat sendiri oleh admin lewat /admin/roles -- baru muncul di dropdown /admin/users setelah dibuat.
| Username | Password | Akses |
|---|---|---|
| admin | rahasia123 |
/admin
|
Tambah role baru: Login sebagai admin, buka /admin/roles, isi nama role. Dua hal dibuat otomatis: berkas middleware {Role}Only.php di app/Middleware (langsung terdaftar sebagai alias, pakai lewat Route::group(['middleware' => 'nama-role'], ...)), dan route group /nama-role di routes/web.php yang sudah dilindungi middleware tersebut.
Ganti nama / hapus role: Di /admin/roles, tiap role (selain admin) punya form ganti nama + toggle registrasi, dan tombol hapus. Middleware & route mengikuti otomatis; ganti nama juga memindahkan seluruh user pemilik role itu ke nama barunya. Role tidak bisa dihapus selama masih ada user yang memakainya.
Registrasi mandiri: Centang "Daftar" saat membuat/mengubah role supaya role itu muncul sebagai pilihan di halaman /register. Kalau tidak ada role yang dicentang, halaman /register otomatis mengarahkan kembali ke /login.
Tips & Trik
Helper global yang tersedia:
view('name', ['var' => 'value']) // Render view
route('name', ['id' => 1]) // Generate URL
redirect('/path') // Redirect
redirect(route('home'))
back() // Kembali ke halaman sebelumnya
old('field') // Nilai input sebelumnya
errors() // Object error dari validasi
session('key') // Baca session
dd($var) // Dump & die
abort(404, 'Not found') // Abort dengan status code
Eager loading (cegah N+1):
// Buruk: 11 query (1 posts + 10 comments)
foreach (Post::all() as $post) {
echo $post->comments;
}
// Bagus: 2 query (1 posts + 1 comments)
foreach (Post::with('comments')->get() as $post) {
echo $post->comments;
}
// Nested
Post::with('comments.user')->get();
Join data dari tabel lain:
User::leftJoin('profil', 'profil.user_id', '=', 'users.id')
->select('users.nama', 'profil.alamat')
->get();
Untuk pertanyaan atau kontribusi, kunjungi GitHub repository.