Cleaning up controllers with model observers
Heads up: This post is over 8 years old. People, opinions, and industries change — some of this may be outdated.
Lately I've been doing a lot of refactor work and wanted to share a great way to clean up some of those "god objects" and growing controllers. Often times we place a lot of responsibility on a single area of the application and it can get a bit hard to maintain.
For this piece, we will use a Blog post as an example. Lets pretend for a second the Spatie Sluggable package doesn't exist, and that they're not awesome at everything they do. I know, a hard feat!
Example
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
$post = new BlogPost($request->all());
$post->generateSlug(); // generate a url friendly slug of the article
$post->save();
$post->publish(); // some logic to officially publish the article
// return response
}
This is fairly straight forward. We are creating a new BlogPost, filling it with the form input and then performing a couple actions afterwards. There's really nothing wrong with this workflow. Though, like anything else this can be improved.
The issue I find with code like this is, there's critical functionality being called in a potentially hard to discover fashion. ~~If~~ When there are any updates, you'll need to track down where you've manually called these functions.
Model Observers
Model observers are very similar to Events & Listeners. You can trigger logic on events such as: creating, created, updating, deleting, etc. Observers, being their own classes, are abstracted away from the model.
Lets see what this could look like using a model observer.
/**
* BlogPost creating event
*
* @param \App\BlogPost $blogPost
*/
public function creating(BlogPost $blogPost)
{
$blogPost->generateSlug();
}
/**
* BlogPost created event
*
* @param \App\BlogPost $blogPost
*/
public function created(BlogPost $blogPost)
{
$blogPost->publish();
}
Now whenever the app is creating a new BlogPost, it will generate a slug before it finishes persisting to the datastore. Abstracting this logic here, removes the need of manually triggering critical logic.
Back to our BlogPostController.
/**
* Store a newly created resource in storage.
*
* @param \App\Http\Requests\BlogPostRequest $request
* @return \Illuminate\Http\Response
*/
public function store(BlogPostRequest $request)
{
try {
BlogPost::create($request->all());
} catch (Exception $exception) {
// error handling
}
// return response
}
We've reduced the logic to store a new instance to one line. That one line being a Laravel standard create() call. All critical logic is in one place behind the scenes.
We've also ditched the standard Illuminate request for a custom form request object BlogPostRequest. This way we know the data coming in has already passed a level of validation.
Now our responsibilities are contained. Our controllers handle requests and validation, models handle their data objects and our observers listen in between to ensure the consistency of our app.
When should I use Model Observers?
The best use for Observers are critical logic that needs to be performed during model CRUD events.
Take entering in a hotel booking for example. The observer can be the last opportunity to throw an exception -- maybe the room was reserved during the time it took to submit the request.
When not to use Model Observers?
Observers can really improve the maintainability of your projects, though like anything else, should be used with caution. Creating Observers that are hard to maintain and test defeats their purpose.
What about triggered third party services or API calls?
While this can be done with observers, in my opinion it should be kept to a minimum. When deciding where to put event driven functionality I ask myself a question.
"How many other events are being triggered by this particular event?"
If the answer is two or more, I'm likely to reach for a full featured Event and a Listener.
A use case for an event listener can be user registration. During this event a few things may need to happen:
- Send a welcome or confirmation email to the user.
- Process some data against the user profile, or build a report.
- Schedule a job to synchronize data to a marketing service.
Since a lot is happening there, it'd be better maintained in a full listener for that particular event.
Summary
- If there's logic that needs to be triggered on model events, an observer can help maintainability.
- Don't overcomplicate your model observers, the goal is maintainability and stability.
- If two or more events need to be triggered, reach for an event listener.
- Refrain from triggering data cascading logic. Keep this responsibility on the datastore itself when possible.
- Refrain from calling too many APIs or sending emails, those should be handled elsewhere.
- Like anything else, Observers should be tested, keep this in mind.