Oh Dear is the all-in-one monitoring tool for your entire website. We monitor uptime, SSL certificates, broken links, scheduled tasks and more. You'll get a notifications for us when something's wrong. All that paired with a developer friendly API and kick-ass documentation. O, and you'll also be able to create a public status page under a minute. Start monitoring using our free trial now.

Strategies for making Laravel packages customizable

Original – by Freek Van der Herten – 10 minute read

While attending Laracon India, many people approached me to ask how we handle the maintenance of all our Spatie packages.

One of the ways we keep maintenance burden low is by making our packages customizable. In this blog post, I'd like to cover some of our best tips to make a Laravel package easy to customize. Some of these tips will apply to regular projects as well.

Letting users pick their own model

When your package contains a model, you will, at some point in time, be asked to introduce an option to change the table name, connection name, or other kinds of configuration.

The most straightforward, but not best, way to solve this would be to introduce configuration options.

In the config file of your package, you could add keys for all individual options.

return [
	'db-connection-name' => 'default',
	
	// other package options
	// ...
];

In the model inside of your package, you could read that configuration option.

namespace Vendor\Package\Models;

use Illuminate\Database\Model;

class PackageModel extends Model
{
    public function getConnectionName()
    {
        return config('your-package::db-connection-name');
    }
}

That would work, but now imagine that your package users want a second thing on the model to be made configurable. Maybe the table name or yet something else needs to be customizable. You could add new config options, but that gets unwieldy quickly.

We usually solve this problem in our packages by making the entire model customizable.

Instead of adding an option to customize the connection name or table name, we add a config option named model.

return [
	'model' => Vendor\Package\Models\PackageModel::class,
	
	// other package options
	// ...
];

In your package, we'll be reading the class name from the config file everywhere where we use the model.

// read the config option to get the right model
$modelClass = config('your-package::model);

// execute any query on the model class from the config file
$models = $modelClass::query()->get();

With this in place, users can create a model class of their own that extends the model class provided by the package. On that custom model, users can customize whatever option they like.

namespace App\Models;

use Vendor\Package\Models\PackageModel;

class CustomModel extends PackageModel
{
   public $connectionName = 'custom-connection'; 
	   
   // any other option
}

Of course, the package user must specify the class name of the custom model in the config file.

return [
	'model' => App\Models\CustomModel::class,
	
	// other package options
	// ...
];

And with that in place, the package user can modify all model behavior to their liking.

Some of our packages that have a customizable method are Laravel Health, Laravel Notification Log, Laravel Schedule Monitor. Look in the source code of those to see a real-world example.

Using action classes

When your package contains logic that you want your users to be able to customize, you could consider moving the logic to an action class.

An action class is nothing more than a simple class with a method that executes some logic. At Spatie, most of our action classes have a method called execute, but you could also use __invoke() or any name you want.

Let's take a look at a concrete example. Our Laravel Dynamic Servers package can dynamically provision, start and stop servers (we named that package well 🙂). When a server is provisioned, that server needs a name. The name of a new server gets determined in an action class called GenerateServerNameAction.php

Here is the implementation:


namespace Spatie\DynamicServers\Actions;

use Spatie\DynamicServers\Models\Server;

class GenerateServerNameAction
{
    public function execute(Server $server): string
    {
        return "dynamic-server-{$server->type}-{$server->id}";
    }
}

Users of the package can override the behavior by again creating a new class that extends GenerateServerNameAction

namespace App\Support;

use Spatie\DynamicServers\Actions\GenerateServerNameAction;

class MyCustomGenerateServerNameAction extends GenerateServerNameAction
{
   public function execute(Server $server): string
   {
      return "jolly-good-server-{$server->type}-{$server->id}";
   }
}

The Dynamic Servers package config file contains a key actions that contains a key value array of actions. Here people can override the default with their custom class.

// in config/dynamic-servers.php

return [
// ... other options

    'actions' => [
        'generate_server_name' => App\Support\MyCustomGenerateServerNameAction::class,
        
        // ... other actions
];

We can use the config value to instantiate the class and call the execute method in our package code.

$generateServerActionClass = config('dynamic-servers.actions.generate_server_name');

$generateServerAction = app($generateServerActionClass);

$generateServerAction->execute($server);

This technique is powerful because people can generate the server name based on their desired criteria.

Splitting big functions up into smaller ones

In the previous section, we used an action class with only very simple logic. Some actions can contain a lot more logic. Let's look at an example from the self-hosted version Mailcoach. Mailcoach is a platform to affordably send newsletters and automations to email lists of any size. Even if you're not in the market for an email solution, it might be worthwhile to purchase a license to take a look at the source code, there are a lot of interesting things happening there.

Like all our other packages, the self-hosted version of Mailcoach is very customizable. Its config file contains a lot of actions that are registered as described in the previous section.

One of the biggest actions is the SendCampaignAction, which, true to its name, sends off a campaign to all subscribers (in most cases, this is a newsletter).

In that action, a lot of things happen:

  • we'll prepare the HTML content for sending
  • we'll create a webview
  • we dispatch jobs that will do the actual sending
  • we'll mark the campaign as being sent

Let's imagine that the logic for all this work would live in the execute method of the SendCampaignAction.

namespace Spatie\Mailcoach\Domain\Campaign\Actions;

use Spatie\Mailcoach\Domain\Campaign\Models\Campaign;

class SendCampaignAction
{
    public function execute(Campaign $campaign): void
    {
        // add logic for all the tasks described above
    }
}

If a user wants to add something to the start or end of the method, it's easy, just call the parent class and add some logic before or after.

namespace App\Actions;

use Spatie\Mailcoach\Domain\Campaign\Models\Campaign;
use Spatie\Mailcoach\Domain\Campaign\Actions\SendCampaignAction;

class CustomAction extends SendCampaignAction
{
    public function execute(Campaign $campaign): void
    {
        // add custom logic here
    
        parent::execute($campaign);
        
        // add custom logic here
    }
}

But let's now imagine that something needs to be customized in the middle of the logic of the original class. That's not so easy: you're now forced to copy the original implementation in your custom class.

namespace App\Actions;

use Spatie\Mailcoach\Domain\Campaign\Models\Campaign;
use Spatie\Mailcoach\Domain\Campaign\Actions\SendCampaignAction

class CustomAction extends SendCampaignAction
{
    public function execute(Campaign $campaign): void
    {
        // copy start of original implementation here 
    
        // add custom code here
        
        // copy end of original implementation here
    }
}

Your class now contains the logic of the original implementation. This is problematic because if the original class receives an update, your custom implementation does not get those new changes.

You can solve this problem by splitting up your action in easy to override functions.

This is what the actual SendCampaignAction in Mailcoach looks like.

namespace Spatie\Mailcoach\Domain\Campaign\Actions;

use Spatie\Mailcoach\Domain\Campaign\Models\Campaign;

class SendCampaignAction
{
    public function execute(Campaign $campaign): void
    {
        $this
            ->prepareSubject($campaign)
            ->prepareEmailHtml($campaign)
            ->prepareWebviewHtml($campaign)
            ->dispatchCreateSendJobs($campaign)
            ->markCampaignAsSent($campaign);
    }
    
    public function prepareSubject(Campaign $campaign): self
    {
		    // implementation removed for brevity
    }
    
    public function prepareEmailHtml(Campaign $campaign): self
    {
		    // implementation removed for brevity
    }
    
    public function prepareWebviewHtml(Campaign $campaign): self
    {
		    // implementation removed for brevity
    }
    
    public function dispatchCreateSendJobs(Campaign $campaign): self
    {
		    // implementation removed for brevity
    }

    public function markCampaignAsSent(Campaign $campaign): self
    {
		    // implementation removed for brevity
    }
}   

With the implementation split up, you can easily override one of the tasks.

namespace App\Actions;

use Spatie\Mailcoach\Domain\Campaign\Models\Campaign;
use Spatie\Mailcoach\Domain\Campaign\Actions\SendCampaignAction;

class CustomAction extends SendCampaignAction
{
    public function prepareWebviewHtml(Campaign $campaign): self
    {
		    // add your own implementation of how to do this task
    }
}

If the implementation of one of the other functions changes in the parent, you'll get those changes, as you are not overriding those functions.

If you want to add logic of your own in between the steps of the original function, that's now possible too. Let's add someNewTask to the chain.

namespace App\Actions;

use Spatie\Mailcoach\Domain\Campaign\Models\Campaign;
use Spatie\Mailcoach\Domain\Campaign\Actions\SendCampaignAction;

class CustomAction extends SendCampaignAction
{
    public function execute(Campaign $campaign): void
    {
        $this
            ->prepareSubject($campaign)
            ->prepareEmailHtml($campaign)
            ->someNewTask($campaign)
            ->prepareWebviewHtml($campaign)
            ->dispatchCreateSendJobs($campaign)
            ->markCampaignAsSent($campaign);
    }
    
    public function someNewTask(Campaign $campaign)
    {
       // implementation
    }
    
    // other methods
}

Splitting up big chunks of code into small functions isn't only useful for action classes. It's best practice to do so everywhere, as it will likely make your code more readable. The fact that it makes it easy to override specific functions is a nice side benefit.

Firing events

In the sections above, we explored how to make it easy for package users to override logic provided by your package. If you only want your users to execute additional logic, you can get away with just firing events at specific points in your code.

Let's take the SendCampaignAction again as an example. To introduce a new event, we have to create an event class and fire it where you want.

Here's what an event could look like.

namespace Spatie\Mailcoach\Domain\Campaign\Events;

class CampaignSentEvent
{
   public function __construct(public Campaign $campaign)
   {
   }
}

And of course, it needs to be fired in the right place.

namespace Spatie\Mailcoach\Domain\Campaign\Actions;

use Spatie\Mailcoach\Domain\Campaign\Models\Campaign;
use Spatie\Mailcoach\Domain\Campaign\Events\ CampaignSentEvent;

class SendCampaignAction
{
    public function execute(Campaign $campaign): void
    {
        $this
            ->prepareSubject($campaign)
            ->prepareEmailHtml($campaign)
            ->prepareWebviewHtml($campaign)
            ->dispatchCreateSendJobs($campaign)
            ->markCampaignAsSent($campaign);
            
        event(new CampaignSentEvent($campaign));
    }

To execute extra logic, the user can then use an event listener or subscriber.

In closing

We use the above techniques a lot in our package to make our package to be as customizable as can be, so our users can use code for scenarios we didn't think of.

Some package maintainers do quite the opposite. They mark their classes as final and try to block users from using the package for scenarios they didn't intend. Their biggest argument for this is often that they want to protect their users from shooting themselves in the foot. Those maintainers also want to protect themselves from issues being created for edge cases.

Personally, I believe that making code more flexible results in less maintenance. Our code doesn't need to support exotic use cases, as users can use the provided flexibility. With our packages having been downloaded over 500 million times, I can ensure that this strategy works to have less work maintaining stuff.

If you want learn more about creating packages, take a look at our Laravel Package training, in which you will learn all you need to know for creating a quality framework agnostic or Laravel package. In this blog post, we also hinted to use action classes in your packages. Actions classes are very helpfull in regular projects as well, take a look at our Laravel Beyond CRUD course to know more.

If you liked this blog post and/or our open-source efforts, consider picking up one of our paid products or subscribe at Mailcoach and/or Flare.

Stay up to date with all things Laravel, PHP, and JavaScript.

You can follow me on these platforms:

On all these platforms, regularly share programming tips, and what I myself have learned in ongoing projects.

Every month I send out a newsletter containing lots of interesting stuff for the modern PHP developer.

Expect quick tips & tricks, interesting tutorials, opinions and packages. Because I work with Laravel every day there is an emphasis on that framework.

Rest assured that I will only use your email address to send you the newsletter and will not use it for any other purposes.

Comments

Stephen Bateman avatar

This was super helpful! I've bookmarked it for later.

Implementation question: is there a reason your action classes don't put the $campaign on the class and then reference $this->campaign from the methods? It might just be a matter of preference, but I figured I'd ask.

I would've expected:

class SendCampaignAction
{
		public function __construct(public Campaign $campaign){}
		
    public function execute(): void
    {
        $this
            ->prepareSubject()
            ->prepareEmailHtml()
            ->prepareWebviewHtml()
            ->dispatchCreateSendJobs()
            ->markCampaignAsSent();
            
        event(new CampaignSentEvent($this->campaign));
    }
		```
Comments powered by Laravel Comments
Want to join the conversation? Log in or create an account to post a comment.