This metrics tool terrifies bad developers

Start free trial
Community Article
Community articles are authored by SitePoint Premium contributors. Content is screened before publication, and SitePoint reserves the right to moderate or remove articles that violate our guidelines. Views expressed are those of the authors and do not necessarily reflect those of SitePoint.

I Built a Tiny Journal App to Learn Laravel. Here's the Process, Step by Step.

Share this article

I Built a Tiny Journal App to Learn Laravel. Here's the Process, Step by Step.
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

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(&#x27;title&#x27;, $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(&#x27;css/app.css&#x27;) }}">


    @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:

  1. A blank title crashing the app (Step 3), the database enforcing a rule at the wrong layer.
  2. **The same crash hiding in **update() (Step 4), the same fix, just needing correct sequencing.
  3. Route model binding clicking (Step 4), once I compared a method using it against one that wasn't.
  4. Old input looking like it worked before it actually did (Step 5), two different mechanisms that produced identical-looking output.
  5. **Hesitating before **Route::resource() (Step 6), fear of a change that turned out to be safe.
  6. 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.

© 2000 – 2026 SitePoint Pty. Ltd.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.