sanchescom / laravel-rest
Eloquent-like models and collections for consuming REST APIs.
Requires
- php: ^8.2
- guzzlehttp/guzzle: ^7.8
- illuminate/container: ^11.0|^12.0
- illuminate/pagination: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
Requires (Dev)
- laravel/pint: ^1.18
- mockery/mockery: ^1.6
- orchestra/testbench: ^9.9|^10.0
- pestphp/pest: ^3.0
- phpstan/phpstan: ^2.0
- psr/simple-cache: ^3.0
Suggests
- psr/simple-cache: Required when response caching is enabled (^3.0).
Provides
None
Conflicts
None
Replaces
None
README
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 · Installation · Quick Start
- Configuration · Defining Models
- Error Handling · Pagination
- Query Builder
- Adapting to API Conventions — parameter names, sort styles, filter styles, presets, casing, headers, error keys, PATCH, envelopes, standalone, all together
- Caching · Relations
- Authentication · Retry · Model Events
- Testing Your Application · Usage Outside Laravel
- Capability Matrix · Upgrading · Contributing
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
- Efimov Aleksandr - Initial work - Sanchescom
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.