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).
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.
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:
user_id
column on its table.auth
middleware applied.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.
class BillsController extends Controller{ public function index() { $bills = Bill::where('user_id', auth()->user()->id)->get(); return view('bills.index', compact('bills')); }}
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 modelspublic 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 modelspublic 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:
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'sboot()
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:
user_id
column just by adding the HasTenancy
trait.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.
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!
We appreciate your interest.
We will get right back to you.