The Case for Model-Centric Laravel

Helium Services
7 min readNov 10, 2020

Laravel is a web MVC framework, providing a solid foundation for quickly and easily spinning up web applications. If you’ve spent any amount of time in the Laravel community, you’ll probably know that the framework is designed to deliver Controller-centric application logic, meaning that most of your application’s processes are implemented directly inside of Controller classes.

For example, take the documentation on Laravel’s included system for Validation. You’ll see that the logic for validating data is designed for use inside of a Controller with an incoming Request object. Similarly, see how the built-in File Storage system allows you to push uploaded files directly from the Controller. These are just a couple of examples, but you can continue reading through the Laravel documentation to find many more examples of how the framework is literally designed for Controller-centric logic.

An Alternate Perspective

After many months of experimenting and improving the idea, I come now to propose a Model-centric approach to the Laravel framework. Granted, it is unlikely that this approach will be formally incorporated into the framework itself, but rather, I propose a technique for using the framework which diverges from the current standard.

Why?

Whether you are a newcomer to the framework or a Laravel veteran, you may be questioning the value of this approach. In fact, the Model-centric approach has several benefits, the most significant of which is that it centralizes code into reusable model functions and reduces the need for boilerplate in the controller.

Organizing logic into model functions modularizes your application logic, which makes it easier to re-use between Web and API controllers, Background Jobs, Seeders, and even Tinker, rather than manually recreating it each time it is needed. The other major benefit is that it allows you to develop a significant chunk of logic via “configuration” by simply including traits and setting model variables. In fact, this is how the base Model class already functions, with variables to configure model behavior, such as $hidden, $fillable, $dates, $dateFormat, etc.

A Good Starting Point

Before I begin, I would be remiss not to mention the ways in which Laravel is already designed to allow for alternate approaches, including some specific components which are already Model-centric:

Firstly, most of the Controller-centric components, such as the Validation system, also provide methods for manual usage outside of the Controller.

Secondly, there are a number of existing Laravel components which enhance your Models directly, including the Laravel Cashier Billable trait which provides member functions for creating, associating, and removing payment methods.

Lastly, the Model class itself includes functionality for extension and customization, such as attribute accessors and mutators, the boot function, and the ability to boot traits.

Technique and Examples

The focal point of the Model-centric approach is the inclusion of reusable traits and configurable member variables on the class, much like $fillable and other configuration options on the base Model class.

Side Note

All traits used in the following examples can be find in the Helium Laravel Helpers repository, and can be installed into your project with composer require helium/laravel-helpers. Please also note that this package is a bit messy, as it is the product of months of random experimentation. We have plans to rewrite all of the included components as formal, well-maintained packages in the near future.

Lastly, please understand that this is not a tutorial on how to use the helium/laravel-helpers package, but rather, a discussion of the general technique. We will publish formal tutorials on each component once we have re-built the package.

Example 1: Model Phone Numbers

A common requirement for many models is a phone_number field, but formatting can often cause a lot of problems. Even if you enforce formatting on your front-end, you should still enforce that formatting on the back-end to ensure that it is standardized across all of your application front-end clients.

In my experience, the easiest way to standardize your formatting on the back-end is to strip out any special characters and store just the digits, then handle formatting on the front-end for display purposes only.

Herein lies the Controller-centric approach:

class UserController extends Controller
{
protected function stripPhoneFormatting(array $data) {
$data['phone_number'] = preg_replace(
'/[^0-9]/',
'',
$data['phone_number']
);
return $data;
}
public function store(Request $request) {
$data = $this->stripPhoneFormatting($request->all());
return User::create($data);
}
public function update(User $user, Request $request) {
$data = $this->stripPhoneFormatting($request->all());
$user->update($data); return $user;
}
}

Now, consider the Model-centric method:

class User extends Model
{
use HasPhoneNumbers;
}

class UserController extends Controller
{
public function store(Request $request)
{
return User::create($request->all());
}
public function update(User $user, Request $request)
{
$user->update($request->all());
return $user;
}
}

With the HasPhoneNumbers trait, the phone_number field is automatically stripped of all special characters upon being set, with no need to write and apply a helper function each time you need to update a Model.

For more advanced configuration, such as custom phone number fields, set the phoneNumbers array on the Model:

class User extends Model
{
use HasPhoneNumbers;
protected $phoneNumbers = [
'home_phone_number',
'cell_phone_number',
'office_phone_number'
];
}

Example 2: Model Self-Validation

Now let’s take the example of validating your model’s data attributes. Traditionally, this is handled in the Controller:

class UserController extends Controller
{
public function store(Request $request)
{
$validatedData = $request->validate([
'name' => 'required|string',
'email' => 'required|email',
'age' => 'nullable|integer'
]);
return User::create($validatedData);
}
public function update(User $user, Request $request)
{
$validatedData = $request->validate([
'name' => 'sometimes|required|string',
'username' => 'sometimes|required|string',
'age' => 'sometimes|required|integer'
]);
$user->update($validatedData); return $user;
}
}

Now consider the Model-centric approach:

class User extends Model
{
use SelfValidates;

protected $validatesOnSave = true;
protected $validationRules = [
'name' => 'required|string',
'email' => 'required|email',
'age' => 'required|integer'
];
}

class UserController extends Controller
{
public function store(Request $request)
{
return User::create($request->all());
}
public function update(User $user, Request $request)
{
$user->update($request->all());
return $user;
}
}

Although this example obscures the logic that actually performs validation on the model, that’s kind of the point. In short, just before the model is saved (i.e., create, update, or save is called), the internal $attributes array is validated against your defined ruleset. Not only does this again reduce the need to reproduce logic each time you update a Model, but it also protects the Model from errant updates that are applied manually after the initial Controller validation, since it is validated at the last possible instance before being saved to the database.

For more advanced configuration, such as conditional validation, simply write a validationRules function, instead of an attribute.

class User extends Model
{
use SelfValidates;
protected $validatesOnSave = true; protected function validationRules()
{
$rules = [
'user_type' => [
'required',
Rule::in(['admin', 'user'])
],
'name' => 'required|string',
'email' => 'required|email',
'age' => 'required|integer'
];
if ($this->user_type = 'admin') {
$rules['admin_group_id'] =
'required|exists:admin_groups,id';
}
return $rules;
}
}

Example 3: Combining Requirements

This last point isn’t as much of a demonstration as it is a discussion.

The previous two examples may not seem to have any significant gains in simplicity over the traditional Controller-centric method, at least just by looking at the number of lines of code. However, consider combining the previous two examples into one User Model and Controller, then add in other real-world requirements, such as uploading files S3. As you can imagine, the Controller-side code can start to get a bit bloated, and becomes very repetitive.

Now take a look at the Model-centric perspective. Sure, there’s a bit of configuration you’ll have to include in each Model class, but remember that you only have to write the configuration once for each model. Plus, all you have to do is handle writing the configuration variables each time, rather than re-writing the same logic each time it’s needed since the logic is implemented once in the Trait and reused.

The final benefit is the fact that there’s no risk that you or another developer will forget to include a certain bit of logic when adding new endpoints to your app. Whether it’s a Background Job, Controllers, or Seeders, you will almost always want to make sure that the same requirements are enforced. The Model-centric approach ensures that rules are always applied consistently across the board, and removes the requirement on the developer to be actively aware of all of the requirements at all times.

Conclusion

Model-centric Laravel has been my primary mode for development for the past several months, and I’m never turning back! The examples shown here are just a small sampling of the things you can accomplish with Model-centric application logic, and again, you can find a whole host of other Traits I’ve built over on the Helium Laravel Helpers repository.

With the size and diversity of the Laravel community, I hope that this alternate take on the framework inspires others to explore and test the boundaries with what you can do with it! Like with life, it’s easy to get stuck in one mode of thinking without realizing what else is out there, so you should experiment, play around, and see what is possible. And if anybody comes up with an interesting idea, be sure to share it and continue this discussion for the benefit of everybody in this community!

--

--