Your App is a Package Manager

Feature image: Your App is a Package Manager

Keeping code organized, readable, and maintainable is tough, and there's no one-size-fits-all solution. When asked to implement new features, it's tempting to add more and more logic to existing classes, increasing the cost of maintenance and leading to infamous "God objects"—objects which contain so many responsibilities that it's difficult to infer what they do by their name, or even by a glance at their contents. While there are many tools in the form of patterns and best practices that can help mitigate these issues, we're going to focus on a simple idea: think of each feature in your app as a dependency.

Very few modern applications are written without any dependencies—most of ours include a framework like Laravel, at least. Dependencies naturally encapsulate related logic within their own namespace, and can typically be added to applications without conflicting with existing code.

Separating features into clearly-defined dependencies makes it easier to adhere to the Single Responsibility Principle (SRP), creating a simpler and more maintainable codebase. Teams can work on different parts of an application at the same time without getting bogged down by merge conflicts. Defining the boundaries of each feature even makes it easier to extract them into microservices later (but we don't suggest doing that unless you really need to).

Anatomy of a package

The most basic package may contain only a single file. Its code can be imported (typically via Composer autoloading) into any application object.

Laravel packages often contain an additional service provider which integrates the package with the framework, usually by binding configured objects into the application container.

Example: a tenancy feature

Let's walk through an example using a financial SaaS application. We want it to support multiple users, so we'll need to implement multi-tenancy.

Multi-tenancy allows you to scope your application data to an individual user, so users can't see or edit each other's data. It's a big topic, and there are numerous ways to implement it.

For this example we'll assume John and Jane both have access to our application. Their bills are stored in a bills table and their payments are stored in a payments table. To prevent John from seeing Jane's data and Jane from seeing John's data, we'll implement tenancy by adding a user_id column to each table. Now we can scope access to the data on the bills and payments tables to Jane's authenticated user by adding a where clause to queries.

For example, if Jane's user id is 5, a query to fetch Jane's payments would look like:

SELECT * from payments WHERE user_id = 5;

For an in-depth explanation and other approaches to multi-tenancy, check out Tom Schlick's excellent Laracon 2017 talk on the subject at multitenantlaravel.com.

Now that we've decided on a tenancy implementation, our code examples will assume the following:

  1. Each model representing user data will have a user_id column on its table.
  2. Tenancy only needs to be applied when a user is authenticated so the routes we discuss will have the auth middleware applied.

Let's get started

For this example we'll focus on the Bill and Payment models. The first thing we need to do is build controllers for each of these entities. Each controller will fetch the data belonging to the authenticated user and make it accessible to a view.

BillsController.php

class BillsController extends Controller
{
public function index()
{
$bills = Bill::where('user_id', auth()->user()->id)->get();
 
return view('bills.index', compact('bills'));
}
}

PaymentsController.php

class PaymentsController extends Controller
{
public function index()
{
$payments = Payment::where('user_id', auth()->user()->id)->get();
 
return view('payments.index', compact('payments'));
}
}

While this choice would work, we've put ourselves in a position where we must remember to apply this where clause to any queries we make on this model. We've also coupled these methods to the user_id column and to the auth() helper's underlying AuthManager instance. We can solve part of this by extracting a local scope in each model:

// In Payment & Bill models
public function scopeCurrentTenant($query)
{
$query->where('user_id', auth()->user()->id);
}

Then we can change the query in our controllers:

$payments = Payment::currentTenant()->get();
$bills = Bill::currentTenant()->get();

Unfortunately, all we've done is move the problematic code out of the controllers and into the models. It's still error prone and requires us to apply the scope to each query. We can further improve it by implementing an automatically-applied global scope:

// In Payment & Bill models
public static function boot()
{
static::addGlobalScope('user_id', function (Builder $builder) {
$builder->where('user_id', auth()->user()->id);
});
}

Now our controllers can simply call:

$payments = Payment::all();
$bills = Bill::all();

This approach will get the job done, but you may be thinking that we still have duplication, and if we want to change our tenancy scope we need to do that in two places. You're absolutely right. We also have logic that applies tenancy to our entity mixed in with our entity's own code.

Extracting the scope to a dedicated Scope class fixes the duplication issue:

class TenancyScope implements Scope
{
public function apply(Builder $builder, Model $model)
{
$builder->where('user_id', auth()->user()->id);
}
}

However, we still have to remember to register the scope in every model.

Let's consider how we might approach this problem if we were creating a package. The simplest way to add a group of related logic to any model that requires tenancy is with a trait — something like HasTenancy.

Let's start isolating our feature into a package! We'll start by creating an App\Tenancy namespace, moving our new scope into it, and creating the HasTenancy trait to register the scope:

HasTenancy.php

namespace App\Tenancy;
 
trait HasTenancy
{
public static function bootHasTenancy()
{
static::addGlobalScope(new TenancyScope);
}
}

Note that bootHasTenancy is a Laravel feature allowing you to hook into a model's boot() method without having conflicting method names. For more info, see Caleb Porzio's blog post.

Now all we have to do in our models is use the HasTenancy trait:

class Payment extends Model
{
use HasTenancy;
}
class Bill extends Model
{
use HasTenancy;
}

With these changes, we've accomplished a few things:

  1. We've removed the responsibility of tenancy from our models. Tenancy can be applied automatically to any model with a user_id column just by adding the HasTenancy trait.
  2. We have a dedicated namespace in which to organize tenancy logic. If we wanted to change how tenancy is implemented, all of the affected code would be contained to the App\Tenancy namespace.

This code is getting better, but our tenancy scope is dependent on an Auth instance — querying a model with tenancy when no one is authenticated will throw an exception. Additionally, our scope is tightly coupled to the User object; if we wanted to determine tenancy in any other way we'd have to change the scope. Let's see how we can implement a Tenant object and a service provider from within the package namespace, leveraging Laravel's application container to get around this.

First we'll create a Tenant class to contain all logic related to the User model in one place:

namespace App\Tenancy;
 
class Tenant
{
private $user;
 
public function __construct(User $user)
{
$this->user = $user;
}
 
public function column()
{
return 'user_id';
}
 
public function id()
{
return $this->user->id;
}
}

Next, we'll bind the Tenant into Laravel's application container in a service provider, passing it the authenticated user:

namespace App\Tenancy;
 
class TenancyProvider extends ServiceProvider
{
public function register()
{
$this->app->singleton(Tenant::class, function() {
return new Tenant(Auth::user());
});
}
}

Finally, we'll update our scope to use our new Tenant class:

namespace App\Tenancy;
 
class TenancyScope implements Scope
{
public function apply(Builder $builder, Model $model)
{
$tenant = app(Tenant::class);
$builder->where($tenant->column(), $tenant->id());
}
}

Now the Tenancy feature is standing on its own. The service provider where we register our Tenant serves as the package's entry point into the application; any other configuration related to tenancy would go here, where users can easily override it.

If we were making a package, we'd use Laravel's package discovery feature to automatically register our service provider, and all users would have to do after installing the package is use the HasTenancy trait.

Conclusion

Even if you don’t end up actually extracting a new package or spinning up a microservice, approaching your code with a mentality of "feature = package" often gives you a better mindset for adhering to SRP and, more importantly, helps you create a codebase with well-organized components that are easier to maintain and understand at a glance.

See the code: If you want to see the full multi-tenancy feature extracted to a package, Sara Bine has you covered.

Package Development: If you are interested in building your own package, take a look Matt Stauffer's post on the subject

Happy coding!

Get our latest insights in your inbox:

By submitting this form, you acknowledge our Privacy Notice.

Hey, let’s talk.
©2024 Tighten Co.
· Privacy Policy