— Published: 22-09-2019 | mentions

Creating a Laravel specific package (part 3/5)

This post is part of a series:

Introduction

Sometimes you want your package to offer a bit more. If we image that we're developing a Blog related package, we might want to provide a Post model for example. This post will focus on handling Models, migrations, how to test them and how to deal with the situation whenever your model needs a relationship with the App\User model that ships with Laravel.

Models & Migrations

Models

Models in our package do not differ from models we would use in a standard Laravel application. Since we required the Orchestra Testbench, we can create a model extending the Laravel Eloquent model and save it within the src/Models directory:

// 'src/Models/Post.php'
<?php

namespace JohnDoe\BlogPackage\Models;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
  // Disable Laravel's mass assignment protection
  protected $guarded = [];
}

To quickly scaffold your models together with a migration, I would advise to create a new Laravel application (a “dummy application” just for the creation of models / migrations / etc.) and use the php artisan make:model -m command and copy the model to the package’s src/Models directory and using the proper namespace.

Migrations

Migrations live in the database/migrations folder in a Laravel application. In our package we mimic this file structure. Therefore, database migrations will not live in the src/ directory but in their own database/migrations folder. The root directory of our package now contains two folders: src/ and database/.

After you’ve generated the migration, copy it from your “dummy” Laravel application to the package’s database/migrations folder. Rename it to create_posts_table.php.stub removing its timestamp and using a .stub extension.

// 'database/migrations/create_posts_table.php.stub'
<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreatePostsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('posts');
    }
}

To present the end user with our migration(s), we need to register that our package “publishes” its migrations. We can do that as follows in the boot() method of our package’s service provider, employing the publishes() method, which takes two arguments: (1) an array of file paths ("source path" => "destination path") and (2) the name (“tag”) we assign to this group of related publishable assets.

Practically, we can implement this functionality as follows. First, we'll check if the application is running in the console, then we'll check if the user already published the migrations, if not we will publish the create_posts_table migration in the migrations folder in the database path, prefixed with the current date and time:

class BlogPackageServiceProvider extends ServiceProvider
{
  public function boot()
  {
    if ($this->app->runningInConsole()) {
      // publish config file
      // register artisan command

      if (! class_exists('CreatePostsTable')) {
        $this->publishes([
          __DIR__ . '/../database/migrations/create_posts_table.php.stub' => database_path('migrations/' . date('Y_m_d_His', time()) . '_create_posts_table.php'),
          // you can add any number of migrations here
        ], 'migrations');
      }
    }
  }
}

The migrations of this package are now publishable under the “migrations” tag via:

php artisan vendor:publish --provider="JohnDoe\BlogPackage\BlogPackageServiceProvider" --tag="migrations"

Testing Models & Migrations

Writing a Unit test

Now that we’ve got PHPunit set up, let’s create a unit test for our Post model in the tests/Unit directory called PostTest.php. Ideally we would write a test that verifies a Post has a title:

// 'tests/Unit/PostTest.php'
<?php

namespace JohnDoe\BlogPackage\Tests\Unit;

use Illuminate\Foundation\Testing\RefreshDatabase;
use JohnDoe\BlogPackage\Tests\TestCase;
use JohnDoe\BlogPackage\Models\Post;

class PostTest extends TestCase
{
  use RefreshDatabase;

  /** @test */
  function a_post_has_a_title()
  {
    $post = factory(Post::class)->create(['title' => 'Fake Title']);
    $this->assertEquals('Fake Title', $post->title);
  }
}

Note: we're using the RefreshDatabase trait to be sure that we start with a clean database state before every test.

Running the tests

We can run our test suite by calling the phpunit binary in our vendor directory using ./vendor/bin/phpunit. However, let’s alias this to test in our composer.json file by adding a “script”:

{
  ...,

  "autoload-dev": {},

  "scripts": {
    "test": "vendor/bin/phpunit",
    "test-f": "vendor/bin/phpunit --filter"
  }
}

Now, we can run composer test to run all of our tests and composer test-f followed by a name of a test method to only run that test.

When we run composer test-f a_post_has_a_title, it leads us to the following error:

InvalidArgumentException: Unable to locate factory with name [default] [JohnDoe\BlogPackage\Models\Post].

In the next section, we'll address this issue by creating a model factory for the Post model.

Creating a Model Factory

Let’s create a PostFactory in the database/factories folder:

// 'database/factories/PostFactory.php'
<?php

use JohnDoe\BlogPackage\Models\Post;
use Faker\Generator as Faker;

$factory->define(Post::class, function (Faker $faker) {
  return [
    //
  ];
});

However, the tests will still fail since we haven’t created the posts table in our in-memory sqlite database yet. We need to tell our tests to first perform all migrations, then run the test. Let’s load the migrations in the getEnvironmentSetUp() method of our TestCase:

// 'tests/TestCase.php'

public function getEnvironmentSetUp($app)
{
  // import the CreatePostsTable class from the migration
  include_once __DIR__ . '/../database/migrations/create_posts_table.php.stub';

  // run the up() method of that migration class
  (new \CreatePostsTable)->up();
}

Now, running the tests again will lead to the expected error of no ‘title’ column being present on the ‘posts’ table. Let’s fix that in the create_posts_table.php.stub migration:

// 'database/migrations/create_posts_table.php.stub'
Schema::create('posts', function (Blueprint $table) {
    $table->bigIncrements('id');
    $table->string('title');
    $table->timestamps();
});

After running the test, you should see it passing.

Let’s add tests for the “body” and “author_id”:

// 'tests/Unit/PostTest.php'
class PostTest extends TestCase
{
  use RefreshDatabase;

  /** @test */
  function a_post_has_a_title()
  {
    $post = factory(Post::class)->create(['title' => 'Fake Title']);
    $this->assertEquals('Fake Title', $post->title);
  }

  /** @test */
  function a_post_has_a_body()
  {
    $post = factory(Post::class)->create(['title' => 'Fake Body']);
    $this->assertEquals('Fake Title', $post->body);
  }

  /** @test */
  function a_post_has_an_author_id()
  {
    // Note that we are not assuming relations here, just that we have a column to store the 'id' of the author
    $post = factory(Post::class)->create(['author_id' => 999]); // we choose an off-limits value for the author_id so it is unlikely to collide with another author_id in our tests
    $this->assertEquals(999, $post->author_id);
  }
}

To keep the post to the point, I won’t walk you through driving this out with TDD. However, eventually you’ll end up with a model factory and migration as follows:

// 'database/factories/PostFactory.php'
<?php

namespace JohnDoe\BlogPackage\Database\Factories;

use Faker\Generator as Faker;
use JohnDoe\BlogPackage\Models\Post;

$factory->define(Post::class, function (Faker $faker) {
    return [
        'title'     => $faker->words(3),
        'body'      => $faker->paragraph,
        'author_id' => 999,
    ];
});

For now, I’ve hard coded the ‘author_id’, but in the next section I want to show how we could whip up a relationship with a User model.

// 'database/migrations/create_posts_table.php.stub'

Schema::create('posts', function (Blueprint $table) {
    $table->bigIncrements('id');
    $table->string('title');
    $table->text('body');
    $table->unsignedBigInteger('author_id');
    $table->timestamps();
});

Models related to App\User

Now that we have an “author_id” column on our Post model, let’s create a relationship between a Post and a User. However … we have a problem, since we need a User model, but this model also comes out-of-the-box with a fresh installation of the Laravel framework…

We can’t just provide our own User model, since you likely want your end user to be able to hook up his own User model with your Post model. Or even better, let the end user decide which model they want to associate with the Post model.

Refactoring to a polymorphic relationship

Therefore, instead of opting for a conventional one-to-many relationship (a user can have many posts, and a post belongs to a user), we’ll use a polymorphic one-to-many relationship where a Post morphs to a certain related model (not necessarily a User model).

Let’s compare the standard and polymorphic relationships.

Definition of a standard one-to-many relationship:

// Post model
class Post extends Model
{
  public function author()
  {
    return $this->belongsTo(User::class);
  }
}

// User model
class User extends Model
{
  public function posts()
  {
    return $this->hasMany(Post::class);
  }
}

Definition of a polymorphic one-to-many relationship:

// Post model
class Post extends Model
{
  public function author()
  {
    return $this->morphTo();
  }
}

// User (or other) model
use JohnDoe\BlogPackage\Models\Post;

class Admin extends Model
{
  public function posts()
  {
    return $this->morphMany(Post::class, 'author');
  }
}

After adding this author() method to our Post model, we need to update our create_posts_table_migration.php.stub file to reflect our polymorphic relationship. Since we named the method “author”, Laravel expects an “author_id” and an “author_type” field. The latter contains a string of the namespaced model we are referring to (for example “App\User”).

Adding the "author_type" field:

Schema::create('posts', function (Blueprint $table) {
    $table->bigIncrements('id');
    $table->string('title');
    $table->text('body');
    $table->unsignedBigInteger('author_id');
    $table->string('author_type');
    $table->timestamps();
});

Now, we need a way to provide our end user with the option to allow certain models to be able to have relationship with our Post model. Traits offer an excellent solution for this exact purpose.

Providing a Trait for this relationship

Create a “Traits” folder in the src/ directory and add the following hasPosts trait:

// 'src/Traits/HasPosts.php'
<?php

namespace JohnDoe\BlogPackage\Traits;

use JohnDoe\BlogPackage\Models\Post;

trait HasPosts
{
  public function posts()
  {
    return $this->morphMany(Post::class, 'author');
  }
}

Now, within a Laravel application that required our package, the end user can add a use HasPosts statement to any of their models (likely the User model) which would automatically register the one-to-many relationship with our Post model. This provides access to creating new posts as follows:

// Given we have a User model, using the HasPosts trait
$user = User::first();

// We can create a new post from the relationship
$user->posts()->create([
  'title' => 'Some title',
  'body' => 'Some body',
]);

Testing polymorphism

Of course, we want to prove that any model using our HasPost trait can indeed create new posts and that those posts are stored correctly.

Therefore, we’ll create a new User model, but not within the src/Models/ directory, but rather in our tests/ directory. In the User model we’ll use the same traits that would be available on the User model that ships with a standard Laravel project to stay close to a real world scenario. Also, we use our own HasPosts trait:

// 'tests/User.php'
<?php

namespace JohnDoe\BlogPackage\Tests;

use Illuminate\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\Access\Authorizable;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
use JohnDoe\BlogPackage\Traits\HasPosts;

class User extends Model implements AuthorizableContract, AuthenticatableContract
{
    use HasPosts, Authorizable, Authenticatable;

    protected $guarded = [];

    protected $table = 'users';
}

Now that we have a User model, we also need to add a new migration (the standard users table migration that ships with Laravel) to our database/migrations as create_users_table.php.stub:

// 'database/migrations/create_users_table.php.stub'
<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateUsersTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('name');
            $table->string('email')->unique();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password');
            $table->rememberToken();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('users');
    }
}

Also load the migration at the beginning of our tests, by including the migration and performing its up() method in our TestCase:

// 'tests/TestCase.php'
public function getEnvironmentSetUp($app)
{
    include_once __DIR__ . '/../database/migrations/create_posts_table.php.stub';
    include_once __DIR__ . '/../database/migrations/create_users_table.php.stub';

    // run the up() method (perform the migration)
    (new \CreatePostsTable)->up();
    (new \CreateUsersTable)->up();
}

Updating our Post model factory

Now that we can whip up User models with our new factory, let’s first create a new User in our PostFactory and then assign it to “author_id” and “author_type”:

// 'database/factories/PostFactory.php'
<?php

namespace JohnDoe\BlogPackage\Database\Factories;

use Faker\Generator as Faker;
use JohnDoe\BlogPackage\Models\Post;
use JohnDoe\BlogPackage\Tests\User;

$factory->define(Post::class, function (Faker $faker) {
    $author = factory(User::class)->create();
    
    return [
        'title'         => $faker->words(3),
        'body'          => $faker->paragraph,
        'author_id'     => $author->id,
        'author_type'   => get_class($author),
    ];
});

Let’s update the Post unit test, to also verify an ‘author_type’ can be specified.

// 'tests/Unit/PostTest.php'
class PostTest extends TestCase
{
  // other tests...

  /** @test */
  function a_post_has_an_author_type()
  {
    $post = factory(Post::class)->create(['author_type' => 'Fake\User']);
    $this->assertEquals('Fake\User', $post->author_type);
  }
}

Finally, we need to verify that our test User can create a Post and that it is stored correctly. Since we are not creating a new post using a call to a specific route in the application, I would store this test also in the Post unit test. In the next section on “Routes & Controllers”, we’ll make a POST request to an endpoint to create a new Post model and therefore divert to a Feature test.

A test method that verifies the desired behavior between a User and a Post could look as follows:

// 'tests/Unit/PostTest.php'
class PostTest extends TestCase
{
  // other tests...

  /** @test */
  function a_post_belongs_to_an_author()
  {
    // Given we have an author
    $author = factory(User::class)->create();
    // And this author has a Post
    $author->posts()->create([
        'title' => 'My first fake post',
        'body'  => 'The body of this fake post',
    ]);

    $this->assertCount(1, Post::all());
    $this->assertCount(1, $author->posts);

    // Using tap() to alias $author->posts()->first() to $post
    // To provide cleaner and grouped assertions
    tap($author->posts()->first(), function ($post) use ($author) {
        $this->assertEquals('My first fake post', $post->title);
        $this->assertEquals('The body of this fake post', $post->body);
        $this->assertTrue($post->author->is($author));
    });
  }
}

At this stage all of the tests are passing, so we can move on to the next post.

Next post

In the next section we'll cover all routing related things: adding routes, controllers, views and assets. We'll also look at how to feature test these routes and controller actions. Continue to part 4.

Webmentions

No mentions.