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.
| Rule | Example value | Stored in |
|---|---|---|
| Standard loan period | 14 days | Loan::LOAN_DAYS |
| Fine per late day | $0.25 | Loan::FINE_PER_DAY (in cents) |
| Max active loans per member | 3 books | Loan::MAX_ACTIVE |
| Renewal | Once, before the due date | renewals 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.