Web-based Library Information System

Many student projects stop at book CRUD: create, edit, delete, done. Once a real librarian uses it, the gaps appear. One title has three copies but the system only knows "borrowed"...

Web-based Library Information System

Many student projects stop at book CRUD: create, edit, delete, done. Once a real librarian uses it, the gaps appear. One title has three copies but the system only knows "borrowed" or "available", fines are calculated by hand, and nobody can see who is late returning books.

The real business logic of a library lives in copy availability, due dates, fines, and per-member limits. This tutorial builds the core of a library system in Laravel 11 (PHP 8.2+) with three entities, a borrow-and-return flow that is safe under concurrent requests, and automatic reminders through the scheduler.

Agree on the Business Rules First

Write the rules down before writing migrations. The values below are examples.

RuleExample valueStored in
Standard loan period14 daysLoan::LOAN_DAYS
Fine per late day$0.25Loan::FINE_PER_DAY (in cents)
Max active loans per member3 booksLoan::MAX_ACTIVE
RenewalOnce, before the due daterenewals column

Storing money as integer cents avoids floating-point rounding errors. If rules change often, move them to a settings table editable from an admin panel.

1. Entities and Migrations

We need books, members, and loans. Availability is not a column; it is the number of copies minus loans not yet returned.

Schema::create('books', function (Blueprint $table) {
    $table->id();
    $table->string('isbn')->unique()->nullable();
    $table->string('title');
    $table->string('author');
    $table->unsignedInteger('copies')->default(1);
    $table->timestamps();
});

Schema::create('members', function (Blueprint $table) {
    $table->id();
    $table->string('code')->unique();
    $table->string('name');
    $table->string('email')->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_cents')->default(0);
    $table->timestamps();

    $table->index(['book_id', 'returned_at']);
    $table->index(['member_id', 'returned_at']);
});

While returned_at is null, the loan is active. The composite indexes speed up the two most frequent questions: how many copies of this book are out, and how many books this member holds. For modeling help, see designing a simple ERD for your app.

2. Models and Relationships

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    = 14;
    const FINE_PER_DAY = 25; // cents
    const MAX_ACTIVE   = 3;

    protected $fillable = ['book_id', 'member_id', 'borrowed_at', 'due_at', 'returned_at', 'renewals', 'fine_cents'];

    protected function casts(): array
    {
        return ['borrowed_at' => 'date', 'due_at' => 'date', 'returned_at' => 'date'];
    }
}

Why a relationship instead of an accessor that runs a query? An accessor calling count() fires one query per book, so a catalog page with 50 books runs 50 extra queries. With a relationship you can use withCount:

$books = Book::withCount('activeLoans')->orderBy('title')->paginate(20);
// available = $book->copies - $book->active_loans_count

More on avoiding N+1 problems in optimizing Laravel Eloquent query performance.

3. A Safe Borrow Flow

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, 'All copies are currently on loan.');
        }

        $active = Loan::where('member_id', $data['member_id'])
            ->whereNull('returned_at')->count();

        if ($active >= Loan::MAX_ACTIVE) {
            abort(422, 'Member has reached the loan limit.');
        }

        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', 'Loan recorded.');
}

The row lock makes a second request wait until the first transaction commits, then re-count with fresh data. Without it, two desks could both see "one copy left" and lend it twice.

4. Returns and Fines

public function returnBook(Loan $loan)
{
    if ($loan->returned_at) {
        return back()->with('error', 'Already returned.');
    }

    $today = today();
    $lateDays = $today->greaterThan($loan->due_at)
        ? (int) $loan->due_at->diffInDays($today)
        : 0;

    $loan->update([
        'returned_at' => $today,
        'fine_cents'  => $lateDays * Loan::FINE_PER_DAY,
    ]);

    return back()->with('success', 'Book returned.');
}

In Carbon 3, which ships with Laravel 11, diffInDays is signed and returns a float. Reversing the argument order gives a negative number, so keep the order shown and cast to int.

5. Renewals

public function renew(Loan $loan)
{
    abort_if($loan->returned_at !== null, 422, 'Loan is closed.');
    abort_if($loan->renewals >= 1, 422, 'Only one renewal allowed.');
    abort_if(today()->greaterThan($loan->due_at), 422, 'Loan is overdue.');

    $loan->update([
        'due_at'   => $loan->due_at->copy()->addDays(Loan::LOAN_DAYS),
        'renewals' => $loan->renewals + 1,
    ]);

    return back()->with('success', 'Due date extended.');
}

Blocking renewals after the due date stops members from using a renewal to dodge fines.

6. Overdue List and Reminders

$overdue = Loan::with(['book', 'member'])
    ->whereNull('returned_at')
    ->whereDate('due_at', '<', today())
    ->orderBy('due_at')
    ->get();

For day-before reminders, create an Artisan command and register it in routes/console.php, which replaces the old console kernel in Laravel 11:

use Illuminate\Support\Facades\Schedule;

Schedule::command('loans:remind')->dailyAt('08:00');

Inside the command, select active loans with whereDate('due_at', today()->addDay()) and queue an email for each. Your server cron must run php artisan schedule:run every minute.

7. Reports for the Library Manager

Because loan history is never deleted, reports need no extra tables. For example, the ten most borrowed titles in the last 30 days help decide which books deserve extra copies:

$popular = Book::withCount(['loans' => fn ($q) => $q->where('borrowed_at', '>=', today()->subDays(30))])
    ->orderByDesc('loans_count')
    ->limit(10)
    ->get();

Other common requests are total fines per month, grouped by the month of returned_at, and the members who are most often late.

8. Test the Rules, Not Just the Pages

The fine and limit logic is exactly where regressions hide. A few feature tests with Pest or PHPUnit pay off quickly: borrowing when no copies remain must return 422, a member at the limit must be refused, and a return three days late must store three days of fines. Use $this->travelTo() to move the clock forward instead of editing dates by hand, so the tests stay readable and deterministic.

Common Mistakes and Fixes

  • A boolean "borrowed" flag on books breaks for multiple copies. Derive availability from copies minus active loans.
  • Borrowing without a transaction and lock lets the last copy go out twice.
  • Negative or fractional fines come from argument order and Carbon 3 floats.
  • Deleting old loans destroys history needed for reports and fine disputes.

Pre-Launch Checklist

  • Loan period, fine, and limits approved by the library manager.
  • Concurrent borrowing of the last copy tested.
  • Fines checked for on-time, one day late, and very late returns.
  • Separate roles for desk staff and administrators.
  • Scheduler cron active and daily database backups running.

This structure extends cleanly to reservations, barcode member cards, or popular-title reports. If the library is part of a larger campus platform, the same patterns fit into a modern school information system.

Yudhi
Written by
Yudhi
Founder & Lead Developer, GudangCode

Yudhi is the founder of GudangCode and a Laravel developer who has built dozens of ready-to-use business information systems — from POS and HRIS to management apps. He writes guides and articles on GudangCode to help Indonesian developers run, understand, and deploy Laravel source code correctly.

LaravelPHPMySQLSistem Informasi Bisnis See all articles by Yudhi
Want the full source code & apps?

Sign up free to download ready-to-use business applications, information systems, and Laravel source code.

Sign Up Free & Download
Sistem Informasi Perpustakaan Sistem Informasi
Share this article
Back to Blog
📚 Free Learning Hub

Learn Coding for Free at DhieCoderWeb

Explore Laravel, PHP, JavaScript tutorials, source code, web development guides, and practical programming tips.

DhieCoderWeb
100+
Tutorials
Free
Learning
SEO
Tips
Visit Dhiecoderweb.com →

Get Full Access Now!

Join our membership and unlock exclusive access to all premium features. Fast, easy, and ready to use instantly.

Join Membership Now