{"id":763309,"date":"2026-06-27T08:07:09","date_gmt":"2026-06-27T08:07:09","guid":{"rendered":"https:\/\/www.newsbeep.com\/au\/763309\/"},"modified":"2026-06-27T08:07:09","modified_gmt":"2026-06-27T08:07:09","slug":"i-built-a-tiny-journal-app-to-learn-laravel-heres-the-process-step-by-step","status":"publish","type":"post","link":"https:\/\/www.newsbeep.com\/au\/763309\/","title":{"rendered":"I Built a Tiny Journal App to Learn Laravel. Here&#8217;s the Process, Step by Step."},"content":{"rendered":"<p>Most Laravel tutorials hand you working code and walk you through it. That&#8217;s fine for seeing how the pieces fit, but it doesn&#8217;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&#8217;t fully trust yet.<\/p>\n<p>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.<\/p>\n<p>This is the process I followed, in order, including the parts that broke.<\/p>\n<p>What You&#8217;ll Need<\/p>\n<p>Basic PHP knowledge (variables, functions, arrays)<br \/>\nA local Laravel installation<br \/>\nFamiliarity with MVC at a conceptual level, you don&#8217;t need Laravel experience specifically<\/p>\n<p>Step 1: The Model and Migration<\/p>\n<p>Everything starts with one model, Entry, and one migration:<\/p>\n<p>php artisan make:model Entry -m<\/p>\n<p>Schema::create(&#8216;entries&#8217;, function (Blueprint $table) {<br \/>\n    $table-&gt;id();<br \/>\n    $table-&gt;string(&#8216;title&#8217;);<br \/>\n    $table-&gt;text(&#8216;content&#8217;);<br \/>\n    $table-&gt;timestamps();<br \/>\n});<\/p>\n<p>php artisan migrate<\/p>\n<p>Two fields, title and content, both required at the database level. That last detail matters more than it looks like it should, it&#8217;s the reason the very first real bug in this project existed at all.<\/p>\n<p>Step 2: Reading Entries (index and show)<\/p>\n<p>With the table in place, the first working feature was just displaying entries:<\/p>\n<p>public function index()<br \/>\n{<br \/>\n    $entries = Entry::all();<br \/>\n    return view(&#8216;entries.index&#8217;, [&#8216;entries&#8217; =&gt; $entries]);<br \/>\n}<\/p>\n<p>public function show(Entry $entry)<br \/>\n{<br \/>\n    return view(&#8216;entries.show&#8217;, [&#8216;entry&#8217; =&gt; $entry]);<br \/>\n}<\/p>\n<p>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&#8217;t fully appreciate this until later, when I tried writing edit() the long way and noticed the mismatch (more on that below).<\/p>\n<p>Step 3: Creating Entries (create and store)<br \/>\npublic function create()<br \/>\n{<br \/>\n    return view(&#8216;entries.create&#8217;);<br \/>\n}<\/p>\n<p>public function store(Request $request)<br \/>\n{<br \/>\n    $entry = new Entry();<br \/>\n    $entry-&gt;title = $request-&gt;title;<br \/>\n    $entry-&gt;content = $request-&gt;content;<br \/>\n    $entry-&gt;save();<\/p>\n<p>    return redirect(&#8216;\/&#8217;);<br \/>\n}<\/p>\n<p>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&#8217;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.<\/p>\n<p>The fix is validation, called before anything touches the database:<\/p>\n<p>public function store(Request $request)<br \/>\n{<br \/>\n    $request-&gt;validate([<br \/>\n        &#8216;title&#8217; =&gt; &#8216;required&#8217;,<br \/>\n        &#8216;content&#8217; =&gt; &#8216;required&#8217;,<br \/>\n    ]);<\/p>\n<p>    $entry = new Entry();<br \/>\n    $entry-&gt;title = $request-&gt;title;<br \/>\n    $entry-&gt;content = $request-&gt;content;<br \/>\n    $entry-&gt;save();<\/p>\n<p>    return redirect(&#8216;\/&#8217;);<br \/>\n}<\/p>\n<p>validate() either lets the request through or stops it and redirects back to the form automatically. To actually display the error, loop over Laravel&#8217;s error bag in the view:<\/p>\n<p>@foreach ($errors-&gt;all() as $error)<\/p>\n<p>{{ $error }}<\/p>\n<p>@endforeach<\/p>\n<p>One gotcha: $errors isn&#8217;t a plain array you can loop over directly, it&#8217;s a MessageBag, and you need -&gt;all() to pull the message strings out. Loop over $errors itself and you get nothing, no error, no crash, just silence.<\/p>\n<p>Step 4: Editing Entries (edit and update)<br \/>\npublic function edit($id)<br \/>\n{<br \/>\n    $entry = Entry::find($id);<br \/>\n    return view(&#8216;entries.edit&#8217;, [&#8216;entry&#8217; =&gt; $entry]);<br \/>\n}<\/p>\n<p>public function update(Request $request, Entry $entry)<br \/>\n{<br \/>\n    $entry-&gt;title = $request-&gt;input(&#8216;title&#8217;);<br \/>\n    $entry-&gt;content = $request-&gt;input(&#8216;content&#8217;);<br \/>\n    $entry-&gt;save();<\/p>\n<p>    return redirect(&#8216;\/&#8217;);<br \/>\n}<\/p>\n<p>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:<\/p>\n<p>public function update(Request $request, Entry $entry)<br \/>\n{<br \/>\n    $request-&gt;validate([<br \/>\n        &#8216;title&#8217; =&gt; &#8216;required&#8217;,<br \/>\n        &#8216;content&#8217; =&gt; &#8216;required&#8217;,<br \/>\n    ]);<\/p>\n<p>    $entry-&gt;title = $request-&gt;input(&#8216;title&#8217;);<br \/>\n    $entry-&gt;content = $request-&gt;input(&#8216;content&#8217;);<br \/>\n    $entry-&gt;save();<\/p>\n<p>    return redirect(&#8216;\/&#8217;);<br \/>\n}<\/p>\n<p>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:<\/p>\n<p>public function edit(Entry $entry)<br \/>\n{<br \/>\n    return view(&#8216;entries.edit&#8217;, [&#8216;entry&#8217; =&gt; $entry]);<br \/>\n}<\/p>\n<p>The rule became obvious: route model binding works whenever the URL contains an ID pointing at something that already exists. That&#8217;s true for edit, update, and destroy. It&#8217;s never true for store or index, there&#8217;s no specific record in those URLs to bind to in the first place.<\/p>\n<p>Step 5: A Subtler Bug, Old Input<\/p>\n<p>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&#8217;t, the title was just $entry-&gt;title, untouched, because validation stopped the save before anything reached the database.<\/p>\n<p>The actual fix is the old() helper:<\/p>\n<p>title) }}&#8221;&gt;<br \/>\n{{ old(&#8216;content&#8217;, $entry-&gt;content) }}<\/p>\n<p>old(&#8216;title&#8217;, $entry-&gt;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&#8217;s stored value. The real test: change the title, blank out the content, submit. If it&#8217;s wired up correctly, your edited title survives the round trip instead of reverting.<\/p>\n<p>Step 6: Deleting Entries, and Collapsing Six Routes Into One<br \/>\npublic function destroy(Entry $entry)<br \/>\n{<br \/>\n    $entry-&gt;delete();<br \/>\n    return redirect(&#8216;\/&#8217;);<br \/>\n}<\/p>\n<p>By this point I had six hand-written routes, one per action:<\/p>\n<p>Route::get(&#8216;\/entries&#8217;, [EntryController::class, &#8216;index&#8217;]);<br \/>\nRoute::get(&#8216;\/entries\/create&#8217;, [EntryController::class, &#8216;create&#8217;]);<br \/>\nRoute::post(&#8216;\/entries&#8217;, [EntryController::class, &#8216;store&#8217;]);<br \/>\nRoute::get(&#8216;\/entries\/{entry}\/edit&#8217;, [EntryController::class, &#8216;edit&#8217;]);<br \/>\nRoute::put(&#8216;\/entries\/{entry}&#8217;, [EntryController::class, &#8216;update&#8217;]);<br \/>\nRoute::delete(&#8216;\/entries\/{entry}&#8217;, [EntryController::class, &#8216;destroy&#8217;]);<\/p>\n<p>Laravel collapses all six into one line:<\/p>\n<p>Route::resource(&#8216;entries&#8217;, EntryController::class);<\/p>\n<p>I sat on this for longer than I should have, convinced switching would force changes elsewhere. It didn&#8217;t. The controller didn&#8217;t change at all, only the routes file did. Route::resource() isn&#8217;t generating new behavior, it&#8217;s the same six routes, same names, same verbs, pre-assembled.<\/p>\n<p>Step 7: Cleaning Up With a Shared Layout<\/p>\n<p>By the time everything worked, all four views (index, create, edit, show) had their own full , , and &#8220; tags, copy-pasted four times. A shared layout fixes this:<\/p>\n<p>{{&#8211; resources\/views\/layouts\/app.blade.php &#8211;}}<\/p>\n<p>    Tiny Journal<\/p>\n<p>    @yield(&#8216;content&#8217;)<\/p>\n<p>Each view shrinks to just its unique content:<\/p>\n<p>{{&#8211; resources\/views\/entries\/index.blade.php &#8211;}}<br \/>\n@extends(&#8216;layouts.app&#8217;)<\/p>\n<p>@section(&#8216;content&#8217;)<\/p>\n<p>    @foreach ($entries as $entry)<br \/>\n        {{ $entry-&gt;title }}<\/p>\n<p>{{ $entry-&gt;content }}<\/p>\n<p>        <a href=\"&quot;\/entries\/{{\">id }}&#8221;&gt;View<\/a><br \/>\n        <a href=\"&quot;\/entries\/{{\">id }}\/edit&#8221;&gt;Edit<\/a><br \/>\n    @endforeach<br \/>\n@endsection<\/p>\n<p>Add a stylesheet link to the layout once, and every page picks it up automatically, no other file needs touching.<\/p>\n<p>The Walls, Briefly<\/p>\n<p>Looking back at the process above, the actual friction points were:<\/p>\n<p>A blank title crashing the app (Step 3), the database enforcing a rule at the wrong layer.<br \/>\n**The same crash hiding in **update() (Step 4), the same fix, just needing correct sequencing.<br \/>\nRoute model binding clicking (Step 4), once I compared a method using it against one that wasn&#8217;t.<br \/>\nOld input looking like it worked before it actually did (Step 5), two different mechanisms that produced identical-looking output.<br \/>\n**Hesitating before **Route::resource() (Step 6), fear of a change that turned out to be safe.<br \/>\nFour duplicated page shells (Step 7), fixed once the repetition got annoying enough to notice.<\/p>\n<p>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.<\/p>\n<p>Where Tiny Journal Goes From Here<\/p>\n<p>A few obvious next steps if you&#8217;re building along:<\/p>\n<p>Flash messages after create, update, and delete, so the user gets confirmation instead of a silent redirect<br \/>\nStronger validation rules, minimum lengths, character limits, rather than just required<br \/>\nSoft deletes, so &#8220;delete&#8221; doesn&#8217;t mean permanently gone<\/p>\n<p>If you&#8217;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&#8217;re feeling. You just have to hit the wall first to know which question to ask.<\/p>\n","protected":false},"excerpt":{"rendered":"Most Laravel tutorials hand you working code and walk you through it. That&#8217;s fine for seeing how the&hellip;\n","protected":false},"author":2,"featured_media":763310,"comment_status":"","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[6],"tags":[64,63,105],"class_list":["post-763309","post","type-post","status-publish","format-standard","has-post-thumbnail","category-technology","tag-au","tag-australia","tag-technology"],"_links":{"self":[{"href":"https:\/\/www.newsbeep.com\/au\/wp-json\/wp\/v2\/posts\/763309","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.newsbeep.com\/au\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.newsbeep.com\/au\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.newsbeep.com\/au\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/www.newsbeep.com\/au\/wp-json\/wp\/v2\/comments?post=763309"}],"version-history":[{"count":0,"href":"https:\/\/www.newsbeep.com\/au\/wp-json\/wp\/v2\/posts\/763309\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.newsbeep.com\/au\/wp-json\/wp\/v2\/media\/763310"}],"wp:attachment":[{"href":"https:\/\/www.newsbeep.com\/au\/wp-json\/wp\/v2\/media?parent=763309"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.newsbeep.com\/au\/wp-json\/wp\/v2\/categories?post=763309"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.newsbeep.com\/au\/wp-json\/wp\/v2\/tags?post=763309"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}