Refactoring my Laravel Project

My website has been on Laravel for a while and it has grown a lot over the last year. There weren't (m)any tests nor did the codebase have any static analysis or code style restrictions.

While working on other projects, I have embraced and become an advocate for using static analysis and code style tools. With the complexity of the project increasing, I decided to apply this knowledge to my codebase.

Writing Tests

Before making any code changes or refactoring, I added tests. I shamefully admit that for many years I didn't write tests for projects, but working in a team environment I was enlightened to some best practices that had left me behind when working solo. Writing tests was a massive part of this process and I've been hooked ever since. Tests give confidence in making changes and releasing code. No more crossing my fingers when deploying.

On a new codebase, I aim to write a host of different tests. Starting with some basic unit tests and then more complicated feature tests, the aim is for over 90% coverage.

With a legacy codebase like mine, writing feature tests help cover code a lot quicker, giving confidence the pages you care about are working correctly. You can easily check pages are responding OK using Laravel HTTP Tests:

public function homepageIsOk(): void
{
	$this->get('/')->assertOk();
}

public function movieSectionIsOk(): void
{
	$this->get('/movies/')->assertOk();
}

public function aboutPageIsOK(): void
{
	$this->get('/about/')->assertOk();
}

public function statisticsPageIsOK(): void
{
	$this->get('/statistics/')->assertOk();
}

With this method, I wrote tests for my routes and ended up with just over 80% coverage, which is OK and definitely better than 0. I will look to improve this in the future.

With tests in place, this gave me the confidence to upgrade and refactor some of the codebase.

Upgrading from PHP 8.0 to 8.1

After writing my test suite, my first task was to upgrade from PHP 8.0. This version of PHP stopped receiving active support on 26th November 2022, so I wanted to update to PHP 8.1 (the newest supported version on my server). This is also in preparation for Laravel 10 which makes PHP 8.1 the minimum supported version.

I updated my local development environment easily using PHP Monitor, changed my composer version restriction and updated my dependencies. Re-running my test suite everything passed… that was seamless. Finally, I bumped the dependencies, so everything was up to date.

Upgrading didn't break anything but did give me access to newer features that I want to start using. I like the idea of array unpacking with string keys and using native enums. I also like the look of readonly properties and pure intersection types, so they're on the list for improvements to the codebase.

Refactoring

Part of my goal in refactoring was to reduce the size of my Eloquent models. These are often littered with a lot of relationships, attributes and scopes. I decided to move the scopes to separate classes and Tim MacDonald’s article ”Dedicated query builders for Eloquent models” was incredibly helpful.

PHP 7.4 introduced arrow functions, a shorter syntax of closures. I was happy with the more verbose syntax until I started noticing a lot of methods that could benefit from this change and stopped the messy requirement of use statements for variable scoping. I decided to tackle these whenever I came across one.

Embracing arrow functions allowed me to clean up a lot of code. Using the when() conditionable method from Eloquent queries, I refactored away from the scope injection of variables in the following;

Model::query()
	->when($year && $month, static function (Month $query) use ($year, $month): Builder {
		return $query->byYearAndMonth($year, $month);
	})

to the much nicer…

Model::query()->when($year && $month, static fn (Builder $query): Builder => $query->byYearAndMonth($year, $month))

I was also able to update some of my test fixtures.

The following code went through a couple of improvements. Firstly I used the magic methods for relationships to create the watches relationship, refactoring the $watches variable and the need for saveMany(). I then changed the withoutEvents callable to use the arrow functions syntax, because I had simplified the function code. I then realised you could use the createSilently() method to stop events from firing. This means the following code

$model = Model::withoutEvents(static function (): Model {
    $watches = Watched::factory()->times(2)->make();
    $movie = Model::factory()->create();
    $movie->watches()->saveMany($watches);
    return $movie;
});

became…

$model = Model::factory()->hasWatches(2)->createSilently()

Type Hinting and Code Style

When I added tools such as PHPStan and PHP CodeSniffer to this legacy codebase I was greeted by hundreds of warnings and errors. As I didn't have much code coverage due to a lack of tests, I wasn't confident applying the automated suggestions PHP CodeSniffer provided. PHPStan allows you to create a baseline file, which allows you to start fresh without overwhelming you to fix everything straight away and only show errors and warnings for the code you are adding.

With my code coverage improved and the refactoring of query builders, I decided to tackle some of the problems diagnosed by these tools. I found an amazing useful article by Harmen Janssen titled Statically analyze your Laravel 9 application with PHPStan which helped me solve most of the PHPStan issues.

The most notable fix to help with warnings/errors that I previously ignored was type-hinting the factories. Using the @extends docblock cleared up a lot of issues that PHPStan was “false-y” reporting in my test codebase because it didn't know how to handle them.

/** @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User> */
class UserFactory extends Factory
{
    /** @var class-string<\App\Models\User> $model */
    protected $model = Model::class;

    public function definition(): array
    {
    }
}

Adding the correct docblock methods to the codebase helped the static analysis tool to work correctly. It also helps with your IDE when autocompleting code. Laravel 10 is adding more native type-hints and docblocks in the core, so hopefully, more people will benefit from the improvements.