Building Configurable Laravel Packages

LaravelPackagesConfigurationFacadesService Providers

When developing open-source packages, my aim is to make them highly configurable. This approach empowers the end-user to tailor the package to their specific needs, negating the need to fork the repository and maintain their own package version. This strategy also saves me from having to deal with feature requests that I have no desire to implement or maintain. In this article, I'll demonstrate how I create configurable packages for Laravel, and provide guidance on how you can do the same.

The initial step involves creating a configuration file for your package. This file will encompass all the configuration options for your package and will be published to the application's config directory upon package installation. The configuration file should carry the same name as your package and should reside in your package's config directory. For instance, if your package is named themis, the configuration file should be labeled themis.php.

The configuration file should return an array of configuration options. These options should be grouped by their purpose and thoroughly documented using comments. Below is an example of a configuration file for a package named themis:

<?php
 
declare(strict_types=1);
 
return [
    /*
    |--------------------------------------------------------------------------
    | Themis Models
    |--------------------------------------------------------------------------
    |
    | This array contains the class names of the Eloquent models used by Themis.
    | These models will be used when querying and persisting permissions and roles.
    |
    */
 
    'models' => [
        /*
        |--------------------------------------------------------------------------
        | Permission Model
        |--------------------------------------------------------------------------
        |
        | This is the Eloquent model used for permissions. This class will be used
        | when performing permission related queries and should extend
        | 'Illuminate\Database\Eloquent\Model'.
        |
        */
 
        'permission' => \BombenProdukt\Themis\Permission::class,
 
        /*
        |--------------------------------------------------------------------------
        | Role Model
        |--------------------------------------------------------------------------
        |
        | This is the Eloquent model used for roles. This class will be used
        | when performing role related queries and should extend
        | 'Illuminate\Database\Eloquent\Model'.
        |
        */
 
        'role' => \BombenProdukt\Themis\Role::class,
    ],
 
    /*
    |--------------------------------------------------------------------------
    | Themis Tables
    |--------------------------------------------------------------------------
    |
    | This array contains the names of the database tables used by Themis.
    | If you have changed the table names in your migrations, adjust these settings to match.
    |
    */
 
    'tables' => [
        /*
        |--------------------------------------------------------------------------
        | Permissions Table
        |--------------------------------------------------------------------------
        |
        | This is the table used to store permissions.
        |
        */
 
        'permissions' => 'permissions',
 
        /*
        |--------------------------------------------------------------------------
        | Roles Table
        |--------------------------------------------------------------------------
        |
        | This is the table used to store roles.
        |
        */
 
        'roles' => 'roles',
 
        /*
        |--------------------------------------------------------------------------
        | Roles Association Table
        |--------------------------------------------------------------------------
        |
        | This is the pivot table used to associate roles with models.
        |
        */
 
        'model_has_roles' => 'model_has_roles',
 
        /*
        |--------------------------------------------------------------------------
        | Permissions Association Table
        |--------------------------------------------------------------------------
        |
        | This is the pivot table used to associate permissions with models.
        |
        */
 
        'model_has_permissions' => 'model_has_permissions',
    ],
 
    /*
    |--------------------------------------------------------------------------
    | Themis Columns
    |--------------------------------------------------------------------------
    |
    | This array contains the column names used by Themis in the database tables.
    | If you have changed the column names in your migrations, adjust these settings to match.
    |
    */
 
    'columns' => [
        /*
        |--------------------------------------------------------------------------
        | Model Column
        |--------------------------------------------------------------------------
        |
        | This is the column used to store the model identifier.
        |
        */
 
        'model' => 'model',
 
        /*
        |--------------------------------------------------------------------------
        | Role ID Column
        |--------------------------------------------------------------------------
        |
        | This is the column used to store the role identifiers.
        |
        */
 
        'role_id' => 'role_id',
 
        /*
        |--------------------------------------------------------------------------
        | Permission ID Column
        |--------------------------------------------------------------------------
        |
        | This is the column used to store the permission identifiers.
        |
        */
 
        'permission_id' => 'permission_id',
    ],
];

Given this configuration file, the end-user can customize the package according to their needs by adjusting the configuration options. For instance, if the end-user wants to use a different table to store permissions, they can alter the tables.permissions configuration option to reflect the desired table name.

Similarly, they can modify the package's models by changing the models.permission and models.role configuration options to the class names of their preferred models.

Even the column names utilized by the package can be altered. This can be done by modifying the columns.model, columns.role_id, and columns.permission_id configuration options to their preferred column names.

Now, armed with this configuration file of known structure, we can develop an interface with methods that return these configuration options. This interface will serve the package by retrieving these configuration options. Here's an example of an interface for the themis package:

<?php
 
declare(strict_types=1);
 
namespace BombenProdukt\Themis;
 
interface ConfigurationInterface
{
    public function getPermissionModel(): string;
 
    public function getRoleModel(): string;
 
    public function getPermissionsTableName(): string;
 
    public function getRolesTableName(): string;
 
    public function getModelHasRolesTableName(): string;
 
    public function getModelHasPermissionsTableName(): string;
 
    public function getModelColumnName(): string;
 
    public function getModelIdColumnName(): string;
 
    public function getModelTypeColumnName(): string;
 
    public function getRoleIdColumnName(): string;
 
    public function getPermissionIdColumnName(): string;
}

The interface is straightforward, featuring a method for each configuration option, all returning the options as strings. It is now ready to be implemented by a class that will extract these configuration options from the configuration file. Here's an example of a class implementing the interface:

<?php
 
declare(strict_types=1);
 
namespace BombenProdukt\Themis;
 
use Illuminate\Support\Facades\Config;
use TypeError;
 
final class Configuration implements ConfigurationInterface
{
    public function getPermissionModel(): string
    {
        return $this->getStringValue('themis.models.permission');
    }
 
    public function getRoleModel(): string
    {
        return $this->getStringValue('themis.models.role');
    }
 
    public function getPermissionsTableName(): string
    {
        return $this->getStringValue('themis.tables.permissions');
    }
 
    public function getRolesTableName(): string
    {
        return $this->getStringValue('themis.tables.roles');
    }
 
    public function getModelHasRolesTableName(): string
    {
        return $this->getStringValue('themis.tables.model_has_roles');
    }
 
    public function getModelHasPermissionsTableName(): string
    {
        return $this->getStringValue('themis.tables.model_has_permissions');
    }
 
    public function getModelColumnName(): string
    {
        return $this->getStringValue('themis.columns.model');
    }
 
    public function getModelIdColumnName(): string
    {
        return $this->getModelColumnName().'_id';
    }
 
    public function getModelTypeColumnName(): string
    {
        return $this->getModelColumnName().'_type';
    }
 
    public function getRoleIdColumnName(): string
    {
        return $this->getStringValue('themis.columns.role_id');
    }
 
    public function getPermissionIdColumnName(): string
    {
        return $this->getStringValue('themis.columns.permission_id');
    }
 
    private function getStringValue(string $key): string
    {
        $value = Config::get($key);
 
        if (\is_string($value)) {
            return $value;
        }
 
        throw new TypeError("Configuration value '{$key}' must be a string.");
    }
}

The class implements the interface and fetches configuration options from the configuration file using the Config facade. It also includes a private method that retrieves configuration options and throws an exception if the configuration option isn't a string. The class is now prepared for registration in the service container. Here's an example of a service provider that registers the class in the service container:

<?php
 
declare(strict_types=1);
 
namespace BombenProdukt\Themis;
 
use BombenProdukt\PackagePowerPack\Package\AbstractServiceProvider;
 
final class ServiceProvider extends AbstractServiceProvider
{
    public function packageRegistered(): void
    {
        $this->app->singleton(
            ConfigurationInterface::class,
            Configuration::class,
        );
    }
}

Lastly, we need to create a facade for the class, enabling the end-user to access it conveniently. This can be achieved by crafting a facade class that extends the Illuminate\Support\Facades\Facade class and overrides the getFacadeAccessor method. This simplifies the end-user's access to the configuration. Here's an example of a facade class for the themis package:

<?php
 
declare(strict_types=1);
 
namespace BombenProdukt\Themis;
 
use Illuminate\Support\Facades\Facade;
 
/**
 * @method static string getModelColumnName()
 * @method static string getModelHasPermissionsTableName()
 * @method static string getModelHasRolesTableName()
 * @method static string getModelIdColumnName()
 * @method static string getModelTypeColumnName()
 * @method static string getPermissionIdColumnName()
 * @method static string getPermissionModel()
 * @method static string getPermissionsTableName()
 * @method static string getRoleIdColumnName()
 * @method static string getRoleModel()
 * @method static string getRolesTableName()
 */
final class Themis extends Facade
{
    protected static function getFacadeAccessor(): string
    {
        return ConfigurationInterface::class;
    }
}

The facade class extends the Illuminate\Support\Facades\Facade class and overrides the getFacadeAccessor method to return the class name of the class that implements the interface. The facade class also includes a @method annotation for each method in the interface. This annotation simplifies usage of the facade class for the end-user and facilitates code completion within the IDE.

The End

That's it! You've now created a configuration that's both easily accessible and type-safe, ready to be used by the end-user. The end-user can interact with this configuration via the facade, eliminating the need to inject the ConfigurationInterface instance or resolve it from the container. All the end-user needs to do is utilize the facade class, and the configuration options will be readily available.