Dokumentasi

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>
Tampilan
col
col
col
col-md-6
col-md-6

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>
Tampilan
col-md-4 offset-md-4
A (order-3)
B (order-1)
C (order-2)
💡 Catatan: g-2 / g-3 di atas row itu "gutter" (jarak antar kolom). Tanpa itu kolom akan mepet satu sama lain.

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>
Tampilan
mb-3 → jarak ke bawah 1rem
mb-3 → jarak ke bawah 1rem
mb-0 → tanpa jarak
p-1
p-3
p-5
💡 Catatan: gap-1 s/d gap-5 dipakai di atas elemen ber-display flex/grid (mis. d-flex) untuk mengatur jarak antar anak secara otomatis, tanpa perlu margin manual di tiap item.

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>
Tampilan
Header Card
Judul Card
Subjudul

Teks isi card, biasanya deskripsi singkat.

Aksi

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>
Tampilan
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>
Tampilan
Daftar pengguna terbaru
#NamaRoleStatus
1BudiAdmin aktif
2SariStaff pending
3AndiUser 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>
Tampilan
Daftar Post

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>
Tampilan
Nama minimal 3 karakter.

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.