Faking Eloquent with Http API
One day you (or maybe not exactly you haha) wake up with the idea that some piece of software should finally stop talking directly to the database. Maybe you’re moving toward a service-oriented setup, maybe there’s a new API, maybe the DB just shouldn’t be exposed anymore. But the problem is simple: the system already works. There’s, for example, a Filament admin panel happily using Eloquent, and doing a full rewrite sounds… painful.
It could be that for some legacy reasons a few tabes were in that project accessible from another database and you want to cut the access to the second database. It also could be that many components already rely on the model. if you are using Filament and simply want to change the view - okay, Filament allow that. But it is only on the view, e.g. background jobs won’t simply start using the new data.
So here’s another, much more fun option — we can pretend that Eloquent is still doing its job, but secretly intercept its calls and feed it data from an API instead. Sounds crazy? Maybe a bit. But in reality it’s surprisingly easy, and can fit into roughly a hundred lines of code.
If you were already looking for options - you may have seen the Sushi package. It allows you to fake models with static data and with some adjustments you may inject API there. But what if I tell you that it is pretty easy to abstract the EloquentBuilder and replace with API calls?
To do so you have to create a new class. Let’s simply name it “ApiQueryBuilder” and we can keep it empty for now. Now, if you go to any Model that you would like to use the API - you may create a function there.
//Models/User.php
public function newEloquentBuilder($query)
{
return new ApiQueryBuilder(); //You may want to inject some services there later
}
This would enforce the model to use the custom Builder. Of course, it will immediately complain, because our newborn class isn’t a real Builder yet. So let’s fix that.
To do so we may simply extend the \\Illuminate\\Database\\Eloquent\\Builder method. You would need to fake just one method to prevent calling the database.
//e.g. Services/ApiQueryBuilder.php
public function setModel(Model $model)
{
$this->model = $model;
return $this;
}
On top of that we should implement methods that are preparing and executing statements to the database.
Sample class:
//e.g. Services/ApiQueryBuilder.php
class ApiQueryBuilder extends \\Illuminate\\Database\\Eloquent\\Builder
{
public function __construct(QueryBuilder $query, private ApiService $apiService)
{
$this->query = $query;
}
public function setModel(Model $model)
{
$this->model = $model;
return $this;
}
protected $apiParts = [
'with' => [],
'filter' => [],
'sort' => [],
];
public function with($relations, $callback = null)
{
$this->apiParts['with'] = array_merge(
$this->apiParts['with'],
(array) $relations
);
return $this;
}
public function where($column, $operator = null, $value = null, $boolean = 'and')
{
if ($column instanceof \\Closure) {
return $this;
}
if ($value === null) {
$value = $operator;
$operator = '=';
}
$this->apiParts['filter'][$column] = $value;
return $this;
}
public function orderBy($column, $direction = 'asc')
{
$this->apiParts['sort'][] = ($direction === 'asc' ? '' : '-') . $column;
return $this;
}
public function toApiQuery(): string
{
$query = [];
if (!empty($this->apiParts['with'])) {
$query['with'] = implode(',', $this->apiParts['with']);
}
foreach ($this->apiParts['filter'] as $key => $value) {
$query["filter[$key]"] = $value;
}
if (!empty($this->apiParts['sort'])) {
$query['sort'] = implode(',', $this->apiParts['sort']);
}
if (!empty($this->apiParts['search'])) {
$query['filter']['search'] = $this->apiParts['search'];
}
$model = get_class($this->model);
$model = array_last(explode('\\\\', $model));
return urldecode((sprintf("internal/%s?%s", strtolower($model), http_build_query($query))));
}
public function get($columns = ['*'])
{
return $this->apiService->getByQuery($this->toApiQuery());
}
public function getCountForPagination($columns = ['*'])
{
$query = $this->toApiQuery();
$query .= (str_contains($query, '?') ? '&' : '?') . http_build_query(['per_page' => 1, 'page' => 1]);
[$items, $total] = $this->apiService->getPaginatedByQuery($query);
return (int) $total;
}
public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null, $total = null) {
$page = $page ?: Paginator::resolveCurrentPage($pageName);
$perPage = value($perPage) ?: $this->model->getPerPage();
$apiQuery = $this->toApiQuery();
$apiQuery .= (str_contains($apiQuery, '?') ? '&' : '?') . http_build_query([
'page' => $page,
'per_page' => $perPage,
]);
[$resultsCollection, $totalCount] = $this->apiService->getPaginatedByQuery($apiQuery);
$results = $resultsCollection ?: $this->model->newCollection();
return $this->paginator($results, $totalCount, $perPage, $page, [
'path' => Paginator::resolveCurrentPath(),
'pageName' => $pageName,
]);
}
}
You may logically group these methods by three groups:
statement preparation methods that would now write request parts into
$this->apiPartsmethods that are actually performing the request -
paginateandgetAPI query preparation -
toApiQuery()
That’s it - it should allow you to use the API transparently like it would be for database access. Only reading obviously. You will have to implement the deletion/creating/modification on your own. You will also have to take care of apiService but it shouldn’t be a big deal at all.
Remember that we are bypassing the logic and not everything will work. What is expected to work:
Querying many items with:
sorting (
orderBy⇒sort=)filtering (
where⇒filter)relations (
with)
Querying a single item what is actually
filter[id]=123
What is not covered by this but would be possible to cover:
creating
updating
deleting
P.S. How should it look on the API side? If you are also building API - I may recommend you to use Spatie QueryBuilder package. It magically converts http request to database query given all allowed parameters from the query parameters.
//another project
class AnyRandomController
{
public function index(Request $request)
{
$query = QueryBuilder::for(User::class)->with(['posts'])
->allowedSorts(['id'])
->allowedFilters([AllowedFilter::exact('id')]);
return response()->json($query->get()); //or paginate()
}
}