Banyak tugas akhir dan proyek sekolah berhenti di CRUD buku: tambah, ubah, hapus, selesai. Begitu dipakai petugas sungguhan, masalah muncul. Satu judul punya tiga eksemplar tapi sistem hanya mengenal "dipinjam" atau "tersedia", denda dihitung manual, dan tidak ada yang tahu siapa saja yang terlambat mengembalikan buku.
Logika bisnis perpustakaan justru ada di ketersediaan eksemplar, jatuh tempo, denda, dan batas pinjam per anggota. Tutorial ini membangun inti sistem perpustakaan di Laravel 11 (PHP 8.2+) dengan tiga entitas, alur pinjam-kembali yang aman dari rebutan data, serta pengingat otomatis lewat scheduler.
Aturan Bisnis yang Perlu Disepakati Dulu
Sebelum menulis migration, tuliskan aturan main perpustakaan. Nilai di tabel ini adalah contoh; sesuaikan dengan kebijakan perpustakaan Anda.
| Aturan | Contoh nilai | Disimpan di |
|---|---|---|
| Lama pinjam standar | 7 hari | Konstanta Loan::LOAN_DAYS |
| Denda per hari terlambat | Rp1.000 | Konstanta Loan::FINE_PER_DAY |
| Maksimal buku aktif per anggota | 3 buku | Konstanta Loan::MAX_ACTIVE |
| Perpanjangan | 1 kali, sebelum jatuh tempo | Kolom renewals |
Menyimpan aturan di satu tempat membuat perubahan kebijakan cukup diubah sekali. Jika aturan sering berubah, pindahkan ke tabel settings agar bisa diatur dari panel admin.
1. Merancang Entitas dan Migration
Kita butuh tiga tabel: books (judul beserta jumlah eksemplar), members (anggota), dan loans (transaksi peminjaman). Kunci desainnya: ketersediaan tidak disimpan sebagai kolom, melainkan dihitung dari jumlah eksemplar dikurangi peminjaman yang belum kembali.
php artisan make:model Book -m
php artisan make:model Member -m
php artisan make:model Loan -m
Schema::create('books', function (Blueprint $table) {
$table->id();
$table->string('isbn')->unique()->nullable();
$table->string('title');
$table->string('author');
$table->unsignedInteger('copies')->default(1); // total eksemplar
$table->timestamps();
});
Schema::create('members', function (Blueprint $table) {
$table->id();
$table->string('code')->unique(); // nomor anggota
$table->string('name');
$table->string('phone')->nullable();
$table->timestamps();
});
Schema::create('loans', function (Blueprint $table) {
$table->id();
$table->foreignId('book_id')->constrained();
$table->foreignId('member_id')->constrained();
$table->date('borrowed_at');
$table->date('due_at');
$table->date('returned_at')->nullable();
$table->unsignedTinyInteger('renewals')->default(0);
$table->unsignedInteger('fine')->default(0);
$table->timestamps();
$table->index(['book_id', 'returned_at']);
$table->index(['member_id', 'returned_at']);
});
Selama returned_at masih null, peminjaman dianggap aktif. Dua indeks gabungan di akhir mempercepat query yang paling sering dijalankan: "berapa eksemplar buku ini yang sedang dipinjam" dan "berapa buku yang sedang dipinjam anggota ini". Untuk gambaran relasi antar tabel, lihat juga merancang ERD sederhana untuk aplikasi.
2. Model dan Relasi
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Book extends Model
{
protected $fillable = ['isbn', 'title', 'author', 'copies'];
public function loans(): HasMany
{
return $this->hasMany(Loan::class);
}
public function activeLoans(): HasMany
{
return $this->loans()->whereNull('returned_at');
}
}
class Loan extends Model
{
const LOAN_DAYS = 7;
const FINE_PER_DAY = 1000;
const MAX_ACTIVE = 3;
protected $fillable = ['book_id', 'member_id', 'borrowed_at', 'due_at', 'returned_at', 'renewals', 'fine'];
protected function casts(): array
{
return [
'borrowed_at' => 'date',
'due_at' => 'date',
'returned_at' => 'date',
];
}
public function book(): BelongsTo { return $this->belongsTo(Book::class); }
public function member(): BelongsTo { return $this->belongsTo(Member::class); }
}
Kenapa relasi activeLoans dan bukan accessor yang menjalankan query? Accessor yang memanggil $this->loans()->count() akan menjalankan satu query per buku. Di halaman katalog berisi 50 buku, itu berarti 50 query tambahan. Dengan relasi, kita bisa memakai withCount sehingga semuanya dihitung dalam satu query:
$books = Book::withCount('activeLoans')->orderBy('title')->paginate(20);
// di Blade: {{ $book->copies - $book->active_loans_count }} tersedia
Teknik menghindari N+1 seperti ini dibahas lebih lengkap di optimasi performa query Eloquent Laravel.
3. Alur Peminjaman yang Aman
Saat anggota meminjam, ada tiga pemeriksaan: buku masih punya eksemplar, anggota belum mencapai batas, dan semuanya terjadi atomik. DB::transaction bersama lockForUpdate mencegah dua petugas meminjamkan eksemplar terakhir kepada dua anggota pada detik yang sama.
use App\Models\Book;
use App\Models\Loan;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
public function borrow(Request $request)
{
$data = $request->validate([
'book_id' => 'required|exists:books,id',
'member_id' => 'required|exists:members,id',
]);
DB::transaction(function () use ($data) {
$book = Book::lockForUpdate()->findOrFail($data['book_id']);
if ($book->activeLoans()->count() >= $book->copies) {
abort(422, 'Semua eksemplar buku ini sedang dipinjam.');
}
$active = Loan::where('member_id', $data['member_id'])
->whereNull('returned_at')
->count();
if ($active >= Loan::MAX_ACTIVE) {
abort(422, 'Anggota sudah mencapai batas peminjaman.');
}
Loan::create([
'book_id' => $book->id,
'member_id' => $data['member_id'],
'borrowed_at' => today(),
'due_at' => today()->addDays(Loan::LOAN_DAYS),
]);
});
return back()->with('success', 'Peminjaman berhasil dicatat.');
}
Kunci baris buku membuat permintaan kedua menunggu sampai transaksi pertama selesai, lalu menghitung ulang dengan data terbaru. Tanpa kunci, keduanya bisa membaca "1 eksemplar tersisa" bersamaan.
4. Pengembalian dan Perhitungan Denda
public function returnBook(Loan $loan)
{
if ($loan->returned_at) {
return back()->with('error', 'Buku ini sudah dikembalikan.');
}
$today = today();
$lateDays = $today->greaterThan($loan->due_at)
? (int) $loan->due_at->diffInDays($today)
: 0;
$loan->update([
'returned_at' => $today,
'fine' => $lateDays * Loan::FINE_PER_DAY,
]);
return back()->with('success', $lateDays > 0
? 'Buku dikembalikan. Denda: Rp ' . number_format($loan->fine, 0, ',', '.')
: 'Buku dikembalikan tepat waktu.');
}
Perhatikan dua detail. Pertama, urutan $loan->due_at->diffInDays($today): di Carbon 3 (dipakai Laravel 11), hasil diffInDays bertanda, sehingga urutan terbalik menghasilkan angka negatif. Kedua, Carbon 3 mengembalikan float, jadi kita ubah ke int agar denda tidak berupa pecahan.
5. Perpanjangan Pinjam
Aturan contoh: perpanjangan hanya sekali dan hanya sebelum jatuh tempo, agar anggota tidak memakai perpanjangan untuk menghindari denda.
public function renew(Loan $loan)
{
abort_if($loan->returned_at !== null, 422, 'Peminjaman sudah selesai.');
abort_if($loan->renewals >= 1, 422, 'Perpanjangan hanya boleh satu kali.');
abort_if(today()->greaterThan($loan->due_at), 422, 'Sudah lewat jatuh tempo.');
$loan->update([
'due_at' => $loan->due_at->copy()->addDays(Loan::LOAN_DAYS),
'renewals' => $loan->renewals + 1,
]);
return back()->with('success', 'Jatuh tempo diperpanjang.');
}
6. Daftar Terlambat dan Pengingat Otomatis
Petugas perlu daftar siapa saja yang masih menahan buku melewati jatuh tempo:
$overdue = Loan::with(['book', 'member'])
->whereNull('returned_at')
->whereDate('due_at', '<', today())
->orderBy('due_at')
->get();
Untuk pengingat H-1, buat command lalu daftarkan di routes/console.php. Di Laravel 11, jadwal tidak lagi ditulis di Kernel.php:
use Illuminate\Support\Facades\Schedule;
Schedule::command('loans:remind')->dailyAt('08:00');
Di dalam command, ambil peminjaman aktif dengan whereDate('due_at', today()->addDay()) lalu kirim email atau pesan. Jika pengirimannya lambat, lempar ke queue. Pastikan cron server menjalankan php artisan schedule:run setiap menit.
7. Laporan untuk Pengelola Perpustakaan
Data peminjaman yang tersimpan rapi bisa langsung diolah menjadi laporan. Contoh: sepuluh judul paling sering dipinjam dalam 30 hari terakhir, berguna untuk memutuskan judul mana yang perlu ditambah eksemplarnya.
$popular = Book::withCount(['loans' => fn ($q) => $q->where('borrowed_at', '>=', today()->subDays(30))])
->orderByDesc('loans_count')
->limit(10)
->get();
Laporan lain yang sering diminta: total denda per bulan (sum('fine') dikelompokkan per bulan returned_at) dan anggota yang paling sering terlambat. Semua bisa dibuat tanpa tabel tambahan karena riwayat peminjaman tidak pernah dihapus.
Kesalahan Umum dan Perbaikannya
- Kolom boolean "dipinjam" di tabel buku. Gagal untuk judul dengan banyak eksemplar. Hitung dari
copiesdikurangi peminjaman aktif. - Meminjam tanpa transaksi dan lock. Eksemplar terakhir bisa keluar dua kali. Bungkus dengan
DB::transactiondanlockForUpdate. - Denda negatif atau pecahan. Periksa urutan argumen
diffInDaysdan cast ke integer. - Menghapus data peminjaman lama. Riwayat dibutuhkan untuk laporan dan sengketa denda. Simpan, lalu filter di tampilan.
- Accessor yang menjalankan query di halaman daftar. Ganti dengan
withCount.
Checklist Sebelum Dipakai Petugas
- Aturan lama pinjam, denda, dan batas pinjam disetujui pengelola perpustakaan.
- Peminjaman bersamaan untuk eksemplar terakhir sudah diuji.
- Denda tepat waktu, terlambat satu hari, dan terlambat lama sudah dicek.
- Hak akses dibedakan antara petugas dan admin.
- Cron untuk scheduler aktif di server.
- Backup database harian berjalan.
Struktur ini mudah dikembangkan menjadi reservasi buku, kartu anggota dengan barcode, atau laporan buku terpopuler tanpa merombak tabel. Jika perpustakaan ini bagian dari sistem yang lebih besar, pola yang sama bisa digabungkan dengan modul di sistem informasi sekolah modern.