Eloquent (and by extension, Laravel) is often criticized because of its ActiveRecord ORM implementation, and that DataMapper produces a cleaner, more maintainable architecture in the long run.
I am going to avoid the debate about ActiveRecord vs DataMapper entirely by showing that this debate is a bit of a false dichotomy — while DataMapper can indeed be a cleaner architecture overall, Eloquent actually compliments a DataMapper-like architecture beautifully by making it very easy to implement one.
The primary criticism against Eloquent is that any given Eloquent object has FOUR distinct, high-level behaviors:
- Data Model (instance: business rules and logic, managing its own state)
- Row data gateway (instance: persistence of its data)
- Table data gateway (class: finding, deleting, and creating records)
- Factory (class: creating new instances)
This combination of behaviors crammed into a single object in itself is not the problem (in fact, it’s a wonderful asset), the problem is in how you make use of those behaviors throughout your application. You can be sloppy with them, or you can be tidy with them.
The Sloppy Approach
The sloppy approach is what has earned ActiveRecord (and Eloquent, by extension) its reputation — you have leaky abstractions all over the app. Controllers calling $user->save(), or doing something like Articles::where(‘active’, 1)->get() somewhere in a middleware, or calling a $post->comments relation in your views. Now, depending on the size of the app, this may not be a problem, however in my personal experience, it doesn’t take a very large app for this kind of lazy ActiveRecord usage to become hard to maintain and reason about. The solution? The tidy approach.
The Tidy Approach
The tidy approach aims to implement a DataMapper-like architecture that uses interfaces to emulate persistence-agnostic “POPOs” as data models, tucks away the row and table gateway behaviors behind repositories (which are also defined by interfaces), and keeps factory behavior in dedicated factories. The objectives for this extra effort are ambitious, but simple:
- I should be able to swap out any Eloquent model with a POPO (plain old PHP object), and my app should continue to work without requiring any changes (same goes for testing)
- I should be able to swap out Eloquent repositories for raw SQL repositories, or Doctrine repositories, or in-memory repositories, or even Mongo repositories and my app should continue working without requiring any changes (again, this benefits testing)
- I should be able to change the underlying database table column names, and only have to change their references in a SINGLE location in the app.
- Bonus, if I’m using an IDE, it should always autocomplete just the interface(s) methods I defined for my models, and not the massive clutter of public Eloquent methods. That way I can see quite clearly only the “domain behavior” I truly care about, and not concern myself with the broad range of persistence behavior I want to avoid.
To achieve these objectives, and implement the tidy approach, we only need to follow 8 simple rules:
- Put all model attributes, relations, and mutation calls behind dedicated methods, and define those with one or more interfaces.
- Define all Eloquent attributes/properties as class constants. Your database schema is more volatile than you think.
- Never use any Eloquent database or query builder behavior outside of an Eloquent repository, but feel free to use it liberally inside of Eloquent repositories or within Eloquent models themselves.
- Always retrieve models through a repository or relation, and always persist changes through a repository.
- Do not clutter Eloquent models with magic query scopes, finder methods, and magic attribute getters/setters. Define only relation calls, and an explicit instance-API. Be explicit. Always. It’s literally the best thing you can do for yourself as a programmer.
- In the vein of #5, treat all Eloquent models as instances only, not static classes for fetching and querying. Defer that behavior to repositories.
- Define all repositories with interfaces
- Put all new Eloquent model creation behind simple factories (optionally, define those factories with interfaces)