APIs The Eloquent Way

Heads up: this was a technical demo.
The code in this article was built as a showcase.

Motivation

Almost every modern project requires third-party API integration — weather forecasts, stock data, and countless other use cases. While some APIs offer SDKs, many don't.

Quick access using ZTTP looks like:

$symbols = \Zttp\Zttp::get("https://api.iextrading.com/1.0/ref-data/symbols")->json();

As complexity grows, you need abstraction for errors, validation, and transformation. The goal: access APIs the way you access a database:

$user = User::where('email', $email)->first();

What if APIs worked the same way?

$symbol = IEX::Symbol()->where('symbol', $apiSymbol)->first();

Solution

The approach introduces an API → Endpoint → Shape pattern:

  • ApiConsumer — represents one API (e.g., JSONPlaceholder)
  • Endpoint — represents one resource (e.g., /posts), with chainable query methods
  • Shape — represents the data structure returned

Endpoints mirror Eloquent's query builder. Shapes mirror models. All results return as collections, enabling methods like filter() when the API itself doesn't support filtering:

$users = User::all();
$users = $users->filter(function ($value, $key) {
    return $value->age >= 21;
});

Shapes also support mutator-style custom methods (e.g., combining first_name + last_name), structural validation, and field normalization via $transformations.

Relations between endpoints are supported via hasOne() and hasMany():

$user = JSONPlaceholder::Post()->first()->user();

Results

The package black-bits/laravel-api-consumer provides:

  • Artisan generators for ApiConsumers, Endpoints, and Shapes
  • Chainable collection methods on Endpoints
  • Relation support between resources
  • Built-in caching out of the box

Show Case

Two APIs were implemented: IEX Trading and JSONPlaceholder.

ApiConsumers Setup

composer require black-bits/laravel-api-consumer
php artisan vendor:publish --provider="BlackBits\ApiConsumer\ApiConsumerServiceProvider"
php artisan make:api-consumer IEX
php artisan make:api-consumer-endpoint Symbol -c IEX

Add base URL to /config/api-consumers.php:

return [\
    'IEX' => [\
        'apiBasePath' => 'https://api.iextrading.com/1.0',\
    ],\
];

ApiConsumer class (/app/ApiConsumers/IEX/IEX.php):

namespace App\ApiConsumers\IEX;

use BlackBits\ApiConsumer\ApiConsumer;

class IEX extends ApiConsumer
{
    protected function getEndpoint()
    {
        return config('api-consumers.IEX.apiBasePath');
    }
}

Endpoint class with caching (SymbolEndpoint.php):

class SymbolEndpoint extends Endpoint
{
    protected $path = '/ref-data/symbols';
    protected $shouldCache = true;
    protected $cacheDurationInMinutes = 10;

public function all()
    {
        return $this->get();
    }
}

Shape class with transformations and relations (SymbolShape.php):

class SymbolShape extends BaseShape
{
    protected $return_shape_data_only = true;
    protected $require_shape_structure = true;

protected $transformations = [\
        'symbol'    => 'symbol',\
        'name'      => 'name',\
        'date'      => 'date',\
        'isEnabled' => 'is_enabled',\
        'type'      => 'type',\
        'iexId'     => 'iex_id'\
    ];

protected $fields = ['symbol', 'name', 'date', 'is_enabled', 'type', 'iex_id'];

public function company()
    {
        return $this->hasOne(CompanyEndpoint::class, 'symbol');
    }
}

Usage

$symbols = IEX::Symbol()->all();

// With relation
$company = IEX::Symbol()->first()->company();

Controller example with filtering:

public function list(Request $request)
{
    $symbols = IEX::Symbol();

if (!empty($request->get('symbol'))) {
        $symbol = $request->get('symbol');
        $symbols->filter(function ($value, $key) use ($symbol) {
            return str_contains($value->symbol, $symbol);
        });
    }

if (!empty($request->get('name'))) {
        $symbols->filterName($request->get('name'));
    }

$data = ['symbols' => $symbols->take(10)->get()];
    return response()->view('symbols.list', $data);
}

JSONPlaceholder example — fetch a post with its comments:

public function listComments($post)
{
    $post = JSONPlaceholder::Post()->where('id', $post)->first();

$data = [\
        'comments' => $post->comments(),\
        'post'     => $post\
    ];

return response()->view('json-placeholder.comments-list', $data);
}

Next Steps

  • Authentication support (next priority)
  • Additional HTTP methods (POST, PUT, etc.)
  • Improved relation functionality
  • Testing
  • Long-term: auto-generation for standardized endpoints (JSON API / Swagger)

Conclusion

The package aims to fill a gap in the Laravel ecosystem by offering a standardized, Eloquent-style interface for third-party API consumption. Open questions for feedback:

  • Thoughts on the Consumer → Endpoint → Shape structure?
  • Missing features?
  • Which APIs cause the most implementation friction?