A library information system is a classic must-know project, yet many beginners oversimplify it — for example building just a book CRUD without handling availability, due dates, and fines. But that is exactly where the real business logic lives. This tutorial builds a proper library in Laravel 11 with three core entities and a safe borrow-return flow.
1. Designing the Entities
We need three tables: books (book data plus number of copies), members, and loans (borrowing transactions). Key design: each title can have several copies, so availability is computed from total copies minus outstanding (not-yet-returned) loans.
php artisan make:model Book -m
php artisan make:model Member -m
php artisan make:model Loan -m
The books and members migrations
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 copies
$table->timestamps();
});
Schema::create('members', function (Blueprint $table) {
$table->id();
$table->string('code')->unique(); // membership number
$table->string('name');
$table->string('phone')->nullable();
$table->timestamps();
});
The loans migration
The loans table stores the borrow date, due date, and return date. As long as returned_at is still null, the book is considered on loan.
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->unsignedInteger('fine')->default(0);
$table->timestamps();
});
php artisan migrate
2. Models and Availability Check
On the Book model, we count how many copies are currently on loan, then availability = total copies minus those on loan. This prevents a book from being borrowed beyond its number of copies.
class Book extends Model
{
protected $fillable = ['isbn', 'title', 'author', 'copies'];
public function loans()
{
return $this->hasMany(Loan::class);
}
public function getOnLoanAttribute(): int
{
return $this->loans()->whereNull('returned_at')->count();
}
public function getAvailableAttribute(): int
{
return max(0, $this->copies - $this->on_loan);
}
}
The Loan model stores the business rules — loan duration and daily fine rate — as constants so they are easy to change in one place:
class Loan extends Model
{
const LOAN_DAYS = 7; // standard loan duration
const FINE_PER_DAY = 1000; // fine per late day
protected $fillable = ['book_id', 'member_id', 'borrowed_at', 'due_at', 'returned_at', 'fine'];
protected $casts = [
'borrowed_at' => 'date',
'due_at' => 'date',
'returned_at' => 'date',
];
}
3. The Borrowing Flow
When a member borrows, we check availability first, then create a transaction with an automatic due date. Use DB::transaction and lockForUpdate so two members cannot grab the last copy at the same time.
use App\Models\Loan;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
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->available < 1) {
abort(422, 'All copies of this book are currently on loan.');
}
Loan::create([
'book_id' => $book->id,
'member_id' => $data['member_id'],
'borrowed_at' => now(),
'due_at' => now()->addDays(Loan::LOAN_DAYS),
]);
});
return back()->with('success', 'Loan recorded successfully.');
}
4. The Return Flow and Fine Calculation
When a book is returned, we compare the return date with the due date. If late, the fine = number of late days multiplied by the daily rate. Note the diffInDays argument order so the lateness comes out positive.
public function returnBook(Loan $loan)
{
if ($loan->returned_at) {
return back()->with('error', 'This book has already been returned.');
}
$today = Carbon::today();
$fine = 0;
if ($today->greaterThan($loan->due_at)) {
$lateDays = $loan->due_at->diffInDays($today);
$fine = $lateDays * Loan::FINE_PER_DAY;
}
$loan->update([
'returned_at' => $today,
'fine' => $fine,
]);
return back()->with('success', $fine > 0
? "Book returned. Fine: Rp " . number_format($fine)
: 'Book returned on time.');
}
5. Listing Overdue Loans
Staff need to see who is still holding books past the due date. The query simply filters loans that are not yet returned and past their date:
$overdue = Loan::query()
->whereNull('returned_at')
->whereDate('due_at', '<', now())
->with(['book', 'member'])
->get();
foreach ($overdue as $loan) {
$lateDays = $loan->due_at->diffInDays(now());
// show $loan->member->name, $loan->book->title, $lateDays
}
Common Mistakes to Avoid
- Storing an "on loan" boolean on the book table: it fails to handle multiple copies. Compute availability from copies minus active loans.
- Skipping a transaction when borrowing: the last copy can be lent to two people at once.
- Wrong diffInDays order: make sure you measure from the due date to today so the fine is never negative.
With a three-entity structure and rules stored as constants, your library system is easy to extend: add loan renewals, a maximum number of books per member, or due-date email notifications without reworking the data model.