Search by

sanchescom / laravel-rest

sanchescom

Eloquent-like models and collections for consuming REST APIs.

Package info

github.com/sanchescom/laravel-rest

pkg:composer/sanchescom/laravel-rest

Statistics

Installs: 1 360

Dependents: 0

Suggesters: 0

Stars: 4

Open Issues: 0

1.3.0 2026-09-07 02:44 UTC

This package is auto-updated.

Last update: 2026-09-08 14:01:43 UTC


README

CI Latest Version Downloads PHP Version License

Eloquent-like models and collections for consuming REST APIs.

Define a model, point it at an endpoint, and work with remote resources the way you work with Eloquent: User::get(), User::get($id), User::post([...]), $user->put(), User::delete($id).

Table of Contents

Requirements

  • PHP 8.2+
  • Laravel 11 or 12

Note

Laravel 11 reached end of life in 2026 (no more security fixes). The package still supports and tests it, but new projects should target Laravel 12.

Installation

composer require sanchescom/laravel-rest

The service provider is registered automatically via package auto-discovery — no manual registration needed.

Quick Start

A model is a class pointed at an endpoint. Everything below runs for real against JSONPlaceholder:

<?php

use Sanchescom\Rest\Model;

class Post extends Model
{
    // endpoint is inferred from the class name: "posts"
    protected array $casts = ['id' => 'int', 'userId' => 'int'];
}
// config/rest.php
'clients' => [
    'placeholder' => [
        'provider' => 'guzzle',
        'base_uri' => 'https://jsonplaceholder.typicode.com/',
    ],
],
$post  = Post::get(1);                  // GET posts/1        -> Post
$posts = Post::get();                   // GET posts          -> Collection<Post>
$mine  = $posts->where('userId', 1);    // in-memory collection filter (server-side: see Query Builder)
$page  = $posts->paginate(10);          // LengthAwarePaginator

$some  = Post::getMany([3, 1, 2]);      // concurrent requests, results in [3, 1, 2] order

$new   = Post::post(['title' => 'Hi', 'userId' => 1]);   // POST posts
$upd   = Post::put(1, ['title' => 'Updated']);           // PUT posts/1
Post::delete(1);                                          // DELETE posts/1

// ...or update/delete through an instance using its own primary key:
$post = Post::get(2);
$post->title = 'Changed';
$post->put();                           // PUT posts/2
$post->delete();                        // DELETE posts/2

Post::get(987654);                      // 404 -> throws ModelNotFoundException

Configuration

Publish the config file:

php artisan vendor:publish --tag=rest-config

Configure one or more clients in config/rest.php:

<?php

return [
    'default' => env('REST_CLIENT', 'localhost'),

    'clients' => [
        'localhost' => [
            'provider' => 'guzzle',
            'base_uri' => 'https://localhost/',
            'options' => [
                'headers' => [
                    'Accept' => 'application/json',
                ],
            ],
        ],
        'billing' => [
            'provider' => 'guzzle',
            'base_uri' => 'https://billing.example.com/api/',
            'options' => [
                'headers' => [
                    'Authorization' => 'Bearer '.env('BILLING_TOKEN'),
                ],
            ],
        ],
    ],
];

Note

base_uri must end with a trailing slash, and endpoints must not start with one — that is how Guzzle resolves relative URIs.

Defining Models

<?php

use Sanchescom\Rest\Model;

class User extends Model
{
    /** Client name from config/rest.php; null uses the default client. */
    protected ?string $client = null;

    /** Endpoint; defaults to the snake-cased plural of the class name ("users"). */
    protected ?string $endpoint = 'users';

    /** Key that wraps payloads in responses (e.g. {"data": ...}); null for bare payloads. */
    protected ?string $dataKey = 'data';

    /** Whitelist of fillable attributes; empty array allows everything. */
    protected array $fillable = ['id', 'first_name', 'last_name', 'email'];

    /** Attribute casts applied on read: int|float|bool|string. */
    protected array $casts = ['id' => 'int'];

    /** Per-model HTTP client options merged over the configured ones. */
    protected array $options = [];
}

Error Handling

Every 4xx/5xx response throws a typed exception; you never get a silent null:

Status Exception
404 Sanchescom\Rest\Exceptions\ModelNotFoundException
422 Sanchescom\Rest\Exceptions\ValidationException (->errors())
500+ Sanchescom\Rest\Exceptions\ServerException
other 4xx Sanchescom\Rest\Exceptions\RequestException

All of them extend Sanchescom\Rest\Exceptions\RestException and expose the request context: $e->uri, $e->status, $e->body.

use Sanchescom\Rest\Exceptions\ValidationException;

try {
    User::post(['email' => 'not-an-email']);
} catch (ValidationException $e) {
    $errors = $e->errors();
}

Pagination

Collections can be paginated in memory:

$paginator = User::get()->paginate(15); // Illuminate LengthAwarePaginator

Query Builder

Filter, sort, and paginate without writing query strings by hand.

Important

Post::where(...)->get() filters on the server — the conditions are compiled into the query string and the API returns only matching records. Post::get()->where(...) filters the already-loaded collection in memory — it fetches everything first. For anything beyond trivial datasets, prefer the builder form.

// Plain grammar (default) — ?status=active&sort=-created_at&limit=20
$posts = Post::where('status', 'active')
    ->orderBy('created_at', 'desc')
    ->limit(20)
    ->get();

// Pagination
Post::page(2)->limit(15)->get();   // ?page=2&limit=15
Post::offset(30)->limit(15)->get(); // ?offset=30&limit=15

// Extra / non-standard params
Post::withQuery(['include' => 'author'])->get();

// Convenience
Post::where('status', 'draft')->first(); // first item of the collection
Post::where('userId', 1)->count();       // count of matching items

Other query conventions (JSON:API, Django, custom names) — the preferred way is a one-word preset or a small config array on the client; see Adapting to API Conventions:

'clients' => [
    'myapi' => [
        'provider' => 'guzzle',
        'base_uri' => 'https://api.example.com/',
        'query'    => 'jsonapi',   // filter[field]=..., page[size]=..., page[number]=...
    ],
],

For a truly custom query language, implement Sanchescom\Rest\Query\Grammar yourself and register the class per client ('grammar' => MyGrammar::class) or per model (protected ?string $grammar = MyGrammar::class). A grammar class always takes precedence over the 'query' config.

Adapting to API Conventions

Every REST API names its parameters differently. Some call it page_size, others call it page[size]. Some prefix descending sorts with -, others append :desc. The 'query' key in any client config covers all of these without touching the calling code. For truly exotic conventions, implement Sanchescom\Rest\Query\Grammar (or AuthInterface / ClientInterface) — but in the common case a config array is all you need.

1. Renaming Parameters

The names map translates the library's canonical parameter names (limit, offset, page, sort) to whatever the API expects. Dot notation in the value produces a nested array that Guzzle serialises as param[sub]:

// config/rest.php
'clients' => [
    'catalog' => [
        'provider' => 'guzzle',
        'base_uri'  => 'https://api.example.com/v2/',
        'query' => [
            'names' => [
                'limit'  => 'page.size',    // page[size]=...
                'page'   => 'page.number',  // page[number]=...
                'offset' => 'page.offset',  // page[offset]=...
            ],
        ],
    ],
],
Article::page(2)->limit(25)->get();
// GET articles?page[size]=25&page[number]=2

2. Sort Styles

Four styles are available. Pick the one that matches your API:

dash (default) — prefix descending fields with -, comma-separate:

'query' => ['sort' => 'dash'],
Product::orderBy('price', 'desc')->orderBy('name')->get();
// GET products?sort=-price,name

suffix — append a separator (default :) followed by the direction:

'query' => ['sort' => 'suffix'],              // uses ':' separator
'query' => ['sort' => 'suffix', 'sort_suffix' => '|'],  // custom separator
Product::orderBy('price', 'desc')->orderBy('name')->get();
// GET products?sort=price:desc,name:asc
// (or price|desc,name|asc with the custom separator)

separate — two distinct parameters for field and direction (only the first orderBy is sent; additional calls are ignored). When sort_names is omitted, defaults are: field param sort, direction param direction:

'query' => [
    'sort'       => 'separate',
    'sort_names' => ['field' => 'sort_by', 'direction' => 'sort_dir'],
],
Product::orderBy('price', 'desc')->get();
// GET products?sort_by=price&sort_dir=desc

array — an associative array keyed by field name:

'query' => ['sort' => 'array'],
Product::orderBy('price', 'desc')->orderBy('name')->get();
// GET products?sort[price]=desc&sort[name]=asc

3. Filter Styles

Three filter styles cover the most common conventions. The operator (third argument of where()) is an arbitrary string passed through to the API — use whatever vocabulary your API defines (gte, lte, like, in, …):

plain (default) — bare equality, operator in brackets for everything else:

'query' => ['filters' => 'plain'],
Order::where('status', 'shipped')->where('total', 'gte', 100)->get();
// GET orders?status=shipped&total[gte]=100

brackets — JSON:API-style filter[field]:

'query' => ['filters' => 'brackets'],
Order::where('status', 'shipped')->where('total', 'gte', 100)->get();
// GET orders?filter[status]=shipped&filter[total][gte]=100

django — Django REST Framework style with double-underscore lookups:

'query' => ['filters' => 'django'],
Order::where('status', 'shipped')->where('total', 'gte', 100)->get();
// GET orders?status=shipped&total__gte=100

4. Presets

Two presets are built in. Pass a string to activate one:

// JSON:API preset — brackets filters, dash sort, page[size]/page[number]/page[offset]
'query' => 'jsonapi',

// Django DRF preset — django filters, dash sort, ordering/page_size
'query' => 'django',
// Preset with overrides — start from jsonapi and change just the sort style
'query' => [
    'preset' => 'jsonapi',
    'sort'   => 'suffix',
],
// jsonapi preset in action
Article::where('published', true)->orderBy('created_at', 'desc')->page(2)->limit(15)->get();
// GET articles?filter[published]=true&sort=-created_at&page[size]=15&page[number]=2

// django preset in action
Article::where('published', true)->orderBy('created_at', 'desc')->limit(15)->get();
// GET articles?published=true&ordering=-created_at&page_size=15

5. Field Casing

The casing option normalises every field name that appears in where() and orderBy() calls before it hits the wire. Useful when your PHP code uses camelCase but the API expects snake_case:

'query' => ['casing' => 'snake'],   // camel → snake
'query' => ['casing' => 'camel'],   // snake → camel
// 'casing' => 'snake'
Report::where('reportDate', '2024-01-01')->orderBy('totalRevenue', 'desc')->get();
// GET reports?report_date=2024-01-01&sort=-total_revenue

6. Dynamic Headers

Model-level headers — always sent for every request from this model:

class Tenant extends Model
{
    protected ?string $client   = 'platform';
    protected ?string $endpoint = 'tenants';

    /** @var array<string, string> */
    protected array $headers = [
        'X-Tenant-ID' => 'acme',
    ];
}

// GET tenants   (headers: X-Tenant-ID: acme)
Tenant::get();

Per-chain withHeaders() — merges over the model-level headers; chain value wins on key conflicts:

Tenant::withHeaders([
    'X-Tenant-ID'      => 'betacorp',   // overrides the model default
    'Accept-Language'  => 'fr-FR',
])->where('active', true)->get();
// GET tenants?active=true   (headers: X-Tenant-ID: betacorp, Accept-Language: fr-FR)

Note

Under Rest::fake() the inline resolver ignores the options argument passed to client(), so headers are not forwarded to the fake. The fake sees bare requests with no custom headers. Similarly, the standalone ClientResolver ignores the options argument — wire headers directly into the ClientInterface instance you register instead.

7. Configuring the Errors Key

By default, ValidationException::errors() looks for the 'errors' key in the response body. Set errors_key (dot notation supported) to point at a different location:

'clients' => [
    'myapi' => [
        'provider'   => 'guzzle',
        'base_uri'   => 'https://api.example.com/',
        'errors_key' => 'meta.validation_errors',
    ],
],
// Response body from the API:
// {
//   "message": "Unprocessable",
//   "meta": {
//     "validation_errors": {"email": ["already taken"]}
//   }
// }

try {
    User::post(['email' => 'taken@example.com']);
} catch (\Sanchescom\Rest\Exceptions\ValidationException $e) {
    $e->errors(); // ['email' => ['already taken']]
}

8. PATCH Updates

By default put() sends a PUT request. Switch to PATCH per client:

'clients' => [
    'myapi' => [
        'provider'      => 'guzzle',
        'base_uri'      => 'https://api.example.com/',
        'update_method' => 'patch',
    ],
],
User::put(42, ['email' => 'new@example.com']);
// PATCH users/42   {"email":"new@example.com"}

9. Request Envelopes

Some APIs require the POST/PUT body to be nested under a key:

class Invoice extends Model
{
    protected ?string $endpoint       = 'invoices';
    protected ?string $requestDataKey = 'data';  // write envelope
    protected ?string $dataKey        = 'data';  // read unwrap key
}
Invoice::post(['number' => 'INV-001', 'total' => 500]);
// POST invoices
// Body: {"data":{"number":"INV-001","total":500}}

Invoice::get(1);
// GET invoices/1
// Response: {"data":{"id":1,"number":"INV-001","total":500}}
// → Invoice with attributes {id:1, number:"INV-001", total:500}

Note

$requestDataKey wraps write bodies (POST/PUT/PATCH). $dataKey unwraps read responses (GET). They are independent — you can set one without the other.

10. Standalone Resolver — Setting Query Config

Outside Laravel you can configure ConfigurableGrammar directly on ClientResolver instead of relying on the config file:

use Sanchescom\Rest\ClientResolver;
use Sanchescom\Rest\Clients\GuzzleClient;
use Sanchescom\Rest\Model;

$resolver = new ClientResolver;
$resolver->addClient('catalog', GuzzleClient::fromConfig([
    'base_uri' => 'https://api.example.com/',
]));
$resolver->setDefaultClient('catalog');
$resolver->setQueryConfig('catalog', 'jsonapi');   // preset string

Model::setClientResolver($resolver);

// Or with a full config array:
$resolver->setQueryConfig('catalog', [
    'preset' => 'jsonapi',
    'sort'   => 'suffix',
]);

11. Putting It All Together

A realistic client config combining several conventions, plus a model that uses a write envelope:

// config/rest.php
'clients' => [
    'commerce' => [
        'provider'      => 'guzzle',
        'base_uri'      => 'https://api.commerce.example/v3/',
        'update_method' => 'patch',
        'errors_key'    => 'errors.fields',
        'query' => [
            'preset'  => 'jsonapi',  // brackets filters, dash sort, page[size]/page[number]
            'sort'    => 'suffix',   // override sort to suffix style
            'casing'  => 'snake',    // normalise camelCase PHP fields to snake_case on the wire
        ],
        'auth' => [
            'driver' => 'bearer',
            'token'  => env('COMMERCE_TOKEN'),
        ],
    ],
],
class Order extends Model
{
    protected ?string $client         = 'commerce';
    protected ?string $endpoint       = 'orders';
    protected ?string $dataKey        = 'data';
    protected ?string $requestDataKey = 'data';

    /** @var array<string, string> */
    protected array $headers = ['X-Store-ID' => 'eu-west-1'];
}
// List — filters, sort, pagination, casing all applied automatically
Order::where('status', 'pending')
     ->where('totalAmount', 'gte', 50)
     ->orderBy('createdAt', 'desc')
     ->page(2)
     ->limit(20)
     ->get();
// GET orders?filter[status]=pending&filter[total_amount][gte]=50
//            &sort=created_at:desc&page[size]=20&page[number]=2
// Headers: Authorization: Bearer <token>, X-Store-ID: eu-west-1

// Per-chain header override for a specific locale
Order::withHeaders(['Accept-Language' => 'de-DE'])
     ->where('status', 'pending')
     ->get();
// GET orders?filter[status]=pending
// Headers: Authorization: Bearer <token>, X-Store-ID: eu-west-1, Accept-Language: de-DE

// Create — body wrapped in 'data'
Order::post(['customerEmail' => 'alice@example.com', 'totalAmount' => 99.90]);
// POST orders
// Body: {"data":{"customerEmail":"alice@example.com","totalAmount":99.9}}

// Validation error — errors extracted from nested key
try {
    Order::post(['customerEmail' => 'bad']);
} catch (\Sanchescom\Rest\Exceptions\ValidationException $e) {
    // Response: {"errors":{"fields":{"customer_email":["invalid email"]}}}
    $e->errors(); // ['customer_email' => ['invalid email']]
}

Caching

laravel-rest can cache GET responses so that repeat reads within a TTL window cost zero HTTP round-trips. Caching is opt-in per model or per chain; write operations always pass through and automatically invalidate the model's cache on success. Both get() and getMany() are cached; write methods always go to the API.

Quick start — Laravel app

Publish the config and enable the cache store you want (any configured Laravel cache store works):

// config/rest.php
'cache' => [
    'store' => null,   // null = default Laravel store; 'redis', 'memcached', etc.
    'ttl'   => 300,    // default TTL in seconds used by withCache() without args
],

Set 'cache' => false to skip store wiring entirely (no Model::setCacheStore call is made at boot).

Then enable caching on the models you want cached:

class Post extends Model
{
    protected ?int $cacheTtl = 60; // seconds; null (default) = caching disabled
}

Per-chain opt-in and opt-out

Any chain can override the model setting:

// Enable caching for one chain (uses model $cacheTtl, or default TTL if not set)
Post::withCache()->get();

// Use a specific TTL just for this chain
Post::withCache(30)->get();

// Force a fresh hit even if the model has $cacheTtl
Post::withoutCache()->get();

Page-flip scenario — zero HTTP on repeat

Because cache keys encode client | model | version | uri | compiled-query, every unique page is cached independently. Flipping back to a page that was already fetched costs nothing:

$page1a = Post::withCache()->page(1)->get(); // HTTP request
$page2  = Post::withCache()->page(2)->get(); // HTTP request
$page1b = Post::withCache()->page(1)->get(); // cache hit — zero HTTP

Write-through invalidation

Successful post, put, and delete calls bump the model's internal cache version, making all previously cached entries for that model stale. The next read transparently refetches from the API.

Cancelled writes (a creating/updating/deleting listener returning false) do not bump the version.

Post::post(['title' => 'New']);  // POST + flushCache() on success
Post::put(1, ['title' => 'Hi']); // PUT  + flushCache() on success
Post::delete(1);                 // DELETE + flushCache() on success

You can also flush explicitly at any time (O(1) version bump — does not iterate cache keys):

Post::flushCache(); // safe no-op when no store is configured

Warning

External mutations are invisible. If another service creates, updates, or deletes records via the same API, this package has no way to know. Keep $cacheTtl short enough for your consistency requirements, or call Post::flushCache() when you know a mutation happened externally.

Fail loud — no silent cache misses

If caching is requested (via $cacheTtl or withCache()) but no store has been configured, the builder throws a RestException immediately rather than silently falling back to a live request:

Model::setCacheStore(null);
CachedPost::get(); // throws RestException: "no cache store configured"

Standalone use (outside Laravel)

Wire the store directly — any PSR-16 CacheInterface works:

use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Psr16Cache;
use Sanchescom\Rest\Model;

Model::setCacheStore(
    new Psr16Cache(new FilesystemAdapter),
    defaultTtl: 120,
);

// Now any model with $cacheTtl or withCache() will use the file cache
Post::withCache(60)->get(1);

psr/simple-cache is a suggested dependency; install it alongside whichever PSR-16 adapter you choose.

Testing with fakes

Rest::fake() and caching compose correctly. The fake is the inner client; CachingClient wraps it. Use Rest::assertSentCount() to prove cache hits (only the first call is forwarded to the fake):

use Sanchescom\Rest\Rest;
use Sanchescom\Rest\Model;

// ArrayCache is a test double from this package's tests/ — in your app wire any PSR-16 store instead
Model::setCacheStore(new \Sanchescom\Rest\Tests\Support\ArrayCache);

Rest::fake(['posts' => Rest::response([['id' => 1]])]);

Post::withCache()->page(1)->get();
Post::withCache()->page(2)->get();
Post::withCache()->page(1)->get(); // cache hit

Rest::assertSentCount(2); // page1 + page2 — page1 repeat was served from cache

Rest::restore();
Model::setCacheStore(null);

Relations

Three relation types, all lazy-loaded and per-instance cached on first access:

class Post extends Model
{
    // FK filter: GET comments?postId=1
    public function comments(): \Sanchescom\Rest\Relations\HasMany
    {
        return $this->hasMany(Comment::class);
    }

    // Nested URL: GET posts/1/thumbnail
    public function thumbnail(): \Sanchescom\Rest\Relations\HasOne
    {
        return $this->hasOne(Thumbnail::class)->nested();
    }

    // Parent lookup: GET users/{post->userId}
    public function author(): \Sanchescom\Rest\Relations\BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

$post = Post::get(1);
$post->comments;          // Collection<Comment>  — loaded once, cached
$post->thumbnail;         // ?Thumbnail
$post->author;            // ?User

// Relations also chain onto the builder:
$post->comments()->where('approved', true)->get();

Foreign-key name defaults to camelCase class name + Id (postId for Post, userId for User). Pass the key explicitly to override: $this->hasMany(Comment::class, 'post_id').

Authentication

Configure an auth block inside a client entry:

// Bearer token
'auth' => ['driver' => 'bearer', 'token' => env('API_TOKEN')],

// HTTP Basic
'auth' => ['driver' => 'basic', 'username' => '', 'password' => ''],

// Arbitrary header(s) — name must match what the API expects exactly
'auth' => [
    'driver'  => 'header',
    'headers' => ['X-API-Key' => env('API_KEY')],
],

// Custom driver — any class implementing AuthInterface
'auth' => ['driver' => \App\Auth\HmacAuth::class, 'secret' => env('HMAC_SECRET')],

Custom drivers receive the full auth config array as the constructor argument and must implement Sanchescom\Rest\Auth\AuthInterface.

An InvalidArgumentException is thrown immediately on boot for unknown drivers or missing required keys, not at request time.

Retry

Add a retry block to a client config:

'retry' => [
    'times'              => 3,       // max attempts (excluding the first)
    'delay'              => 100,     // base delay in ms
    'multiplier'         => 2.0,     // exponential multiplier
    'statuses'           => [429, 500, 502, 503, 504],
    'respect_retry_after' => true,   // honour Retry-After response header
],

Connection errors (ConnectException) are always retried regardless of statuses. There is no circuit breaker or jitter — reach out or open a PR if you need either.

Model Events

Six hooks fire around write operations. Returning false from a creating, updating, or deleting listener cancels the operation (post/put return null; delete returns false):

Post::creating(function (Post $post): bool|void {
    if ($post->title === '') {
        return false; // cancel
    }
});

Post::created(function (Post $post): void {
    Cache::forget('posts');
});

Post::updating(fn (Post $post) => /* return false to cancel */ null);
Post::updated(fn (Post $post) => null);
Post::deleting(fn (Post $post) => /* return false to cancel */ null);
Post::deleted(fn (Post $post) => null);

Laravel event dispatcher bridge — if you set a dispatcher on the model, created, updated, and deleted also dispatch Sanchescom\Rest\Events\ModelCreated, Sanchescom\Rest\Events\ModelUpdated, and Sanchescom\Rest\Events\ModelDeleted (each carries the model as a public $model property):

use Illuminate\Events\Dispatcher;

Model::setEventDispatcher(app(Dispatcher::class));

Clear listeners between tests with Post::flushEventListeners().

Testing Your Application

Rest::fake() is the primary testing path. It swaps the entire HTTP layer with a fake that records every request and lets you assert against it.

use Sanchescom\Rest\Rest;

Rest::fake([
    'posts/*' => Rest::response(['id' => 1, 'title' => 'Hello']),
    'posts'   => Rest::response(['id' => 2, 'title' => 'Created']),
]);

$post = Post::get(1);                  // GET posts/1  -> {'id': 1, ...} from the fake
Post::post(['title' => 'Created']);    // POST posts   -> {'id': 2, ...} from the fake

Rest::assertSentCount(2);

Rest::assertSent(function ($request) {
    return $request->method() === 'GET' && $request->uri() === 'posts/1';
});

Rest::assertNotSent(function ($request) {
    return $request->method() === 'DELETE';
});

// Inspect all recorded requests
$requests = Rest::recorded(); // list<RecordedRequest>

// Restore the real resolver in teardown
Rest::restore();

RecordedRequest exposes method(), uri(), query(), and data().

Patterns in the map use Str::is() matching (wildcards with *). Responses with a 4xx/5xx status code cause the fake to throw the same typed exception as the real client would.

Important

Rest uses PHPUnit's Assert internally. It is a test-context class — do not call Rest::assertSent* or Rest::recorded() in production code.

Advanced alternative: extend('mock') for full Guzzle handler control:

use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Sanchescom\Rest\Clients\GuzzleClient;

config()->set('rest.default', 'testing');
config()->set('rest.clients.testing', ['provider' => 'mock']);

app('rest')->extend('mock', function () {
    $mock = new MockHandler([
        new Response(200, [], '{"data":[{"id":1}]}'),
    ]);

    return new GuzzleClient(new Client([
        'handler' => HandlerStack::create($mock),
        'base_uri' => 'https://api.test/',
        'http_errors' => false,
    ]));
});

$users = User::get(); // served from the mock

Usage Outside Laravel

The package works without a booted Laravel app (only paginate() and config publishing need one). Wire the resolver yourself:

use Sanchescom\Rest\ClientResolver;
use Sanchescom\Rest\Clients\GuzzleClient;
use Sanchescom\Rest\Model;

$resolver = new ClientResolver([
    'placeholder' => GuzzleClient::fromConfig([
        'base_uri' => 'https://jsonplaceholder.typicode.com/',
        'options' => ['headers' => ['Accept' => 'application/json']],
    ]),
]);
$resolver->setDefaultClient('placeholder');

Model::setClientResolver($resolver);

Post::get(1); // works — no Laravel container involved

Capability Matrix

See docs/capabilities.md for a full breakdown of what is supported, what is not, and how to extend it.

Roadmap

See ROADMAP.md for planned features (server-side pagination, eager loading, multipart bodies, OAuth2, and more).

Upgrading

See UPGRADE.md for breaking-change notes between every major/minor version.

Contributing

Please read CONTRIBUTING.md for details on our code of conduct, and the process for submitting pull requests to us.

Versioning

We use SemVer for versioning. For the versions available, see the tags on this repository.

Authors

See also the list of contributors who participated in this project.

License

This project is licensed under the MIT License - see the LICENSE.md file for details.