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

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

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/"}]} Enter fullscreen mode Exit fullscreen mode 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'], Enter fullscreen mode Exit fullscreen mode 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" Enter fullscreen mode Exit fullscreen mode 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":{...}} Enter fullscreen mode Exit fullscreen mode 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'], Enter fullscreen mode Exit fullscreen mode $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" Enter fullscreen mode Exit fullscreen mode 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'], // ] Enter fullscreen mode Exit fullscreen mode 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"},...} Enter fullscreen mode Exit fullscreen mode 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'], Enter fullscreen mode Exit fullscreen mode 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 get(); foreach ($works as $w) { $w->artist->title; // GET agents/{id}, four times, sequentially } // 5 requests, 782 ms Enter fullscreen mode Exit fullscreen mode Artwork::withQuery($q)->with('artist')->get(); // 5 requests, 144 ms — the four agents go out concurrently through a Guzzle Pool Enter fullscreen mode Exit fullscreen mode 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 Enter fullscreen mode Exit fullscreen mode 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 Enter fullscreen mode Exit fullscreen mode 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 get(27992); // 1 request, 57 ms Artwork::withCache(60)->get(27992); // 0 requests, 0 ms Enter fullscreen mode Exit fullscreen mode 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} Enter fullscreen mode Exit fullscreen mode GET https://fakestoreapi.com/products/99999 HTTP 200 (empty body) Enter fullscreen mode Exit fullscreen mode 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."} Enter fullscreen mode Exit fullscreen mode 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: ; rel="next", ; rel="last" Enter fullscreen mode Exit fullscreen mode GET https://quotesondesign.com/wp-json/wp/v2/posts?per_page=2 HTTP 200 x-wp-total: 1086 x-wp-totalpages: 543 link: ; rel="next" Enter fullscreen mode Exit fullscreen mode 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",...} Enter fullscreen mode Exit fullscreen mode 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,...],...} Enter fullscreen mode Exit fullscreen mode 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",...}] Enter fullscreen mode Exit fullscreen mode 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,...}] Enter fullscreen mode Exit fullscreen mode 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,...} Enter fullscreen mode Exit fullscreen mode 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". Enter fullscreen mode Exit fullscreen mode 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 Enter fullscreen mode Exit fullscreen mode 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 Enter fullscreen mode Exit fullscreen mode 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" Enter fullscreen mode Exit fullscreen mode GET https://hacker-news.firebaseio.com/v0/topstories.json HTTP 200 [49731285,49732931,49733836,49732270,...] Enter fullscreen mode Exit fullscreen mode GET https://dog.ceo/api/breeds/list/all HTTP 200 {"message":{"affenpinscher":[],"african":["wild"],"airedale":[],"australian":["kelpie","shepherd"],...}} Enter fullscreen mode Exit fullscreen mode 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,...]}} Enter fullscreen mode Exit fullscreen mode 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], ... ]} Enter fullscreen mode Exit fullscreen mode 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.

Original Source

Read the full article at Dev →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.