Ramblings of a Tampa engineer
Computer cable
Photo by Steve Johnson / Unsplash

As Halo Infinite was released I knew I had to build another stat site for the game. Like most Halo stat site adventures - I had to do a different stack for the rebuild. In my memory, I had so far done:

  • Halo Reach - Invision Power Board
  • Halo 4 - CodeIgniter
  • Halo 5 - Laravel w/ Blade
  • Halo Infinite - Laravel w/ Livewire

So I started building Leaf with Livewire and it was a pleasant experience that I'm still using as of this post. So I wanted to dive into all the features/functionality I used to show how I went from nothing to something extremely fast.

So the first secret with my 4th iteration of Leaf was to no longer hook the internal game's API myself. While a fun research experiment, building code to keep a constant valid session alongside a growing list of roadblocks of clearance levels and tokens was painful. When you do that - you have less time to devote to the purpose of your project which is stats. So I used HaloDotAPI which did just that and exposed a more friendly API for consumers.

Second, I picked a frontend framework that required no JavaScript. I did this solely out of caution that a Livewire like product might not play nice with a frontend solution that also tries to control the same aspects that Livewire would, so I settled on Bulma.

Finally, I pushed myself to have 100% code coverage with a good set of linters and a fully open source project from code to pipelines for deploying and releasing.

So now that everything was ready - I dove into Livewire. I started building a simple "Add Gamertag" input box.

Livewire Validation

This had a small Blade file that was very familiar to anyone writing Blade.

<form wire:submit.prevent="submit" class="card-content">
    <div class="field">
        <label class="label">Gamertag</label>
        <div class="control has-icons-left @error('gamertag') has-icons-right @enderror">
            <input class="input @error('gamertag') is-danger @enderror" type="text" wire:model="gamertag"
                placeholder="Gamertag">
            <span class="icon is-small is-left">
                <i class="fab fa-xbox"></i>
            </span>
            @error('gamertag')
                <span class="icon is-small is-right">
                    <i class="fas fa-exclamation-triangle"></i>
                </span>
            @enderror
        </div>
        <p class="help is-info" wire:loading>Searching Halo Infinite for your account.</p>
        @error('gamertag')
            <p class="help is-danger">The gamertag is invalid</p>
        @enderror
    </div>

    <div class="field is-grouped">
        <div class="control">
            <button class="button is-link">Find Me</button>
        </div>
    </div>
</form>

You can see in here that it looks like a normal Blade file with a few different things.

  • The form tag has wire.submit.prevent, which instructs Livewire to basically prevent form submission on submit and instead execute submit on the corresponding PHP class.
  • The input has wire:model="gamertag" which binds the value entered into the public $gamertag value on the PHP class.
  • The <p class="help"> tag has wire:loading so it only appears while an in flight network request is ongoing.

So then we can look at the corresponding PHP class.

class AddGamerForm extends Component
{
    public $gamertag;

    protected array $rules = [
        'gamertag' => [
            'required',
            'min:1',
            'max:32'
        ]
    ];

    public function submit(): ?Redirector
    {
        $this->validate();

        $player = Player::fromGamertag($this->gamertag);
        if ($player->exists) {
            return $this->redirectPlayer($player);
        }

        $this->validate([
            'gamertag' => [
                new ValidInfiniteAccount()
            ]
        ]);

        $player = Player::fromGamertag($this->gamertag);
        return $this->redirectPlayer($player);
    }

    public function render(): View
    {
        return view('livewire.add-gamer-form');
    }

    private function redirectPlayer(Player $player): Redirector
    {
        return redirect()->route('player', [$player]);
    }
}

You can see above the following in this PHP class.

  • We leverage the "rules" functionality of Livewire to validate input.
  • If it validates, we first check if we know this gamertag otherwise falling back to looking them up.
  • We instruct Livewire which file to render.

I then have a fully interactive search box with validation and more without writing a single line of JavaScript.

Now I wanted to start figuring out how to paginate without full page refreshes. Livewire once again had a section dedicated to pagination which made this as easy as plain Laravel.

Livewire Pagination

Moving between pages felt slick and fast and it seemed like I could even excel it further with cursor based pagination, but the URL with cursor based pagination didn't look as friendly as say /page/17 of game history.

This made the component PHP wise for Livewire very small and easy to understand.

class GameHistoryTable extends Component
{
    use WithPagination;

    public Player $player;

    public function paginationView(): string
    {
        return 'pagination::bulma';
    }

    public function render(): View
    {
        return view('livewire.game-history-table', [
            'games' => $this->player
                ->games()
                ->with(['playlist', 'map', 'category'])
                ->orderByDesc('occurred_at')
                ->paginate(16)
        ]);
    }
}

All I needed to add was a trait - WithPagination and optionally I added a paginationView function so I could overload which pagination interface was drawn. I needed to do this to build a Bulma compatible pagination since it wasn't a supported default in Laravel.

So now I had a pretty slick working site, but refreshing data was the next challenge. I wanted visitors to immediately be able to view information quickly with the power of server side rendering. However, I also wanted to immediately trigger a background update of stats and immediately refresh the screen once that updated information arrived.

Livewire "Refresh"

So I built a page setup that is full of Livewire components. It we look at the above animation and associated Blade template - it looks roughly like:

<div class="columns">
  <div class="column">
    <livewire:player-toggle-panel />
    @include('partials.player.player-card')
    <livewire:update-player-panel :player="$player" :type="$type" />
  </div>
  <div class="column is-three-quarters">
    @include('partials.player.navigation')
    @include('partials.player.tabs.' . $type, ['player' => $player])
  </div>
</div>

So the sidebar has an interesting component called update-player-panel and as the name suggests - it just spawns with some messaging about "Checking for updated stats".

This Blade file is quite small and breaks down to just:

<article class="message is-small {{ $color }}" wire:init="processUpdate">
    <div class="message-header">
        <p>Checking for Update</p>
    </div>
    <div class="message-body">
        {{ $message }}
    </div>
</article>

It uses wire:init which is another cool Livewire feature called Defer Loading, so you can call a PHP function after the frontend portion of the component has completed loading.

So once the page loads, I emit an request to run processUpdate on the PHP class. This is a simple function that toggles a class variable on the render function. This is what Livewire uses to render the component initially and every time it updates.

However, I hit an issue that the component that was detecting updated stats was not the component that was drawing the stats.

Thankfully, Livewire has an "emit" function that allows us to pass messages between components and Livewire even offers a special "$refresh" event that instructs that component to refresh itself.

$this->emitTo(GameHistoryTable::class, '$refresh');

We just ask the component to update itself once we know we've updated the underlying data source.

So now the site was working quite well and I was off to get 100% test coverage. So I can test Livewire in both isolation and as a unit.

Take this first example as a more integration level test.

// Tests/Feature/Jobs/GamePageTest.php
public function testLoadingGamePageWithUnpulledGame(): void
{
    // Arrange
    $game = Game::factory()->createOne([
        'was_pulled' => false
    ]);

    // Act
    $response = $this->get('/game/' . $game->uuid);

    // Assert
    $response->assertStatus(Response::HTTP_OK);
    $response->assertSeeLivewire('game-page');
    $response->assertSeeLivewire('update-game-panel');
}

At this point I'm just rendering the page to be sure nothing is crashing. The existence of these Livewire components is good enough that the page itself is rendering.

Now I can take those components individually and test them in isolation. Take the Overview page which is the animated image shown above.

public function testValidResponses(array $attributes): void
{
    // Arrange
    $player = Player::factory()
        ->has(ServiceRecord::factory()->state($attributes))
        ->createOne();

    // Act & Assert
    Livewire::test(OverviewPage::class, [
        'player' => $player
    ])
        ->assertViewHas('serviceRecord')
        ->assertSee('Quick Peek')
        ->assertSee('Overall')
        ->assertSee('Types of Kills')
        ->assertSee('Other')
        // ...
        ->assertSee($serviceRecord->kd);
}

We can now test that component and all the data it renders out just like we would in Blade testing. This is powerful because my integration tests don't have to worry about the internal workings of each Livewire component and instead each component can be tested in isolation with a variety of inputs to account for all code paths. This ease when coming to testing for both Laravel and Livewire just makes the developer experience great when working with it.

Now Leaf is approaching 1000 commits and still has 100% coverage with Laravel Livewire. If I start a new project and the purpose merits it - I'll probably use Livewire again.

You’ve successfully subscribed to Connor Tumbleson
Welcome back! You’ve successfully signed in.
Great! You’ve successfully signed up.
Success! Your email is updated.
Your link has expired
Success! Check your email for magic link to sign-in.