November 3rd, 2023 Laravel PHP

Unorthodox Eloquent II

This is part 2 of the miniseries "Unorthodox Eloquent". If you haven't read the original post yet, you can do so here.

Last post, we explored a wide variety of "unorthodox" options that could be used in conjunction with our Eloquent models. That article, however, was just the tip of the iceberg. In this blog post, I'd like to go over a few other tips and tricks that might be a bit more esoteric—but nevertheless still handy—than the topics presented in the first post. For example, have you ever considered using model factories outside of your seeders or tests? No? Well, then I'm pretty sure you'll learn a thing or two again so make sure you stick around until the end!

Like every tool in existence, Eloquent comes with its own set of trade-offs. As responsible developers, we should always be aware of the things we are trading off against. If you'd like to learn more about ActiveRecord and its design philosophy, I highly recommend this article by Shawn McCool.

Quick navigation

Model factories in app code

In order to fully appreciate this section, I think we should first establish a precise common understanding of what a Factory is. Put simply, it is something that is responsible for the generation of another thing. However, I'd like go one step further and define it as something that is responsible for the genesis of another entity. Typically, all models / entities have a specific lifecycle: they start existing for some particular reason, go through a certain process and eventually cease to exist or "die". A Factory is especially useful for dealing with the first part of an entity's lifecycle.

Eloquent Factories have existed for quite a while now. From the documentation, verbatim:

When testing your application or seeding your database, you may need to insert a few records into your database.

It is depicted as if testing and seeding are the only use cases where Eloquent Factories make sense, but nothing could be further from the truth. They can be used in your application code as well without needing to worry about accidentally breaking something or giving someone too many rights. But we would be mixing up test / seed code with application code, right? Wrong!

Preparation

First and foremost, we should decide on a location to place our test-only factories for use in integration or feature tests. I prefer to leave database/factories alone for application code since it's also auto-discovered by the framework. tests/Factories would be a good candidate to place our testing specific factories. So go ahead and move the test-only factories to their new location. After the files have been moved, we now have to tell the factory discoverer to check our new location while running tests. To achieve this, we should define a new FactoryResolver class in the root of our tests folder:

final readonly class FactoryResolver
{
    public function __invoke(string $fqcn): string
    {
        $model = class_basename($fqcn);

        return "Tests\\Factories\\{$model}Factory";
    }
}

Next, we should define a TestingServiceProvider in the root of our tests folder in which the FactoryResolver is registered:

final class TestingServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Factory::guessFactoryNamesUsing(new FactoryResolver());
    }
}

We can now register this custom TestingServiceProvider whenever the test application is created:

trait CreatesApplication
{
    public function createApplication(): Application
    {
        $app = require __DIR__.'/../bootstrap/app.php';

        $app->make(Kernel::class)->bootstrap();
        $app->register(TestingServiceProvider::class); // 👈

        return $app;
    }
}

You may have noticed that we don't add this to the app.providers configuration array, and why should we? This TestingServiceProvider is of use only during a testing context. Registering it with every real application request makes zero sense and does nothing but waste precious CPU cycles. Once everything is in place, we can go ahead and start using factories in application code on the one hand, and factories meant for testing on the other.

Explained by example: user signup

Let's assume that we have the following route for handling user signups:

Route::post('users', [UserController::class, 'store']);

It should be able to register users with various roles: premium, vip, trial etc. After the user has signed up, depending on their role, a WelcomeEmail should be sent to their email address. One way to do this is by adding all necessary logic to the corresponding Controller method. However, we already know that that's not a very good idea. We can deal with this problem in a clean and unorthodox manner!

First, let's create a UserFactory (in database/factories using php artisan make:factory) that's going to be responsible for generating User objects along with various factory methods to put them in a consistent state:

final class UserFactory extends Factory
{
    protected $model = User::class;

    public function definition(): array
    {
        return [];
    }

    public function isPremium(): self
    {
        return $this->state([
            'type' => 'premium',
        ]);
    }

    public function isTrial(): self
    {
        return $this->state([
            'trial_expires_at' => Date::now()->addWeeks(2),
            'type' => 'trial',
        ]);
    }

    public function isVip(): self
    {
        return $this->state([
            'type' => 'vip',
        ]);
    }
}

You might have noticed that the definition method is rather empty. This is completely on purpose, because the goal of this method is to provide default values for database fields that would not have been set during the execution of a feature test. However, our goal is to use this class in our application code and not in test code so we should leave it blank. The absence of any field will denote a bug in our code and we should fix it asap.

After having done that, we should head over to our User model and define a newFactory method that is going to return a new instance of the very factory we have just defined. This will be crucial for our tests later on:

use Database\Factories\UserFactory;

final class User extends Authenticatable
{
    use HasFactory;

    // omitted for brevity

    protected static function newFactory(): UserFactory
    {
        return UserFactory::new();
    }
}

We can now go ahead and implement the necessary Controller logic to fulfill the business requirements:

final readonly class UserController
{
    public function store(SignUpUser $request): UserResource
    {
        $user = User::factory()
            ->when($request->wantsTrial(), static fn (UserFactory $usr) => $usr->isTrial())
            ->when($request->wantsPremium(), static fn (UserFactory $usr) => $usr->isPremium())
            ->when($request->wantsVip(), static fn (UserFactory $usr) => $usr->isVip())
            ->create($request->validated());

        return UserResource::make($user);
    }
}

As you can see, the "missing" definition attributes are provided by Laravel's FormRequest validation method. Depending on the user's selection in the front-end, we can invoke the various factory methods in order to create a valid User object with a valid state depending on the chosen role. Everything is fine and dandy, but we are still missing a key requirement: delivering the "welcome" mails. At this point, we could start looking into events and listeners, creating dedicated actions, process pipelines... Though just because we can, doesn't mean we should. Instead, let's grok the full power of Eloquent factories:

public function isTrial(): self
{
    return $this->state(...)->afterCreating(function (User $user) {
        Mail::send(new WelcomeTrialUserMail($user));
    });
}

Amàzing, right? Pricesely knowing how your tools work unlocks an incessant amount of power right there at your disposal. Rinse and repeat three times and we are done implementing the biz requirements.

Automated testing

But what about testing? Testing is equally important as the production code itself because it validates our assumptions and ensures that the code meets the business requirements. Good news: nothing changes with the way you have been writing feature tests except one little thing.

Instead of doing this during your test case preparations:

User::factory()->create();

You now have to do this:

use Testing\Factories\UserFactory;

UserFactory::new()->create();

That's because the former is always going to resolve the application factory while the latter will be using the factory specifically created for our test scenarios. We can go ahead and write a simple test that ensures our code works as intended:

final class UsersTest extends TestCase
{
    #[Test]
    public function premium_users_can_sign_up(): void
    {
        $this->post('users', [
            'email' => 'muhammed+evilalias@GMAIL.CoM',
            'name' => 'mUhAmMeD',
            'type' => 'premium',
        ]);

        $this->assertDatabaseHas(User::class, [
            'email' => 'muhammed@gmail.com',
            'name' => 'Muhammed',
            'type' => 'premium',
        ]);
        
        Mail::assertQueued(WelcomePremiumUserMail::class);
    }
}

"But where is the UserFactory?", you might rightfully ask. Well, it doesn't really make sense to use a test factory here because we are testing some behavior whose purpose is to create a User object for us. If you want you can obviously add more tests if it'll increase your confidence levels. Please don't add tests for the sake of testing. Make sure that you are actually netting positive value.

An example test case of another use case might look as follows:

use Tests\Factories\UserFactory;

#[Test]
public function only_premium_users_receive_access_to_discounts(): void
{
    $this
        ->actingAs(UserFactory::new()->create())
        ->get('discounts')
        ->assertForbidden();

    $this
        ->actingAs(UserFactory::new()->isPremium()->create())
        ->get('discounts')
        ->assertOk()
        ->assertSee('Halloween discounts!');
}

This is where the FactoryResolver we defined earlier comes into play. It'll be used by the framework to automatically resolve relational factory instances when creating other relations inside of our test factories. I strongly discourage the use of the factories' relational capabilities outside of test factories because it'll only be a matter of time before someone calls in and says "Wait, how the hell did he get administrator access?!". Define everything explicitly inside your application code factories. Relying on framework magic inside your test factories is totally fine.

"Native" belongsToThrough relation

I don't know about you, but time and time again I have wished that Laravel had a native belongsToThrough relationship because it's so darn useful in certain scenarios. Which native relations does Laravel have that can be used out-of-the-box (OOB)? One definitely catches my attention: HasOneThrough. If we think about, it's almost what we want but the inverse. Unfortunately, it is not exactly what we want so we must fall back to using third-party packages...

Hold on a second. Why are we giving up so fast? Why don't we go ahead and take look behind the curtains to see how this almost-the-relation-we-want-but-unfortunately-not-exactly-the-relation-that-we-want works? Why is our mentality defaulted to using third-party packages as soon as we are faced with a slightly more difficult challenge? We can definitely do better!

Exploration

public function hasOneThrough(
    $related, 
    $through, 
    $firstKey = null, 
    $secondKey = null, 
    $localKey = null, 
    $secondLocalKey = null
) {}

This is the method definition of the HasOneThrough relationship. We can see that it allows us to customize everything with regards to the query that needs to be executed in order to fulfill the relation's needs. If we follow Laravel's example of mechanics-cars-owners and execute the relationship, the following SQL (or something very close, depending on your driver) will be generated:

public function carOwner(): HasOneThrough
{
    return $this->hasOneThrough(Owner::class, Car::class);
}

Mechanic::find(504)->carOwner;
SELECT
	*
FROM
	`owners`
	INNER JOIN `cars` ON `cars`.`id` = `owners`.`car_id`
WHERE
	`cars`.`mechanic_id` = 504

Have you noticed something? No? This is the query pattern that all belongsToThrough packages use! The only thing that they're doing, is inverting the foreign and local keys and calling it a day! Didn't we assert, just now, that the hasOneThrough relation allows us to customize literally everything related to the query? Let's try it!

Solution

This is what we currently have in our Mechanic model:

public function carOwner(): HasOneThrough
{
    return $this->hasOneThrough(Owner::class, Car::class);
}

Let's go to the Owner model and define a relationship for our Mechanic model:

public function carMechanic(): HasOneThrough
{
    return $this->hasOneThrough(Mechanic::class, Car::class);
}

Even if we tried to execute this, the SQL would crash because the query would be nonsensical. Let's invert all keys manually and see what's going to happen:

public function carMechanic(): HasOneThrough
{
    return $this->hasOneThrough(
        Mechanic::class, 
        Car::class, 
        'id', 
        'id', 
        'car_id', 
        'mechanic_id',
    );
}

Owner::find(2156)->carMechanic;

Awesome! If we inspect the generated SQL, we can see that the keys got inverted according to our definition:

SELECT
	*
FROM
	`mechanics`
	INNER JOIN `cars` ON `cars`.`mechanic_id` = `mechanics`.`id`
WHERE
	`cars`.`id` = 2156

We can also make a tiny improvement to the relationship definition so that future self and other fellow developers can quickly understand what's going on:

use Illuminate\Database\Eloquent\Relations\HasOneThrough as BelongsToThrough;

public function carMechanic(): BelongsToThrough
{
    return $this->hasOneThrough(...);
}

Some words of wisdom

Next time, instead of brushing aside existing tools, try and see if you can make it work according to your needs when you make slight modifications to the default behavior. You'll be surprised with how much you can get away with! Remember, pulling in additional dependencies also brings along maintenance burdens so you should think about it at least four times before deciding on actually pulling in a particular dependency.

Fully grokking Eloquent

I have to admit that this is going to be an extremely subjective section, but please hear me out until the end before pulling out the pitchforks and torches. Thank you.

Eloquent ORM comes with various tools OOB: event dispatch, transaction managing, observers, global scopes, automatic timestamp management etc. While this approach has its benefits, unequivocally it also has its drawbacks. Eloquent adheres to the SRE principle: Single-handedly Responsible for Everything. That being said, how many times have we actually taken a gander at the internal workings of this razor-sharp tool?

HasEvents

For example, did you know that all Eloquent models have direct access to the event Dispatcher? So, why do we do this:

public function accept(): void
{
    // omitted for brevity

    event(new ApplicationWasAccepted($this));
}

Instead of this:

public function accept(): void
{
    // omitted for brevity

    static::$dispatcher->dispatch(new ApplicationWasAccepted($this));
}

HasTimestamps

Why this:

public function accept(): void
{
    $this->fill([
        'accepted_at' => now(),
    ]);
}

Instead of this:

public function accept(): void
{
    $this->fill([
        'accepted_at' => $this->freshTimestamp(),
    ]);
}

Did you know that this returns Illuminate\Support\Carbon instead of Carbon\Carbon which is the "wrong" type-hint you've been using all along?

Model

Due to the design philosopy of ActiveRecord, all models must have access to the underlying database connection. So why do we do this:

public static function directory(User $downloader, Directory $directory): self
{
    // omitted for brevity

    DB::transaction(static fn () => $model->save() && $model->directories()->attach($directory));

    return $model;
}

Instead of this:

public static function directory(User $downloader, Directory $directory): self
{
    // omitted for brevity

    $model
        ->getConnection()
        ->transaction(static fn () => $model->save() && $model->directories()->attach($directory));

    return $model;
}

My point

But why?

I'm just trying to provoke our thoughts here and question what we have been doing all along. My presented alternatives are as much "grokking the framework" as using the other options. So why don't we look at the tools under use next?

The latter options signal to me a seasoned developer that knows how their tools work behind the scenes. Why do we go through the entire IoC container service resolution cycle to retrieve an object that was always there to begin with? Convenience? I'd argue that neither option is more convenient than the other, because proper IDEs will always autocomplete the "longer" options...

Tappable scopes in-depth

This is going to be the shortest section of them all, because I am not going to be the one explaining it further. I'd like to give a shout-out to Marco Deleu, who has already done an excellent job at going into greater depths on Tappable scopes.

I am aware that the previous blog post didn't do justice to how amazing Tappable scopes really are. Unfortunately, it's quite a challenging and daunting task to go into every nitty gritty detail when writing long form articles. So, if you'd like to learn more about Tappable scopes, please check out Marco's article:

Pushing the boundaries of Eloquent

Summary

In all honesty, I still have many other tricks up my sleeve. However, those are even more esoteric than the ones highlighted in this blog post, so I think that'll be it from me for today. The one point I really want to get across is the fact that curiosity is the key to unlocking new methodologies, perspectives, approaches, and habits. Even if you've been using the same thing for a long time now, don't hesitate to experiment and see if you can improve your current habits. Perfection does not exist, but refinement is something that is always, always possible.

Join the discussion on X (formerly Twitter)! I'd love to know what you thought about this blog post.

Thanks for reading!