<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Aleksandr Efimov</title>
    <description>The latest articles on DEV Community by Aleksandr Efimov (@sanchescom).</description>
    <link>https://dev.to/sanchescom</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4128556%2F1b73cd9b-b965-493b-850c-e19ba50a15e5.jpg</url>
      <title>DEV Community: Aleksandr Efimov</title>
      <link>https://dev.to/sanchescom</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sanchescom"/>
    <language>en</language>
    <item>
      <title>I pointed one Eloquent-style client at 62 public APIs. Here's how they answered.</title>
      <dc:creator>Aleksandr Efimov</dc:creator>
      <pubDate>Thu, 17 Sep 2026 01:21:02 +0000</pubDate>
      <link>https://dev.to/sanchescom/i-pointed-one-eloquent-style-client-at-62-public-apis-heres-how-they-answered-3g3o</link>
      <guid>https://dev.to/sanchescom/i-pointed-one-eloquent-style-client-at-62-public-apis-heres-how-they-answered-3g3o</guid>
      <description>&lt;p&gt;At work I had a lot of external APIs to talk to from Laravel. Each one got its&lt;br&gt;
own little client, its own pagination loop, its own way of saying "not found".&lt;br&gt;
I wanted the thing I already knew, &lt;code&gt;Post::where(...)-&amp;gt;paginate()&lt;/code&gt;, to work&lt;br&gt;
against all of them, so I wrote &lt;a href="https://github.com/sanchescom/laravel-rest" rel="noopener noreferrer"&gt;laravel-rest&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Later I spent a while on the performance side (eager loading, concurrent&lt;br&gt;
requests, memoization) and started wondering whether the whole approach only&lt;br&gt;
looked uniform because I'd only tested it on APIs I'd chosen. So I wrote a&lt;br&gt;
catalog of public APIs picked for &lt;em&gt;structure&lt;/em&gt; rather than topic: page, offset&lt;br&gt;
and cursor pagination; bare arrays and every envelope shape; JSON:API, OData,&lt;br&gt;
Socrata, GraphQL, JSON-RPC. Then I pointed the package at them. Real requests, no&lt;br&gt;
mocks. 62 APIs, 470 scenarios, and a nightly run.&lt;/p&gt;

&lt;p&gt;This post is mostly what came back on the wire.&lt;/p&gt;
&lt;h2&gt;
  
  
  From the wire to a Model
&lt;/h2&gt;

&lt;p&gt;PokéAPI first, because it's the plain case. This is what the endpoint returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://pokeapi.co/api/v2/pokemon?limit=3&amp;amp;offset=0
HTTP 200
{"count":1351,"next":"https://pokeapi.co/api/v2/pokemon?offset=3&amp;amp;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/"}]}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model says where the rows are and what the key is; the client config says&lt;br&gt;
where the pagination metadata is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Pokemon&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pokemon'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$dataKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'results'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// rows live under "results"&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$primaryKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// PokéAPI addresses by name, not id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// client config&lt;/span&gt;
&lt;span class="s1"&gt;'pagination'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'style'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'offset'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'total'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'count'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'next'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'next'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And this is what comes out. Real values from a run, not a mock:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Pokemon&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;paginate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;             &lt;span class="c1"&gt;// Illuminate\Pagination\LengthAwarePaginator&lt;/span&gt;
&lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;total&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;           &lt;span class="c1"&gt;// 1351&lt;/span&gt;
&lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;lastPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;        &lt;span class="c1"&gt;// 451&lt;/span&gt;
&lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getCollection&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// Sanchescom\Rest\Collection  (an Illuminate Collection)&lt;/span&gt;
&lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;        &lt;span class="c1"&gt;// Pokemon {name: "bulbasaur", url: "https://pokeapi.co/api/v2/pokemon/1/"}&lt;/span&gt;
&lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;  &lt;span class="c1"&gt;// "bulbasaur"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One request. The paginator is the same class Eloquent hands you, so it drops&lt;br&gt;
into a Blade view or an API resource unchanged.&lt;/p&gt;

&lt;p&gt;Now an API with an envelope and a foreign key, the Art Institute of Chicago:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;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":{...}}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Artwork&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'artworks'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$dataKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'data'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;artist&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;BelongsTo&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;belongsTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'artist_id'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// → GET agents/{artist_id}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'agents'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$dataKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'data'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// client config&lt;/span&gt;
&lt;span class="s1"&gt;'pagination'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'style'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'page'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'total'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'pagination.total'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'next'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'pagination.next_url'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$work&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Artwork&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withQuery&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'fields'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'id,title,artist_id'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;27992&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$work&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;      &lt;span class="c1"&gt;// Artwork&lt;/span&gt;
&lt;span class="nv"&gt;$work&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toArray&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;// ['id' =&amp;gt; 27992, 'title' =&amp;gt; 'A Sunday on La Grande Jatte — 1884', 'artist_id' =&amp;gt; 40810]&lt;/span&gt;
&lt;span class="nv"&gt;$work&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;      &lt;span class="c1"&gt;// "A Sunday on La Grande Jatte — 1884"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The envelope is gone; &lt;code&gt;info&lt;/code&gt; and &lt;code&gt;config&lt;/code&gt; never reach the model. Then the part&lt;br&gt;
I actually built the package for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$works&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Artwork&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withQuery&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'ids'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'27992,28560'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'fields'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'id,title,artist_id'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'artist'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nv"&gt;$works&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;   &lt;span class="c1"&gt;// Sanchescom\Rest\Collection&lt;/span&gt;
&lt;span class="nv"&gt;$works&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$w&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$w&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;artist&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="c1"&gt;// [&lt;/span&gt;
&lt;span class="c1"&gt;//   ['The Bedroom',                        'Vincent van Gogh'],&lt;/span&gt;
&lt;span class="c1"&gt;//   ['A Sunday on La Grande Jatte — 1884', 'Georges Seurat'],&lt;/span&gt;
&lt;span class="c1"&gt;// ]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three requests on the wire: the list, then both artists at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET artworks?ids=27992,28560&amp;amp;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"},...}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same &lt;code&gt;paginate()&lt;/code&gt;, same &lt;code&gt;with()&lt;/code&gt;, same &lt;code&gt;get($id)&lt;/code&gt; on crates.io, where the page&lt;br&gt;
size is called &lt;code&gt;per_page&lt;/code&gt; and the total sits under &lt;code&gt;meta.total&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'query'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'names'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'limit'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'per_page'&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
&lt;span class="s1"&gt;'pagination'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'style'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'page'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'total'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'meta.total'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'next'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'meta.next_page'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same goes for the query builder. One &lt;code&gt;where()&lt;/code&gt; and one &lt;code&gt;orderBy()&lt;/code&gt;, and the&lt;br&gt;
grammar picked for the client decides how they hit the wire:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-&amp;gt;where('document__slug', 'in', [...])-&amp;gt;orderBy('name')

plain     ?document__slug=kp,dmag-e&amp;amp;sort=name
JSON:API  ?filter[document__slug]=kp,dmag-e&amp;amp;sort=name
django    ?document__slug__in=kp,dmag-e&amp;amp;ordering=name          &amp;lt;- Open5e, Spaceflight News
OData     ?$filter=...&amp;amp;$orderby=ProductName desc               &amp;lt;- custom Grammar class, ~30 lines
CKAN      ?fq=license_id:cc-by&amp;amp;sort=name asc                   &amp;lt;- custom Grammar class
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole idea: the model and a few config lines absorb the API's&lt;br&gt;
shape, and the calling code doesn't know which API it's talking to.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it does with the requests
&lt;/h2&gt;

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

&lt;p&gt;&lt;strong&gt;Eager loading.&lt;/strong&gt; Four artworks, four different artists. Lazy access is the&lt;br&gt;
classic N+1: five requests, one after another:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$works&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Artwork&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$works&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$w&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;artist&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// GET agents/{id}, four times, sequentially&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// 5 requests, 782 ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Artwork&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'artist'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// 5 requests, 144 ms — the four agents go out concurrently through a Guzzle Pool&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;batch()&lt;/code&gt;&lt;/strong&gt; turns the N into one &lt;code&gt;whereIn&lt;/code&gt;. Open5e's document → spells&lt;br&gt;
relation, three parents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;DocumentV1&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'spellsByDocument'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// concurrent&lt;/span&gt;
&lt;span class="c1"&gt;// 4 requests, 4227 ms  (Open5e is slow; that's the API, not the pool)&lt;/span&gt;

&lt;span class="nc"&gt;DocumentV1&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'spells'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;             &lt;span class="c1"&gt;// -&amp;gt;batch()&lt;/span&gt;
&lt;span class="c1"&gt;// 2 requests, 585 ms&lt;/span&gt;
&lt;span class="c1"&gt;//   GET v1/documents/?limit=3&amp;amp;page=4&lt;/span&gt;
&lt;span class="c1"&gt;//   GET v1/spells/?document__slug__in=kp,dmag-e,warlock&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;&lt;code&gt;getMany()&lt;/code&gt;&lt;/strong&gt; fetches a known set of ids through the same pool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$names&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;PokemonDetail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;    &lt;span class="c1"&gt;// 5 requests, 344 ms&lt;/span&gt;
&lt;span class="nc"&gt;PokemonDetail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$names&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                       &lt;span class="c1"&gt;// 5 requests, 225 ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;Memoization&lt;/strong&gt; is per request cycle: the same query twice inside one job or&lt;br&gt;
one HTTP request hits the API once. Meant for the case where three services in&lt;br&gt;
the same request all ask for the current user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Rest&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;memoize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Artwork&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;27992&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// 1 request&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Response cache&lt;/strong&gt; is the durable one: any PSR-16 store, opt-in per query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Artwork&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;27992&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// 1 request, 57 ms&lt;/span&gt;
&lt;span class="nc"&gt;Artwork&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;27992&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// 0 requests, 0 ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Across the catalog &lt;code&gt;paginate()&lt;/code&gt; passed on 25 APIs, &lt;code&gt;lazy()&lt;/code&gt; on 25, &lt;code&gt;with()&lt;/code&gt;&lt;br&gt;
eager loading on 9, &lt;code&gt;whereIn()&lt;/code&gt; on 9. Every feature the package claims ended&lt;br&gt;
up confirmed on at least three APIs that are built differently. So the theory&lt;br&gt;
held. The rest of this post is the other 104 scenarios: the ones that told me&lt;br&gt;
something about APIs rather than about the package.&lt;/p&gt;
&lt;h2&gt;
  
  
  "Not found"
&lt;/h2&gt;

&lt;p&gt;I assumed a missing record means HTTP 404. Seven APIs disagree. The two I&lt;br&gt;
liked best:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://icanhazdadjoke.com/j/doesnotexist
HTTP 200
{"message":"Joke with id \"doesnotexist\" not found","status":404}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://fakestoreapi.com/products/99999
HTTP 200
(empty body)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://api.openf1.org/v1/sessions?meeting_key=1
HTTP 404
{"detail":"No results found."}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ModelNotFoundException&lt;/code&gt; keys off the status code, so on those seven it never&lt;br&gt;
fires, and on OpenF1 it fires when the answer is "zero rows".&lt;/p&gt;
&lt;h2&gt;
  
  
  Where the total is
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;paginate()&lt;/code&gt; needs a total. Twelve APIs don't put one in the body.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://jsonplaceholder.typicode.com/posts?_page=1&amp;amp;_limit=5
HTTP 200
x-total-count: 100
link: &amp;lt;...?_page=2&amp;amp;_limit=5&amp;gt;; rel="next", &amp;lt;...?_page=20&amp;amp;_limit=5&amp;gt;; rel="last"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://quotesondesign.com/wp-json/wp/v2/posts?per_page=2
HTTP 200
x-wp-total: 1086
x-wp-totalpages: 543
link: &amp;lt;...?per_page=2&amp;amp;page=2&amp;gt;; rel="next"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Socrata and Open Brewery DB want a &lt;em&gt;second request&lt;/em&gt; for the count. Radio&lt;br&gt;
Browser has no count anywhere. The pagination config only knows how to read&lt;br&gt;
body paths, so all of these are recorded as limitations, and "let&lt;br&gt;
&lt;code&gt;'total' =&amp;gt; 'header:X-Total-Count'&lt;/code&gt; work" went onto the 1.7 roadmap, because&lt;br&gt;
twelve APIs asked for it.&lt;/p&gt;
&lt;h2&gt;
  
  
  &lt;code&gt;/resource/{id}&lt;/code&gt;
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;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",...}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;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,...],...}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Forget the &lt;code&gt;.json&lt;/code&gt; on Hacker News and you're redirected to the Firebase admin&lt;br&gt;
console.&lt;/p&gt;

&lt;p&gt;Multiple ids are a whole separate topic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://servicodados.ibge.gov.br/api/v1/localidades/estados/33|35
HTTP 200
[{"id":33,"sigla":"RJ",...},{"id":35,"sigla":"SP",...}]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://rickandmortyapi.com/api/character?id=1,2
HTTP 200
{"info":{"count":826,"pages":42,...},"results":[...]}     &amp;lt;- id= silently ignored, full collection

GET https://rickandmortyapi.com/api/character/1,2
HTTP 200
[{"id":1,"name":"Rick Sanchez",...},{"id":2,...}]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Five spellings across eight APIs, and one of them is actively rejected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;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&amp;amp;rank=FAMILY
HTTP 200
{"count":4769220,...}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson&amp;amp;sort=-magnitude
HTTP 400
Unknown parameter "sort".
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;USGS puts the direction &lt;em&gt;inside the value&lt;/em&gt;: &lt;code&gt;orderby=magnitude&lt;/code&gt; is descending,&lt;br&gt;
&lt;code&gt;orderby=magnitude-asc&lt;/code&gt; is ascending. Same API, one more:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson&amp;amp;offset=0
HTTP 400
Bad offset value "0". Valid values are 1 &amp;lt;= offset
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Offsets start at one. Every offset-style paginator I've seen starts at zero.&lt;/p&gt;

&lt;p&gt;Radio Browser accepts the wrong sort parameter without complaint and just&lt;br&gt;
doesn't sort:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET .../stations/search?limit=3&amp;amp;sort=-votes
   votes: 6, 922, 267                  &amp;lt;- not sorted, no error

GET .../stations/search?limit=3&amp;amp;order=votes&amp;amp;reverse=true
   votes: 824730, 569078, 432898
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second kind, accepted and ignored, is the one that gets past a test&lt;br&gt;
suite. GBIF, Rick and Morty and ReqRes have no sorting at all and behave the&lt;br&gt;
same way: &lt;code&gt;sort=name&lt;/code&gt; returns 200 and the default order.&lt;/p&gt;
&lt;h2&gt;
  
  
  The body isn't a list of objects
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://binaryjazz.us/wp-json/genrenator/v1/genre/
HTTP 200
"motown techno"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://hacker-news.firebaseio.com/v0/topstories.json
HTTP 200
[49731285,49732931,49733836,49732270,...]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://dog.ceo/api/breeds/list/all
HTTP 200
{"message":{"affenpinscher":[],"african":["wild"],"airedale":[],"australian":["kelpie","shepherd"],...}}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://api.open-meteo.com/v1/forecast?latitude=52.52&amp;amp;longitude=13.41&amp;amp;hourly=temperature_2m
HTTP 200
{"hourly":{"time":["2026-09-16T00:00","2026-09-16T01:00",...],"temperature_2m":[11.2,10.9,...]}}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Column-oriented. &lt;code&gt;hourly.time[i]&lt;/code&gt; pairs with &lt;code&gt;hourly.temperature_2m[i]&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://opensky-network.org/api/states/all?lamin=45&amp;amp;lomin=5&amp;amp;lamax=48&amp;amp;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],
  ...
]}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seventeen positions, no keys. Index 6 is latitude; you're expected to know.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Making the run honest
&lt;/h2&gt;

&lt;p&gt;Two things I got wrong before the numbers meant anything.&lt;/p&gt;

&lt;p&gt;The first was outcome classification. A scenario can pass, fail, be skipped&lt;br&gt;
because the API was down, or be &lt;em&gt;unsupported&lt;/em&gt;, a documented limitation. But a&lt;br&gt;
limitation has to prove itself: the scenario still makes the call, and if the&lt;br&gt;
call unexpectedly succeeds, the run &lt;strong&gt;fails&lt;/strong&gt; with "limitation no longer&lt;br&gt;
reproduces". Otherwise the docs quietly drift pessimistic. And "down" needs a&lt;br&gt;
per-API definition: restful-api.dev signals an exhausted daily quota with&lt;br&gt;
HTTP &lt;strong&gt;405&lt;/strong&gt;, which the harness initially recorded as a package failure.&lt;/p&gt;

&lt;p&gt;The second was my own scenarios. Every batch got a second, adversarial review&lt;br&gt;
(an LLM agent that hadn't seen the scenarios), and it kept finding tests that&lt;br&gt;
couldn't fail: an eager-loading scenario that stayed green with the eager loader&lt;br&gt;
gutted because it checked the loaded data instead of counting requests; a filter&lt;br&gt;
on a value every row shared; a relation method named &lt;code&gt;author()&lt;/code&gt; on a model whose&lt;br&gt;
response already had an &lt;code&gt;author&lt;/code&gt; attribute, so &lt;code&gt;__get()&lt;/code&gt; returned the attribute&lt;br&gt;
and the relation was never called. In the other direction, eleven things I'd&lt;br&gt;
written off as "the API can't do this" turned out to work fine once someone&lt;br&gt;
actually tried.&lt;/p&gt;

&lt;p&gt;And some of it was just the internet. Câmara dos Deputados timed out at&lt;br&gt;
&lt;strong&gt;229 seconds&lt;/strong&gt; on one request. The Art Institute's default first page drifted,&lt;br&gt;
between writing the scenario and the full run, to five artworks by the same&lt;br&gt;
artist, which broke an eager-loading&lt;br&gt;
scenario that needs at least two distinct parents; the fix was to pin the ids.&lt;/p&gt;

&lt;p&gt;The full matrix (every feature, which APIs confirm it, every limitation with&lt;br&gt;
its reproduction) is generated from the run:&lt;br&gt;
&lt;a href="https://github.com/sanchescom/laravel-rest/blob/master/docs/live-verification.md" rel="noopener noreferrer"&gt;docs/live-verification.md&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>php</category>
      <category>laravel</category>
      <category>api</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
