I pointed one Eloquent-style client at 62 public APIs. Here's how they answered.
DEV Community

I pointed one Eloquent-style client at 62 public APIs. Here's how they answered.

Introduction

At work I had a lot of external APIs to talk to from Laravel. Each one got its own little client, its own pagination loop, its own way of saying "not found". I wanted the thing I already knew, Post::where(...)->paginate(), to work against all of them, so I wrote laravel-rest. Later I spent a while on the performance side (eager loading, concurrent requests, memoization) and started wondering whether the whole approach only looked uniform because I'd only tested it on APIs I'd chosen.

So I wrote a catalog of public APIs picked for structure rather than topic: page, offset and cursor pagination; bare arrays and every envelope shape; JSON:API, OData, Socrata, GraphQL, JSON-RPC. Then I pointed the package at them. Real requests, no mocks. 62 APIs, 470 scenarios, and a nightly run. This post is mostly what came back on the wire.

From the Wire to a Model

PokéAPI

PokéAPI first, because it's the plain case. This is what the endpoint returns:

GET https://pokeapi.co/api/v2/pokemon?limit=3&offset=0
HTTP 200
{"count":1351,"next":"https://pokeapi.co/api/v2/pokemon?offset=3&limit=3","previous":null,
"results":[{"name":"bulbasaur","url":"https://pokeapi.co/api/v2/pokemon/1/"},
{"name":"ivysaur","url":"https://pokeapi.co/api/v2/pokemon/2/"},
{"name":"venusaur","url":"https://pokeapi.co/api/v2/pokemon/3/"}]}

The model says where the rows are and what the key is; the client config says where the pagination metadata is:

final class Pokemon extends Model {
    protected ?string $endpoint = 'pokemon';
    protected ?string $dataKey = 'results';   // rows live under "results"
    protected string $primaryKey = 'name';    // PokéAPI addresses by name, not id
}

// client config
'pagination' => [
    'style' => 'offset',
    'total' => 'count',
    'next'  => 'next',
],

And this is what comes out. Real values from a run, not a mock:

$page = Pokemon::paginate(3);
$page::class                              // Illuminate\Pagination\LengthAwarePaginator
$page->total()                             // 1351
$page->lastPage()                          // 451
$page->getCollection()                     // Sanchescom\Rest\Collection(an Illuminate Collection)
$page->items()[0]                          // Pokemon {name: "bulbasaur", url: "https://pokeapi.co/api/v2/pokemon/1/"}
$page->items()[0]->name                    // "bulbasaur"

One request. The paginator is the same class Eloquent hands you, so it drops into a Blade view or an API resource unchanged.

Art Institute of Chicago

Now an API with an envelope and a foreign key:

GET https://api.artic.edu/api/v1/artworks/27992?fields=id,title,artist_id
HTTP 200
{"data":{"id":27992,"title":"A Sunday on La Grande Jatte - 1884","artist_id":40810},
"info":{...},"config":{...}}
final class Artwork extends Model {
    protected ?string $endpoint = 'artworks';
    protected ?string $dataKey = 'data';
    public function artist(): BelongsTo {
        return $this->belongsTo(Agent::class, 'artist_id');   // → GET agents/{artist_id}
    }
}

final class Agent extends Model {
    protected ?string $endpoint = 'agents';
    protected ?string $dataKey = 'data';
}

// client config
'pagination' => [
    'style' => 'page',
    'total' => 'pagination.total',
    'next'  => 'pagination.next_url',
],
$work = Artwork::withQuery(['fields' => 'id,title,artist_id'])->get(27992);
$work::class                              // Artwork
$work->toArray()                          // ['id' => 27992, 'title' => 'A Sunday on La Grande Jatte - 1884', 'artist_id' => 40810]
$work->title                              // "A Sunday on La Grande Jatte - 1884"

The envelope is gone; info and config never reach the model. Then the part I actually built the package for:

$works = Artwork::withQuery(['ids' => '27992,28560', 'fields' => 'id,title,artist_id'])
    ->with('artist')->get();
$works::class                              // Sanchescom\Rest\Collection
$works->map(fn($w) => [$w->title, $w->artist->title])->all()
// [
//     ['The Bedroom','Vincent van Gogh'],
//     ['A Sunday on La Grande Jatte - 1884', 'Georges Seurat'],
// ]

Three requests on the wire: the list, then both artists at once:

GET artworks?ids=27992,28560&fields=id,title,artist_id
{"data":[{"id":28560,"title":"The Bedroom","artist_id":40610},
{"id":27992,"title":"A Sunday on La Grande Jatte - 1884","artist_id":40810}],...}
GET agents/40610 } both at once
GET agents/40810 }
{"data":{"id":40810,"title":"Georges Seurat"},...}

Same paginate(), same with(), same get($id) on crates.io, where the page size is called per_page and the total sits under meta.total:

'query' => [
    'names' => ['limit' => 'per_page'],
],
'pagination' => [
    'style' => 'page',
    'total' => 'meta.total',
    'next'  => 'meta.next_page',
],

The same goes for the query builder. One where() and one orderBy(), and the grammar picked for the client decides how they hit the wire:

->where('document__slug', 'in', [...])->orderBy('name')
plain?document__slug=kp,dmag-e&sort=name
JSON:API?filter[document__slug]=kp,dmag-e&sort=name
django?document__slug__in=kp,dmag-e&ordering=name<- Open5e, Spaceflight News
OData?$filter=...&$orderby=ProductName desc <- custom Grammar class, ~30 lines
CKAN?fq=license_id:cc-by&sort=name asc <- custom Grammar class

That's the whole idea: the model and a few config lines absorb the API's shape, and the calling code doesn't know which API it's talking to.

What It Does with the Requests

This is the part I'd spent the most time on, and the part I most wanted to see against real latency rather than a fake. Numbers below are single runs from my laptop, requests counted by a Guzzle history middleware.

Eager Loading

Four artworks, four different artists. Lazy access is the classic N+1: five requests, one after another:

$works = Artwork::withQuery($q)->get();
foreach ($works as $w) {
    $w->artist->title;   // GET agents/{id}, four times, sequentially
}
// 5 requests, 782 ms
Artwork::withQuery($q)->with('artist')->get();
// 5 requests, 144 ms - the four agents go out concurrently through a Guzzle Pool

batch() turns the N into one whereIn.

DocumentV1::limit(3)->page(4)->with('spellsByDocument')->get();
// concurrent
// 4 requests, 4227 ms (Open5e is slow; that's the API, not the pool)

DocumentV1::limit(3)->page(4)->with('spells')->get();
// ->batch()
// 2 requests, 585 ms
GET v1/documents/?limit=3&page=4
GET v1/spells/?document__slug__in=kp,dmag-e,warlock

And the caveat this run taught me: one batch is one request, so it returns one page. Concurrent mode counted 31 + 50 + 43 spells; batch mode counted 10 + 24 + 16. That's exactly 50, Open5e's page size, spread across the three parents. That was implicit in the docs and is now explicit, and paging through the batch is on the roadmap.

getMany()

Fetches a known set of ids through the same pool:

foreach ($names as $n) {
    PokemonDetail::get($n);
}
// 5 requests, 344 ms

PokemonDetail::getMany($names);
// 5 requests, 225 ms

Only 1.5x here, because PokéAPI detail bodies are ~200 KB each, so this one is bandwidth-bound, not latency-bound. Honest number.

Memoization

Memoization is per request cycle: the same query twice inside one job or one HTTP request hits the API once. Meant for the case where three services in the same request all ask for the current user.

Rest::memoize();
for ($i = 0; $i < 5; $i++) {
    Artwork::get(27992);
}
// 1 request

Response Cache

Response cache is the durable one: any PSR-16 store, opt-in per query:

Artwork::withCache(60)->get(27992);
// 1 request, 57 ms

Artwork::withCache(60)->get(27992);
// 0 requests, 0 ms

None of this is exotic. It's what Eloquent users already expect from with() and the cache facade, done over HTTP, and it survived contact with 62 APIs whose only shared trait is that they answer GET.

Across the Catalog

paginate() passed on 25 APIs, lazy() on 25, with() eager loading on 9, whereIn() on 9. Every feature the package claims ended up confirmed on at least three APIs that are built differently. So the theory held.

The Other 104 Scenarios

The rest of this post is the other 104 scenarios: the ones that told me something about APIs rather than about the package.

"Not Found"

I assumed a missing record means HTTP 404. Seven APIs disagree. The two I liked best:

GET https://icanhazdadjoke.com/j/doesnotexist
HTTP 200
{"message":"Joke with id \"doesnotexist\" not found","status":404}

GET https://fakestoreapi.com/products/99999
HTTP 200 (empty body)

IBGE answers [], World Bank a 200 with "Invalid value" in the body, Wikipedia a 200 with pages["-1"].missing. And OpenF1 does the reverse: a filter that matches nothing is a 404:

GET https://api.openf1.org/v1/sessions?meeting_key=1
HTTP 404
{"detail":"No results found."}

ModelNotFoundException keys off the status code, so on those seven it never fires, and on OpenF1 it fires when the answer is "zero rows".

Where the Total Is

paginate() needs a total. Twelve APIs don't put one in the body.

GET https://jsonplaceholder.typicode.com/posts?_page=1&_limit=5
HTTP 200
x-total-count: 100
link: <...?_page=2&_limit=5>; rel="next", <...?_page=20&_limit=5>; rel="last"

GET https://quotesondesign.com/wp-json/wp/v2/posts?per_page=2
HTTP 200
x-wp-total: 1086
x-wp-totalpages: 543
link: <...?per_page=2&page=2>; rel="next"

Socrata and Open Brewery DB want a second request for the count. Radio Browser has no count anywhere. The pagination config only knows how to read body paths, so all of these are recorded as limitations, and "let 'total' => 'header:X-Total-Count' work" went onto the 1.7 roadmap, because twelve APIs asked for it.

/resource/{id}

GET https://services.odata.org/V4/Northwind/Northwind.svc/Products/1
HTTP 400
{"error":{"message":"The request URI is not valid. Since the segment 'Products' refers to a collection, this must be the last segment..."}}

GET https://services.odata.org/V4/Northwind/Northwind.svc/Products(1)
HTTP 200
{"ProductID":1,"ProductName":"Chai",...}

GET https://hacker-news.firebaseio.com/v0/item/1
HTTP 301
Location: https://console.firebase.google.com/project/firebase-hacker-news/...

GET https://hacker-news.firebaseio.com/v0/item/8863.json
HTTP 200
{"by":"dhouston","id":8863,"kids":[9224,8917,...],...}

Forget the .json on Hacker News and you're redirected to the Firebase admin console. Multiple ids are a whole separate topic:

GET https://servicodados.ibge.gov.br/api/v1/localidades/estados/33|35
HTTP 200
[{"id":33,"sigla":"RJ",...},{"id":35,"sigla":"SP",...}]

GET https://rickandmortyapi.com/api/character?id=1,2
HTTP 200
{"info":{"count":826,"pages":42,...},"results":[...]}  <- id= silently ignored, full collection

GET https://rickandmortyapi.com/api/character/1,2
HTTP 200
[{"id":1,"name":"Rick Sanchez",...},{"id":2,...}]

Sixteen APIs in the catalog don't answer to {endpoint}/{id}: some use a different shape, several (openFDA, Treasury Fiscal Data, AviationWeather) have no single-record route at all. from() covers the fixed-path cases; the Products(1) and item/{id}.json shapes need a per-model path template the package doesn't have yet.

whereIn

Five spellings across eight APIs, and one of them is actively rejected:

GET https://api.gbif.org/v1/species/search?rank=GENUS,FAMILY
HTTP 400
Cannot parse GENUS,FAMILY into a known Rank

GET https://api.gbif.org/v1/species/search?rank=GENUS&rank=FAMILY
HTTP 200
{"count":4769220,...}

json-server and OpenF1 also want the repeated form; crates.io wants ids[]=serde&ids[]=rand; IBGE wants the pipe in the path above; Rick and Morty wants the comma in the path. The package renders a comma-joined value, right for some and silently wrong for others. PHP's http_build_query default (id[0]=1&id[1]=2) matched none of them: GBIF answers it with the unfiltered total, OpenF1 with a 404.

Sorting

GET https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson&sort=-magnitude
HTTP 400
Unknown parameter "sort".

USGS puts the direction inside the value: orderby=magnitude is descending, orderby=magnitude-asc is ascending. Same API, one more:

GET https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson&offset=0
HTTP 400
Bad offset value "0". Valid values are 1 <= offset

Offsets start at one. Every offset-style paginator I've seen starts at zero.

Radio Browser accepts the wrong sort parameter without complaint and just doesn't sort:

GET .../stations/search?limit=3&sort=-votes
votes: 6, 922, 267 <- not sorted, no error

GET .../stations/search?limit=3&order=votes&reverse=true
votes: 824730, 569078, 432898

That second kind, accepted and ignored, is the one that gets past a test suite. GBIF, Rick and Morty and ReqRes have no sorting at all and behave the same way: sort=name returns 200 and the default order.

The Body Isn't a List of Objects

GET https://binaryjazz.us/wp-json/genrenator/v1/genre/
HTTP 200
"motown techno"

GET https://hacker-news.firebaseio.com/v0/topstories.json
HTTP 200
[49731285,49732931,49733836,49732270,...]

GET https://dog.ceo/api/breeds/list/all
HTTP 200
{"message":{"affenpinscher":[],"african":["wild"],"airedale":[],"australian":["kelpie","shepherd"],...}}

GET https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41&hourly=temperature_2m
HTTP 200
{"hourly":{"time":["2026-09-16T00:00","2026-09-16T01:00",...],"temperature_2m":[11.2,10.9,...]}}

Column-oriented. hourly.time[i] pairs with hourly.temperature_2m[i].

GET https://opensky-network.org/api/states/all?lamin=45&lomin=5&lamax=48&lomax=11
HTTP 200
{"time":1789598115,"states":[
["4401e9","EJU67FK ","Austria",1789598114,1789598115,8.153,46.2286,6896.1,false,179.12,158.08,-8.78,null,7193.28,"1000",false,0],
["4401e8","EJU72VU ","Austria",1789598114,1789598114,8.742,45.5995,320.04,false,69.21,348.86,-3.9,null,373.38,"0505",false,0],
...]}

Seventeen positions, no keys. Index 6 is latitude; you're expected to know.

This one found a real bug in my code. A row that is a JSON list passes the is_array() guard in Builder::hydrate(), reaches Model::fill() with integer keys, and blows up in isFillable(string $key) with a raw TypeError, on a perfectly good HTTP 200. Dog CEO's breed map hits the same line. I'd never have written a fixture like that, because I'd never have imagined it.

Making the Run Honest

Two things I got wrong before the

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.