Most Laravel tutorials hand you working code and walk you through it. That's fine for seeing how the pieces fit, but it doesn't teach you much about what happens when you leave a field blank, type the wrong parameter name, or hesitate before running a command you don't fully trust yet.
So instead of following a tutorial, I built something small from scratch: Tiny Journal, a single-page journaling app. Write an entry, read it back, edit it, delete it. No auth, no tags, no search. Small enough to finish in a weekend, big enough to hit real Laravel concepts along the way.
This is the process I followed, in order, including the parts that broke.
What You'll Need
- Basic PHP knowledge (variables, functions, arrays)
- A local Laravel installation
- Familiarity with MVC at a conceptual level, you don't need Laravel experience specifically
Step 1: The Model and Migration
Everything starts with one model, Entry, and one migration:
php artisan make:model Entry -m
Schema::create('entries', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('content');
$table->timestamps();
});
php artisan migrate
Two fields, title and content, both required at the database level. That last detail matters more than it looks like it should, it's the reason the very first real bug in this project existed at all.
Step 2: Reading Entries (index and show)
With the table in place, the first working feature was just displaying entries:
public function index()
{
$entries = Entry::all();
return view('entries.index', ['entries' => $entries]);
}
public function show(Entry $entry)
{
return view('entries.show', ['entry' => $entry]);
}
show() already uses route model binding here, Laravel matches the {entry} placeholder in the route to the Entry $entry parameter and fetches the record automatically, no manual find() needed. I didn't fully appreciate this until later, when I tried writing edit() the long way and noticed the mismatch (more on that below).
Step 3: Creating Entries (create and store)
public function create()
{
return view('entries.create');
}
public function store(Request $request)
{
$entry = new Entry();
$entry->title = $request->title;
$entry->content = $request->content;
$entry->save();
return redirect('/');
}
This worked, right up until I submitted the form with the title left blank. What came back was a full-page database error, a NOT NULL constraint violation, straight from the migration in Step 1. The database was correctly enforcing a rule I'd written myself, just in the worst possible place, after the form had already submitted, with a stack trace instead of a sentence the user could act on.
The fix is validation, called before anything touches the database:
public function store(Request $request)
{
$request->validate([
'title' => 'required',
'content' => 'required',
]);
$entry = new Entry();
$entry->title = $request->title;
$entry->content = $request->content;
$entry->save();
return redirect('/');
}
validate() either lets the request through or stops it and redirects back to the form automatically. To actually display the error, loop over Laravel's error bag in the view:
@foreach ($errors->all() as $error)
<p>{{ $error }}</p>
@endforeach
One gotcha: $errors isn't a plain array you can loop over directly, it's a MessageBag, and you need ->all() to pull the message strings out. Loop over $errors itself and you get nothing, no error, no crash, just silence.
Step 4: Editing Entries (edit and update)
public function edit($id)
{
$entry = Entry::find($id);
return view('entries.edit', ['entry' => $entry]);
}
public function update(Request $request, Entry $entry)
{
$entry->title = $request->input('title');
$entry->content = $request->input('content');
$entry->save();
return redirect('/');
}
Two things surfaced here. First, the same blank-title crash from Step 3 was still possible on update(), since it had no validation yet. The fix is identical, just sequenced correctly, validate before writing anything to $entry, not after:
public function update(Request $request, Entry $entry)
{
$request->validate([
'title' => 'required',
'content' => 'required',
]);
$entry->title = $request->input('title');
$entry->content = $request->input('content');
$entry->save();
return redirect('/');
}
Second, comparing edit() and update() side by side made route model binding click. update() uses Entry $entry and never calls find(). edit() still used the manual $id + Entry::find($id) pattern. Once I rewrote edit() to match:
public function edit(Entry $entry)
{
return view('entries.edit', ['entry' => $entry]);
}
The rule became obvious: route model binding works whenever the URL contains an ID pointing at something that already exists. That's true for edit, update, and destroy. It's never true for store or index, there's no specific record in those URLs to bind to in the first place.
Step 5: A Subtler Bug, Old Input
After adding validation, something looked right for the wrong reason. Type a real title, leave content blank, submit. The page reloads, and the title field still shows what I typed. That looked like old input working. It wasn't, the title was just $entry->title, untouched, because validation stopped the save before anything reached the database.
The actual fix is the old() helper:
<input type="text" name="title" value="{{ old('title', $entry->title) }}">
<textarea name="content">{{ old('content', $entry->content) }}</textarea>
old('title', $entry->title) checks for leftover input from a failed submission first. If it exists, it wins. If the page opened fresh, it falls back to the entry's stored value. The real test: change the title, blank out the content, submit. If it's wired up correctly, your edited title survives the round trip instead of reverting.
Step 6: Deleting Entries, and Collapsing Six Routes Into One
public function destroy(Entry $entry)
{
$entry->delete();
return redirect('/');
}
By this point I had six hand-written routes, one per action:
Route::get('/entries', [EntryController::class, 'index']);
Route::get('/entries/create', [EntryController::class, 'create']);
Route::post('/entries', [EntryController::class, 'store']);
Route::get('/entries/{entry}/edit', [EntryController::class, 'edit']);
Route::put('/entries/{entry}', [EntryController::class, 'update']);
Route::delete('/entries/{entry}', [EntryController::class, 'destroy']);
Laravel collapses all six into one line:
Route::resource('entries', EntryController::class);
I sat on this for longer than I should have, convinced switching would force changes elsewhere. It didn't. The controller didn't change at all, only the routes file did. Route::resource() isn't generating new behavior, it's the same six routes, same names, same verbs, pre-assembled.
Step 7: Cleaning Up With a Shared Layout
By the time everything worked, all four views (index, create, edit, show) had their own full , , and `` tags, copy-pasted four times. A shared layout fixes this:
{{-- resources/views/layouts/app.blade.php --}}
<title>Tiny Journal</title>
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
@yield('content')
Each view shrinks to just its unique content:
{{-- resources/views/entries/index.blade.php --}}
@extends('layouts.app')
@section('content')
<h1>My Tiny Journal</h1>
@foreach ($entries as $entry)
<h2>{{ $entry->title }}</h2>
<p>{{ $entry->content }}</p>
<a href="/entries/{{ $entry->id }}">View</a>
<a href="/entries/{{ $entry->id }}/edit">Edit</a>
@endforeach
@endsection
Add a stylesheet link to the layout once, and every page picks it up automatically, no other file needs touching.
The Walls, Briefly
Looking back at the process above, the actual friction points were:
- A blank title crashing the app (Step 3), the database enforcing a rule at the wrong layer.
- **The same crash hiding in **
update()(Step 4), the same fix, just needing correct sequencing. - Route model binding clicking (Step 4), once I compared a method using it against one that wasn't.
- Old input looking like it worked before it actually did (Step 5), two different mechanisms that produced identical-looking output.
- **Hesitating before **
Route::resource()(Step 6), fear of a change that turned out to be safe. - Four duplicated page shells (Step 7), fixed once the repetition got annoying enough to notice.
None of these were individually hard. What was hard was noticing, in the moment, that a bug or a hesitation was pointing at a missing concept, not a missing line of code.
Where Tiny Journal Goes From Here
A few obvious next steps if you're building along:
- Flash messages after create, update, and delete, so the user gets confirmation instead of a silent redirect
- Stronger validation rules, minimum lengths, character limits, rather than just
required - Soft deletes, so "delete" doesn't mean permanently gone
If you're learning Laravel right now: build something small, break it on purpose, and read the error instead of pasting it into a search bar immediately. The framework usually already has an answer for whatever repetition or fragility you're feeling. You just have to hit the wall first to know which question to ask.
