Test your morph map [in Laravel]

First of all, I need to say that I am a sucker for moving files and classes around. App\Users\Group is much nicer than App\UserGroup, sorry.

–

If you have ever used Laravel’s morph map feature, you might know that it’s really easy to forget to update the morph map once you move a model to a different namespace.

For reference, what I mean when I’m talking about “morph map”, I’m referring to the mapping between a model class name and a string, which represents the model on the database level.

public function boot()
{
    Relation::morphMap([
        'comments'    => \App\Comment::class,
        'users'       => \App\Users\User::class,
        'user_groups' => \App\Users\Group::class,
    ]);
}

What I came up with

Naturally, at first I tried to overcomplicate things and I made a symfony/console-based tool to scan the project, find the model files, find the morph map and do the math.

The second (and final I think) attempt, resulted in a much simpler solution. I used the same approach but instead of putting it into a separate tool, I’ve placed it inside a PHPUnit test. This is what it looks like:

<?php

namespace Tests\Unit;

use App\Support\Utils;
use Tests\UnitTestCase;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;

class MorphMapTest extends UnitTestCase
{
    /** @test */
    public function it_includes_all_models()
    {
        $models = Utils::classesIn(app_path())
            ->filter(function ($class) {
                return $class->isSubclassOf(Model::class);
            })
            ->pluck('name');

        $map = collect(Relation::morphMap())
            ->values();

        $this->assertEmpty(
            $models->diff($map),
            'Morph map is missing these models: '.$models->diff($map)->implode(', ').'.'
        );

        $this->assertEmpty(
            $map->diff($models),
            'Morph map contains these extra models: '.$map->diff($models)->implode(', ').'.'
        );
    }
}

How does this work?

The test gets a collection of models which live in the /app folder. Then it gets the morph map using Illuminate\Database\Eloquent\Relations\Relation::morphMap() call and coverts it into a collection.

Finally it compares the two collections and asserts that both diffs are empty. $model->diff($map) contains entries which are missing from the morph map. The inverse, $map->diff($models), contains entries which are present in the morph map but point to a model which does not exist.

Almost there. I have left out one very important thing.

How do you get the list of models?!

I’ve built a utility for this but it’s kind of hacky.

<?php

namespace App\Support;

use ReflectionClass;
use Symfony\Component\Finder\Finder;

class Utils
{
    public static function classesIn(string $path)
    {
        foreach (Finder::create()->files()->in($path) as $file) {
            require_once $file->getPathname();
        }

        return collect(get_declared_classes())
            ->map(function ($class) {
                return new ReflectionClass($class);
            })
            ->reject(function ($class) {
                return $class->isAnonymous();
            })
            ->filter(function ($class) use ($path) {
                return starts_with($class->getFileName(), $path);
            })
            ->values();
    }
}

Cool, right? The utility first requires all files within the /app folder. Then it gets the list of declared classes, filters it to non-anonymous ones which live in the /app folder. Hacky but works.

What do you think?

Feel free to reach out to me on twitter.

© 2020 Jerguš Lejko