Laravel: Are Observers really evil?
Is the developer out of touch? No, is the framework who is wrong…
There are two ways to hook into an Eloquent Model Event: using your Model boot and registering a callback for a given event name, or registering an Observer.
For small tasks, going for a simple callback is enough. You can even do it in your Eloquent Model traits and call it a day, and traits can be shared across multiple Models ensuring you can reuse the same logic across your Models, or even other unrelated classes.
Observers, however, are not that simple. They can contain callbacks to all the Eloquent Events for a particular Model, and when used unscrupulously, they can become real evil. Just imagine hours debugging why a Model had an unexpected behavior, why it didn’t save what you wanted, or retrieved a weird value that doesn’t exist anywhere.
This article will talk about how to not become The Root of all Evil with Observers, the overkill solution to handle multiple tasks over a Model from a single perspective.
By the way, you usually do not need Observers
This is my first, and favorite, tip for those who are checking Observers for the first time. If your logic can be done using a simple callback registered in your Model at boot, then you can skip creating an Observer.
Observer best practices
Let’s imagine we have a payment application. Every time we process a transaction, we need to update the amount that we owe to the Merchant for processing the payment, which we will later consolidate by a wire transfer to his bank account minus the platform fees.
Let’s translate our
ConsolidateTransaction Observer in plain english.
After we have created a Payment or Charge on behalf of a Merchant, we will add the amount to consolidate to his bank account, as long it can be done.
If someones dares to change an amount, we will update the consolidation based on any difference from the previous amount, before the changes are lost.
This Observer comprises several good practices I have collected along my way with Laravel, and that you should also take into consideration.
1. Observers have one responsibility
The first thing you need to understand when dealing with Observers is that one Observer should have one single and straightforward responsibility.
I’m not talking about having just “one method”, but rather, one overall task.
ConsolidateTransaction is very clear from the very name: it consolidates transactions. It doesn’t generates an UUID, it doesn’t refreshes the Cache for a model, or deals with the Bank API more than it has to. For these tasks I use a simple callback, or other Observers, even if is tempting to put them on a single Observer.
Always consider an Observer running on isolation, and never depending on other Observers. If that’s the case, you may consider a Pipeline instead.
2. Observers hear multiple events
Things start to become tricky when you need to hear multiple events on a model. This is when Observers come to play, since a single class you have access to all Eloquent Model events, and you can even call other events or methods within one.
While this is possible with normal callbacks, trying to add multiple can make the Model bloated very quickly.
You may also think about an Observer that hears one single event, but needs multiple protected methods to handle multiple parts of its responsibility — nobody wants to read or debug a single function of 900 lines.
3. Observers may use Dependency Injection
The advantage of Observers over plain events callbacks is the possibility of using Dependency Injection on the object constructor.
In this Observer in particular I’m requiring a Bank API, which was registered outside the example in the Service Container, that will allow me to peek into each Merchant Account.
I only need to require it in my Observer instead of manually instance them in each method, like I would using a callback.
4. Observers may be tested
Since our Observer is resolved by the Service Container, and its dependencies too, we can test the Observer has a whole, separately, or just mock it for quick tests where we need to skip all (or a part) of its logic.
For example, since we don’t want to test the Observer in a particular test, we can just mock it as a whole instead of dealing with the underlying Bank API calls.
Mocking the Observer it’s not meant to be abused either. You should always test it and its dependencies calls.
5. Observers may be shared
You can count on Observer being shared across two or more Eloquent Models. This is one great advantage over simple callbacks, because you can also assert in a test if the callback was called and for which model.
Because you don’t need to copy-paste the same behavior on each Model, and PHP 8.0 and onwards support union-types, registering an Observer on multiple models can help reducing that technical debt of maintaining the same behavior in different places.
6. Observers can be registered in the Model too
Long time ago developers only though that Observers were registrable only on the
EventServiceProvider. Hey, you can also register them inside the Eloquent Model at boot time.
Since the booting is deferred until the Model is required, the Observer won’t be properly “registered” in the Event Dispatcher — the responsible of firing the Observer methods — on the application.
If you need the Observer to be always registered for whatever reason, even if the Model is not needed at all, then the classic registration on the
EventServiceProvider is mandatory.
As you can see, Observers are not evil, not by a longshot. The culprit is mostly one single Observer doing all the tasks when it doesn’t have to, which leads to spaguetti code and testing headaches.
When used right, an Observer can ease the development pain of hearing multiple Eloquent Events even on multiple Models, and reusing logic between each event.
If you haven’t found any opportunity to use an Observer in your project, don’t worry: it’s fine if your task can be managed with a single callback in your Model.