
Late Night Refactors #2: Controller to Livewire Components
— 11 min read
Do you see outside? The day has fallen, and the dark hours have once again swallowed the sun! The time has come once more to heed that call, to roll up our sleeves and embark upon another Late Night Refactor adventure!! 🧙♂️
Dramatic much?
So yeah, back to the main program! In this Late Night Refactor (LNR) session, I'll be tackling a doozy of a controller and working to split it up into Livewire Components. Now, generally speaking, a Laravel Controller should be lean and mean. Ideally, it should stick up to the 7 RESTful methods:
index
create
store
show
edit
update
destroy
But the controller I discovered, BlogController
, had turned into what I affectionately call an "index mapper". I had created multiple index methods like:
indexCategoryPosts()
indexTagPosts()
indexUserFeedPosts()
indexUserPosts()
listAllCategories()
- Oh, and
show()
snuck in there too!
At the time, it seemed like a clever approach - keep all those index-y things together under one roof. But as I revisited this code with fresh eyes, it became clear that this pattern had 2 main downsides:
- Crowded Much?: By piling on the index methods ( and the
show()
method ), the controller had become bloated and harder to navigate. Each additional index method diluted the focus of the class. - Reusablen't: Tying multiple index queries to the
BlogController
made them harder to reuse in other contexts. What if I needed that snazzyindexUserFeedPosts()
logic somewhere else?
Livewire to the rescue! 💪
PostShowComponent
At first, I decided on a folder structure that would go with a somewhat Domain Driven like approach, since Sudorealm is a Blog Platform, I would separate the Livewire Components into two Domains, Blog and Dashboard, therefore the show()
method of the Controller would now transform to:
App\Http\Livewire\Blog\Post\PostShowComponent.php
This component and the equivalent view will be created by running this command:
php artisan livewire:make Blog/Post/PostShowComponent // or sail artisan ...
Before blindly copy-pasting the show()
method's code into the component I took a step back to thoroughly examine the method's contents. I wanted to ensure that I fully understood and agreed with all the logic it contained.
And shocker! I didn't! Let me show you why:
public function show(Post $post) 💥 // Route model binding ignored
{
$post = Post::query() 💥 // Quering same Post Twice
->with('category') 💥 //I can write one with instead of 4
->with('user') 💥 // Loading the entire user model
->with('tags')
->with(['affiliates' => function ($query): void {
$query->byActivated()->with('affiliate_category');
}])
->addSelect([
'crowned_by_user' => Crown::select('id')
->where('user_id', auth()->id())
->whereColumn('post_id', 'posts.id'),
])
->withCount('crowns', 'affiliates')
->findOrFail($post->id);
💥 // This entire logic belongs in the PostPolicy
if ($post->isPublished || $post->user_id === Auth::id()) {
return view('blog.show', [
'post' => $post,
]);
}
abort('403', 'We are not ready yet for you to see this. coming soon😋');
return null; 💥 // This will never run, it's unreachable code
}
Therefore I see that I'll have to continue with the following actions:
- Create
PostPolicy@view
method with logic for when a user can view a post. - Pass new logic to component, and frontend to
post-show-component.blade.php
- Delete unused files:
PostShow.php
post-show.blade.php
show.blade.php
Fun fact: this file was already callingPostShow.php
Livewire component. We're just climbing the abstraction ladder a bit.
Refactor Result
By implementing the Livewire Layout Components I can call the component from web.php
like so:
web.php
// Before
- Route::get('/{post:slug}', [BlogController::class, 'show'])->name('post.show');
//After
+ Route::get('/{slug}', PostShowComponent::class)->name('post.show');
PostPolicy.php
public function view(?User $user, Post $post): bool
{
if ($post->isPublished) {
return true;
}
return $user !== null && $post->user_id === $user->id;
}
Here, I ensure a user can view a post if it's published or the user is the author. Hmm... here a function like
$post->isAuthoredBy($user->id)
would be nice. 🤔 See? This is why I am doing this! 🤓
PostShowComponent.php
<?php
declare(strict_types=1);
namespace App\Http\Livewire\Blog\Post;
use App\Exceptions\CrownNotFoundException;
use App\Exceptions\DuplicateCrownException;
use App\Models\Crown;
use App\Models\Post;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.guest', [
'leftSidebar' => false,
'rightSidebar' => true,
'hideHeader' => false,
])]
class PostShowComponent extends Component
{
public Post $post;
public bool $hasCrownedPost;
public function mount(string $slug): void
{
$this->post = Post::query()
->addSelect([
'crowned_by_user' => Crown::select('id')
->where('user_id', auth()->id())
->whereColumn('post_id', 'posts.id'),
])
->where('slug', $slug)->firstOrFail();
$this->authorize('view', $this->post);
$this->post->load([
'category',
'user' => fn ($query) => $query
->select('id', 'slug', 'name', 'email', 'avatar', 'background_id')
->with('background'),
'tags',
'affiliates' => fn ($query) => $query
->byActivated()
->with('affiliate_category'),
])
->loadCount('crowns', 'affiliates');
$this->hasCrownedPost = (bool) $this->post->crowned_by_user;
}
public function crown(): ?RedirectResponse
{
if (! Auth::check()) {
return $this->redirect(route('login'));
}
try {
if ($this->hasCrownedPost) {
$this->post->removeCrown(Auth::user());
} else {
$this->post->crown(Auth::user());
}
$this->hasCrownedPost = ! $this->hasCrownedPost;
} catch (CrownNotFoundException|DuplicateCrownException $e) {
Log::error($e->getMessage());
}
return null;
}
}
The refactored code improves the original in several ways:
- Eliminates multiple queries for the same
Post
model - Eager loads relationships in one optimized
load()
call - Selects only needed fields from related models to avoid excess data
- Properly uses authorization via the PostPolicy
- Loads additional relationships only after authorization check
- No more unreachable code
These improvements result in more efficient queries, better separation of concerns, and cleaner, more maintainable code. Livewire's use of PHP attributes for layouts is a fresh approach. However, this component can still be improved with:
- Consider caching for better performance
- Decoupling crown functionality
- Introducing Policy for the Crowning
- Refactoring with Query Object pattern
But those are tasks for another Late Night Refactor session. The current changes are a solid step forward in maintainability and organization.
Blog Index Pages
In Sudorealm, I've been wrestling with a few blog section pages that index posts: Index by Category, Index by Tag, and Index by User (the Main Index will live to fight another day).
Here's the thing, these pages are all being called by the same controller, which violates everything I hold holy in software development! I'm not even sure what past-me was thinking, but hey, what can I say? We all have those moments, right?
The Controller was looking something like this:
class BlogController extends Controller
{
public function indexCategoryPosts(Category $category)
{
return view('blog.category.index', [
'category' => $category,
]);
}
public function indexTagPosts(Tag $tag)
{
return view('blog.tag.index', [
'tag' => $tag,
]);
}
// And so the nightmare continues...
Now, the code itself isn't bad, it's the architecture that's giving me a migraine. So here's the clean, organized structure I'm moving towards:
app/Http/Livewire/Blog
├── Category
│ └── CategoryPostIndexComponent.php
├── Tag
│ └── TagPostIndexComponent.php
├── User
│ └── UserPostIndexComponent.php
This new structure follows clean naming conventions and creates a logical organization that makes sense. It's much easier to build a mental model of the project now, and as a bonus, debugbar becomes way more useful when using livewire components!
The actions I will be taking for all of these functions are:
- Create the new Livewire components.
- Replace the old web routes with the new ones and add better restful names to them.
- Add
wire:navigate
to all the links that call these routes. - Delete the old, now unused, blade files.
I'll walk you through refactoring one of these components, worry not! I won't make you sit through all of them. They follow the same pattern, and I respect your time (and sanity) too much for that mindless repetition!
Refactor Result
web.php
Route::name('category.')->group(function () {
// Before
- Route::get('/category/{category:slug}', [BlogController::class, 'indexCategoryPosts'])->name('index');
//After
+ Route::get('realm/{slug}', CategoryPostIndexComponent::class)->name('post.index');
// now the name is category.post.index 🧼✨
Now the web.php
serves as a proper roadmap for new developers, giving them a clearer picture of how the project is structured, the way routing files were always meant to be!
I also took the executive decision to rename category URLs to realms. Will this destroy my SEO? Probably. Do I care? A little. Is it what it is? You bet it is! Moving on. 😎
CategoryPostIndexComponent.php
sail artisan livewire:make Blog/Category/CategoryPostIndexComponent
This name might look like a mouthful, but there's a method to the madness here:
- Category tells us exactly what model we're dealing with.
- Post shows we're handling blog posts.
- Index indicates it's a listing page.
- Component because, well, it's a Livewire component!
<?php
declare(strict_types=1);
namespace App\Http\Livewire\Blog\Category;
use App\Models\Category;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.guest', [
'leftSidebar' => true,
'rightSidebar' => true,
'hideHeader' => false,
])]
class CategoryPostIndexComponent extends Component
{
public Category $category;
public function mount(string $slug): void
{
$this->category = Category::query()
->where('slug', $slug)
->firstOrFail();
}
}
This is a pretty straightforward Livewire component, as you can see, nothing groundbreaking happens in the code itself. But what we get in return is sweet: a cleaner codebase, plus we can leverage wire:navigate
to give Sudorealm that smooth SPA feel when users bounce between pages. Is this a feature-driven refactor? Well, kind of! If wire:navigate
wasn't in the picture, I probably would've just gone with invokable controllers and called it a day. But sometimes new features push us to rethink our architecture, and that's not a bad thing at all!
📝 Sidenote: I will surely cache queries like this forever. They don't have to be called at every click.
There's another late-night refactor lurking in the shadows, 🥷 those frequent queries aren't going to cache themselves! But that's a story for another nocturnal coding session. 😏
Finishing Up
That was a refreshing late-night refactoring session, not only did I get to clean up Sudorealm's codebase, but I also took another step towards the great Livewireazation of the project. Sometimes the best refactors are the ones that spark joy while pushing you forward!
Key takeaways:
- 🦋 Small Decisions, Big Impact: Breaking down the monolithic BlogController into focused Livewire components made the codebase cleaner and more maintainable.
- 📝 Descriptive Naming Pays Off: Taking the time to create meaningful, descriptive component names might feel verbose, but it makes the codebase self-documenting. Your 3 AM coding self will thank you!
- 🗺️ Routes as Documentation: Clean routing files serve as a natural map of your application.
- 🎯 Progressive Enhancement: Not every improvement needs to happen at once. While this refactor tackled component organization, we identified future opportunities (like query caching) for another late-night session.
- 👷♂️ 🔦 Code Spelunking Uncovers Hidden Treasures (and Bugs): Duplicate queries lurking in dark corners, unnecessarily loaded models gathering dust, unreachable code frozen in time, and authorization logic begging to be moved to proper policies. Sometimes the best way to find bugs is to strap on your spelunking gear and explore your old code caves!
The entire saga of this late-night refactor will be available in the branch lnr2-controllers-to-livewire-components
once I open-source the project. All future Late Night Refactor sessions will follow this naming pattern, so you can trace my struggles, victories, and occasional 1 to 3 AM coding revelations. Stay tuned for more late-night code MMA fighting! 🥊
🚀 Spread the Love & Support the Realm
If you enjoyed this post and want to help improve the project, consider these quick ways to contribute:
- 🛍 Affiliate Treasures Below – Check the list of cool gadgets below 👀
- ☕️ Coffee Driven Development: We are nothing but coffee-to-code machines! BuyMeACoffee
Spread the Love
👑 Crown & Share: If you found value in this post, please give it a crown and share it with your fellow coder/hacker enthusiasts. Spreading knowledge is what Sudorealm is all about! Fun fact the Author with the most crowns inside a realm will be crowned as the Realm King! 🤴
🆇 X Shoutout: Feeling extra grateful or have some cool feedback? Drop me a shoutout on Twitter – I'd love to hear from you! d3adR1nger on X
💬 Join our Discord Server: Join the Sudorealm Discord Server
Thanks for being a part of our realm. Every bit of support propels our community to new horizons. Until next time, keep exploring!