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 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.
Now an API with an envelope and a foreign key, the Art Institute of Chicago:
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. Open5e's document → spells
relation, three parents:
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 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 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 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 numbers meant anything.
The first was outcome classification. A scenario can pass, fail, be skipped
because the API was down, or be unsupported, a documented limitation. But a
limitation has to prove itself: the scenario still makes the call, and if the
call unexpectedly succeeds, the run fails with "limitation no longer
reproduces". Otherwise the docs quietly drift pessimistic. And "down" needs a
per-API definition: restful-api.dev signals an exhausted daily quota with
HTTP 405, which the harness initially recorded as a package failure.
The second was my own scenarios. Every batch got a second, adversarial review
(an LLM agent that hadn't seen the scenarios), and it kept finding tests that
couldn't fail: an eager-loading scenario that stayed green with the eager loader
gutted because it checked the loaded data instead of counting requests; a filter
on a value every row shared; a relation method named author() on a model whose
response already had an author attribute, so __get() returned the attribute
and the relation was never called. In the other direction, eleven things I'd
written off as "the API can't do this" turned out to work fine once someone
actually tried.
And some of it was just the internet. Câmara dos Deputados timed out at
229 seconds on one request. The Art Institute's default first page drifted,
between writing the scenario and the full run, to five artworks by the same
artist, which broke an eager-loading
scenario that needs at least two distinct parents; the fix was to pin the ids.
The full matrix (every feature, which APIs confirm it, every limitation with
its reproduction) is generated from the run:
docs/live-verification.md.
Top comments (0)