An Alternative Laravel Package Development Workflow

Younes Rafie
Share

This article was peer reviewed by Francesco Malatesta. Thanks to all of SitePoint’s peer reviewers for making SitePoint content the best it can be!


Every framework gives developers a way to extend the system using packages / extensions. We can generally hook in our logic at any point where we want to provide specific functionality, and Laravel is no exception! Following the article of my fellow author Francesco Malatesta about his Laravel package development workflow, I noticed that mine is a bit different and I wanted to share it with you!

Laravel Package

Demo Package

As a demo package, we’re going to build a Laravel two factor authentication package for this article. The package will handle the user authentication with a verification code being sent through a medium like Nexmo, Twilio, etc. You can check out the final package here.

Setting up the Repository

Before we start the development, we need to create a new repository inside GitHub so we can push/pull from it through the development phase.

Composer supports a repositories key inside the composer.json file. We can use this to specify our custom packages that don’t yet exist on Packagist.

{
    // ....
    "repositories": [
        {
            "type": "vcs",
            "url": "https://github.com/Whyounes/laravel-two-factor-auth-demo"
        }
    ],
    // ....
}

Now, we can require our package as usual.

{
    // ....
    "require": {
        "php": ">=5.6.4",
        "laravel/framework": "5.4.*",
        "laravel/tinker": "~1.0",
        "Whyounes/laravel-two-factor-auth-demo": "dev-master"
    },
    // ....
}

We can specify a branch or a version number. In case we want to use a branch, it should be prefixed with dev-. You can read more about configuring repositories on the Packagist website.

Package Skeleton

Since Composer identifies packages using the composer.json file, we need to create one for our package and push it to the repository.

{
  "name": "whyounes/laravel-two-factor-auth",
  "type": "library",
  "description": "Laravel two factor authentication",
  "keywords": [
    "auth",
    "passwordless"
  ],
  "homepage": "https://github.com/whyounes/laravel-two-factor-auth",
  "license": "MIT",
  "authors": [
    {
      "name": "Rafie Younes",
      "email": "younes.rafie@gmail.com",
      "homepage": "http://younesrafie.com",
      "role": "Developer"
    }
  ],
  "require": {
    "php": "^5.5.9 || ^7.0",
    "illuminate/database": "5.1.* || 5.2.* || 5.3.* || 5.4.*",
    "illuminate/config": "5.1.* || 5.2.* || 5.3.* || 5.4.*",
    "illuminate/events": "5.1.* || 5.2.* || 5.3.* || 5.4.*",
    "illuminate/auth": "5.1.* || 5.2.* || 5.3.* || 5.4.*",
    "twilio/sdk": "^5.4"
  },
  "require-dev": {
    "phpunit/phpunit": "^4.8 || ^5.0",
    "orchestra/testbench": "~3.0",
    "mockery/mockery": "~0.9.4"
  },
  "autoload": {
    "psr-4": {
      "Whyounes\\TFAuth\\": "src"
    },
    "classmap": [
      "tests"
    ]
  }
}

After running the composer update command inside the Laravel project’s root folder, we see an error that no tests were found under the tests folder. This won’t halt the installation process, so we’re fine to continue!

Composer update failing tests

The last part of our package configuration is to link the downloaded package to the GitHub repository.

// assuming you're inside your project folder

cd vendor/whyounes/laravel-two-factor-auth
git init origin git@github.com:Whyounes/laravel-two-factor-auth.git
git commit -am "Init repo"
git push origin master -f // force push for the first time

Package Structure

This part is a little bit controversial, we can’t all agree on one single structure. Here’s our package structure:

▶ config/
    auth.php
    services.php
▶ migrations/
    2014_10_1_000000_add_tfa_columns_to_users_table.php
    2016_12_1_000000_create_tokens_table.php
▶ resources/
    ▶ views/
        verification_code.blade.php
▶ src/
    ▶ Contracts/
        VerificationCodeSenderInterface.php
    ▶ Controllers/
        TFAController.php
    ▶ Exceptions/
        TFANotEnabledException.php
        UserNotFoundException.php
    ▶ Models/
        TFAuthTrait.php
        Token.php
    ▶ Providers/
        TwoFAProvider.php
    ▶ Services/
        Twilio.php
▶ tests/

To install the package we use a Laravel provider:

// config/app.php

// ...
'providers' => [
    // ...
    Whyounes\TFAuth\TwoFAProvider::class,
};

The provider class looks like this:

// src/Providers/TwoFAProvider.php

namespace Whyounes\TFAuth;

use Illuminate\Auth\Events\Authenticated;
use Illuminate\Routing\Router;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Twilio\Rest\Client;
use Whyounes\TFAuth\Contracts\VerificationCodeSenderInterface;
use Whyounes\TFAuth\Services\Twilio;
/**
 * Class TwoFAProvider
 *
 * @package Whyounes\TFAuth
 */
class TwoFAProvider extends ServiceProvider
{
    public function boot()
    {
        $this->loadMigrationsFrom(__DIR__ . '/../../migrations');
        $this->mergeConfigFrom(
            __DIR__ . '/../../config/auth.php', 'auth'
        );
        $this->mergeConfigFrom(
            __DIR__ . '/../../config/services.php', 'services'
        );
        $this->loadViewsFrom(__DIR__.'/resources/views', 'tfa');
        $this->publishes([
            __DIR__.'/../../resources/views' => resource_path('views/vendor/tfa'),
        ]);
    }

    public function register()
    {
        $this->registerBindings();
        $this->registerEvents();
        $this->registerRoutes();
    }

    public function registerBindings()
    {
        $this->app->singleton(Client::class, function () {
            return new Client(config('services.twilio.sid'), config('services.twilio.token'));
        });
        $this->app->bind(VerificationCodeSenderInterface::class, Twilio::class);
    }

    public function registerEvents()
    {
        // Delete user tokens after login
        if (config('auth.delete_verification_code_after_auth', false) === true) {
            Event::listen(
                Authenticated::class,
                function (Authenticated $event) {
                    $event->user->tokens()
                        ->delete();
                }
            );
        }
    }

    public function registerRoutes()
    {
        /** @var $router Router */
        $router = App::make("router");
        $router->get("/tfa/services/twilio/say/{text}", ["as" => "tfa.services.twilio.say", "uses" => function ($text) {
            $response = "<Response><Say>" . $text . "</Say></Response>";
            return $response;
        }]);
    }
}

Let’s go through every method here:

  • The boot method will be publishing our package assets (config, migrations, views).
  • The registerBindings method binds the VerificationCodeSenderInterface to a specific implementation. The package uses the Twilio service as a default, so we create a src/Services/Twilio class which implements our interface and defines the necessary methods.
// src/Services/Twilio.php

namespace Whyounes\TFAuth\Services;
use Twilio\Rest\Client;
use Whyounes\TFAuth\Contracts\VerificationCodeSenderInterface;

class Twilio implements VerificationCodeSenderInterface
{
    /**
     * Twilio client
     *
     * @var Client
     */
    protected $client;

    /**
     * Phone number to send from
     *
     * @var string
     */
    protected $number;

    public function __construct(Client $client)
    {
        $this->client = $client;
        $this->number = config("services.twilio.number");
    }

    public function sendCodeViaSMS($code, $phone, $message = "Your verification code is %s")
    {
        try {
            $this->client->messages->create($phone, [
                "from" => $this->number,
                "body" => sprintf($message, $code)
            ]);
        } catch (\Exception $ex) {
            return false;
        }
        return true;
    }

    public function sendCodeViaPhone($code, $phone, $message = "Your verification code is %s")
    {
        try {
            $this->client->account->calls->create(
                $phone,
                $this->number,
                ["url" => route('tfa.services.twilio.say', ["text" => sprintf($message, $code)])]
            );
        } catch (\Exception $ex) {
            return false;
        }
        return true;
    }
    // getters and setter
}
  • The registerEvents method will delete the previously used token upon authentication if the config value is set to true.
  • The registerRoutes method adds a route for sending a verification code via a phone call.

If, for example, we wanted to switch from Twilio to Nexmo, we only need to create a new class that implements the VerificationCodeSenderInterface and bind it to the container.

Finally, we add a trait to the User model class:

// app/User.php

class User extends Authenticatable
{
    use \Whyounes\TFAuth\Models\TFAuthTrait;

    // ...
}

Tests

Tests are mandatory before releasing any code publicly, not just for Laravel. DO NOT use any packages that don’t have tests. For Laravel, you may use the orchestra/testbench package to help you out, and the documentation provides a really nice flow for testing Laravel components like Storage, Eloquent, etc.

For our package, we have tests for our controller, model and service classes. Check the repository for more details about our tests.

Tagging

We can’t keep just a master branch for our package. We need to tag a first version to start with. This depends on how far along are we in the feature development stage, and what the goal is here!

Since our package is not that complicated and doesn’t have a lot of features to build, we can tag it as a first stable version (1.0.0). You can check the Git documentation website for more details about tagging, and here’s the list of commands for adding a new tag to our repository:

git tag
git tag -a v1.0.0 -m "First stable version"
git push origin --tags

Tagging

We should now see the tag on GitHub under the branch select box:

GitHub Tags

Continuous Integration

Continuous Integration tools help us integrate new parts into our existing code and send a notification about any infringements. It also gives us a badge (image with a link) for the status of the code. For this package, we use TravisCI as our CI tool.

TravisCI Badge

// .travis.yml

language: php

php:
  - 5.6
  - 7.0
  - 7.1

sudo: true

install:
- travis_retry composer install --no-interaction --prefer-source

This config file will be picked up by TravisCI to know how to test the code and what versions to test on. You can read more about how to integrate it here.

Conclusion

The development workflow is not that complicated. In fact, we can develop a package without needing to install the Laravel framework; just include the necessary Illuminate components and test drive your package’s implementation. At the end, you can do an integration test as a demo that the package is working as expected. I hope this helps you create some packages to give back to the community :)

If you have any questions or comments you can post them below! What’s your package development workflow?