esanj / remote-eloquent
Eloquent models backed by the Esanj Accounting resource API — version 2 sends a structured QuerySpec the server validates, never SQL.
Package info
github.com/eSanjDev/ms-package-accounting-remote-eloquent
pkg:composer/esanj/remote-eloquent
Requires
- php: ^8.2|^8.3|^8.4
- illuminate/auth: ^11.0|^12.0|^13.0
- illuminate/console: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- illuminate/validation: ^11.0|^12.0|^13.0
Requires (Dev)
None
Suggests
- esanj/auth-bridge: Supplies the OAuth flow, the session token and the signing key the guard and the actor-token exchange reuse.
- firebase/php-jwt: Required by the "accounting" auth guard to verify the end user's token locally (^6.0).
- phpunit/phpunit: Required by Esanj\RemoteEloquent\Testing\FakeResourceTransport's assertions (^10.0|^11.0|^12.0).
Provides
None
Conflicts
None
Replaces
None
README
Eloquent models whose rows live in the Esanj Accounting service.
use App\Models\User; $user = User::find(7); // GET users/7 $page = User::query()->active()->paginate(20); // POST users/query (rows + total, one request) $user->update(['first_name' => 'Ada']); // PATCH users/7 $user->remoteAction('suspend'); // POST users/7/actions/suspend
No local users table, no schema copy, no hand-written API client. What leaves this process is a QuerySpec —
a structured description of the query — which Accounting validates against the contract it publishes before it
runs anything.
What changed in 2.0, and why
Version 1 compiled Eloquent to a MySQL SQL string and shipped it to Accounting, which vetted it with regular expressions. Three things were wrong with that, and all three were load-bearing:
| v1 | Why it had to go |
|---|---|
| The client built SQL | Accounting's table layout became a public API. Renaming a column broke consumers. |
| The server vetted SQL with regexes | Every domain rule — tenancy, field-level permissions, soft deletes — was bypassed by anything that parsed as a legal SELECT. |
| The SQL was MySQL's | It did not run on PostgreSQL at all. |
| A failed call was replayed on the other transport | Writes and rate-limited calls were silently re-sent. |
Version 2 sends a QuerySpec to a resource API. The server owns the decision; the client never builds SQL. The connection under these models has no PDO at all — if some path ever reaches a grammar and compiles a string, it throws instead of running.
The second rule, and it is the one to remember: this client never silently drops a condition. Anything it cannot express refuses before the request is made, and the exception names the supported alternative. A query that quietly returns the wrong rows is worse than one that fails.
Installation
composer require esanj/remote-eloquent
Laravel discovers the service provider. It registers the remote-eloquent connection, the transport, the
accounting auth guard, the presence verifier behind Rule::exists(), and four artisan commands — nothing to
wire by hand.
Publish the config only if you need to change it:
php artisan vendor:publish --tag=esanj-remote-eloquent-config # -> config/esanj/remote_eloquent.php
Environment
The base URL and the client credentials default to the ACCOUNTING_BRIDGE_* variables, so a service already
talking to Accounting needs almost nothing:
REMOTE_ELOQUENT_DRIVER=rest # REMOTE_ELOQUENT_BASE_URL=https://auth.esanj.io # defaults to ACCOUNTING_BRIDGE_BASE_URL REMOTE_ELOQUENT_PREFIX=/api/remote/v1 REMOTE_ELOQUENT_TIMEOUT=5 REMOTE_ELOQUENT_CONNECT_TIMEOUT=2 REMOTE_ELOQUENT_MAX_LIMIT=100 REMOTE_ELOQUENT_IN_CHUNK=500 # Only when a write must speak for the signed-in user, not just the application: # REMOTE_ELOQUENT_ACTOR_EXCHANGE_URL=https://auth.esanj.io/oauth/token
Run php artisan remote:doctor afterwards. It checks the configuration, the reachability of the service, the
permissions it grants you, and your models against the published schemas.
Defining a model
<?php namespace App\Models; use Esanj\RemoteEloquent\Concerns\RemoteSoftDeletes; use Esanj\RemoteEloquent\Models\ApiUser; class User extends ApiUser { use RemoteSoftDeletes; protected string $resource = 'users'; protected $connection = 'remote-eloquent'; // makes Rule::exists(User::class) remote protected $fillable = ['first_name', 'last_name', 'email', 'phone_number', 'gender']; protected function casts(): array { return [ 'is_active' => 'boolean', 'global_ban' => 'boolean', 'email_verified_at' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; } }
Extend ApiModel for an ordinary resource, ApiUser for the one behind the auth guard. $fillable should be
exactly the fields the contract marks writable — everything else comes back as 400 field_not_writable.
Or let the schema write it for you:
php artisan remote:model users --auth # app/Models/User.php from the published schema php artisan remote:schema users --diff # what drifted since
Casts, accessors, $appends, $hidden, model events, $fillable/$guarded and local relations all behave
exactly as they do on a normal model. $timestamps is off and created_at/updated_at are stripped from every
write: the server stamps its own rows.
The supported surface
Everything here works, and works the way it reads:
| Fetch one | find, findOrFail, findMany, whereKey, first, firstOrFail, sole, firstOrNew |
| Fetch many | get() with an explicit limit(), pluck() with a limit, value |
| Project | select, addSelect |
| Filter | where with = != <> > >= < <=, orWhere, nested closures (depth ≤ 3), whereNot, whereIn, whereNotIn, whereIntegerInRaw, whereNull, whereNotNull, whereBetween, whereNotBetween, whereDate/whereYear with = |
| LIKE | 'x%', '%x%', '%x' — the three the contract publishes as starts_with, contains, ends_with |
| Order | orderBy, latest, oldest, reorder |
| Page | limit/take, offset/skip, paginate, simplePaginate |
| Aggregate | count, exists, doesntExist, min, max, sum, avg |
| Walk | chunkById, lazyById, eachById |
| Write | create, save, update on a model, delete, destroy |
| Soft delete | RemoteSoftDeletes: withTrashed, onlyTrashed, restore, forceDelete, trashed |
| Relations | a local model's belongsTo/hasMany to a remote one, and server-side with()/withCount() |
| Compose | when, tap, scopes |
| Domain | remoteAction(), validateRemote(), remoteCan() |
The operators a field accepts are per-field and published by the server: is_active may allow only eq,
created_at only the range operators. A disallowed one is 400 operator_not_allowed, and remote:schema prints
the table.
What it refuses, and what to write instead
Each of these throws before any network call, and the message names the alternative.
| You wrote | Why it cannot work | Write instead |
|---|---|---|
User::all() / unbounded get() |
There is no "all" over a network. It would page the whole account service into memory. | ->limit(100)->get(), or chunkById() |
cursorPaginate() |
Needs a keyset cursor the API does not expose. | paginate() or simplePaginate() |
chunk(), lazy(), cursor(), each() |
Offset paging over a table that is being written to skips and repeats rows. | chunkById(), lazyById(), eachById() |
firstOrCreate(), updateOrCreate(), upsert() |
There is no atomic upsert endpoint; a read-then-write would race. | first() then create(), or a domain action |
User::where(...)->update([...]) / ->delete() |
A mass write by filter has no endpoint, and doing it row by row is not the same operation. | Load the page, then write each model |
increment(), decrement(), touch() |
Not atomic across the network. | A domain action that owns the rule |
whereColumn, whereRaw, selectRaw, orderByRaw, DB::raw |
Raw SQL is exactly what 2.0 removed. | A published field, or an action |
whereExists, subqueries |
The server evaluates no client SQL. | pluck() the ids, then whereIn() |
whereJson*, whereFullText |
Not in the contract. | Ask for the field to be published |
whereMonth, whereDay, whereTime |
Only whereDate/whereYear translate. |
whereBetween on the timestamp |
inRandomOrder, groupBy, having, join |
Set operations belong to the owner of the data. | An aggregate, or an endpoint |
whereHas() against a local table |
The two sides are in different databases. | pluck() the ids locally, then whereIn() |
DB::transaction() on this connection |
There is no distributed transaction. | One idempotent call, or a domain action |
Auth::attempt() |
The password hash is not a published field, and never will be. | The OAuth flow, and the accounting guard |
// Not this: $orders = Order::whereHas('user', fn ($q) => $q->where('is_active', true))->get(); // This: $ids = User::query()->active()->limit(100)->pluck('id'); $orders = Order::whereIn('user_id', $ids)->get();
Relations
Local → remote works as usual, because the foreign key is a plain column on your table:
class Order extends Model // a LOCAL table { public function user(): BelongsTo { return $this->belongsTo(User::class); // one GET users/{id}, through the identity map } }
Remote → remote is done by the server, through include:
User::query()->with('roles')->limit(20)->get(); // one request, roles in the payload User::query()->withCount('wallets')->limit(20)->get();
Only the relations the schema publishes under includes and relations can be asked for. has() and
whereHas() translate too — as long as the relation is remote:
User::query()->has('wallets', '>=', 1)->limit(20)->get(); // server-side "has" clause
Lazy loading is a network call here, so the provider turns on Model::preventLazyLoading() outside production.
A LazyLoadingViolationException in development is the N+1 you would otherwise have found in production.
Pagination in one request
paginate() sets with_total on the spec, so the rows and the count come back in a single response — not
the two queries a local paginator runs:
$users = User::query()->active()->orderBy('created_at', 'desc')->paginate(20); $users->total(); // from meta.total $users->hasMorePages(); // from meta.has_more
simplePaginate() skips the total and is one request as well.
max_query_limit (100 by default) caps a page. Asking for more throws before the call rather than coming back as
a 400.
Writes and idempotency
$user = User::create(['first_name' => 'Ada', 'last_name' => 'Lovelace', 'email' => 'ada@example.com']); $user->first_name = 'Ada B.'; $user->save(); // PATCH with ONLY the dirty attributes $user->delete(); // users: closes this application's membership $user->forceDelete(); // DELETE ?force=1 — the account itself; 409 while another application hosts it $user->restore(); // reopens the membership
email and phone_number are sent on create only; an existing address changes through the change-email
action. Editing users who are not this application's members needs users.update.all.
Every mutating call carries an Idempotency-Key. The key identifies an operation, not a request: a retry — an internal one, a queued job's second attempt, the same code path running again — reuses it, so the server recognises the replay instead of applying the write twice. The key is released once the outcome is known, so the next write is a new operation and gets a new key.
A queued job that may be retried in a fresh process should bind the scope to something stable, and the keys are then derived rather than random:
app(OperationContext::class)->useScope($job->uuid());
Read-only POSTs — query, aggregate, validate — carry no key.
If the record changed under you, the PATCH answers 409 stale_record as a ConflictException. Models carrying
a version attribute send it as if_match.
Domain actions
Anything with a rule attached to it is an action, not a column. Suspending an account, changing an email, syncing roles — Accounting owns what each one means:
$user->remoteAction('suspend'); $user->remoteAction('change-email', ['email' => 'ada@example.com']); $user->remoteAction('sync-roles', ['roles' => ['editor']]); // protected roles are always refused
php artisan remote:schema users lists the actions a resource publishes. An unknown one throws
UnsupportedResourceException naming the ones that exist.
Validation
Rule::exists() and Rule::unique() work against a remote resource, in one request each — the presence
verifier posts an aggregate or a single distinct query, never one call per value:
$request->validate([ 'user_id' => ['required', Rule::exists(User::class, 'id')], 'email' => ['required', 'email', Rule::unique(User::class, 'email')->ignore($user)], ]);
Use the class-string form. The string form ('exists:users,id') is indistinguishable from a local table and
goes to Laravel's own verifier; remote:doctor reports every one it finds.
To ask the server to run its own rules before you commit to a write:
$user->fill($request->validated()); if (! $user->validateRemote()) { // RemoteValidationException was thrown for a failure; false means it was refused softly } User::validateRemoteInto($attributes); // create mode, no record in hand
RemoteValidationException extends Illuminate\Validation\ValidationException, so a failed remote validation
lands back on the form with the server's messages attached to the right fields, with no try/catch anywhere.
The auth guard
The signed-in user is a record in Accounting, so the guard verifies the end user's token locally and then reads
GET users/me with that token — once per request.
// config/auth.php 'defaults' => ['guard' => 'accounting', 'passwords' => 'users'], 'guards' => [ 'accounting' => [ 'driver' => 'accounting', 'provider' => 'remote-users', 'input' => 'session', // or 'bearer' for an API // 'scopes' => ['users.read'], 'issuer' => ..., 'leeway' => 30, // 'me' => ['include' => ['roles']], ], ], 'providers' => [ 'remote-users' => ['driver' => 'remote', 'model' => App\Models\User::class], ],
auth()->user() returns your User model, $this->authorize() and every policy keep working, and @can reads
the same as always.
Two behaviours worth knowing:
- A bad token — expired, wrong signature, wrong audience, missing scope — logs a warning and answers guest.
- A configuration fault that makes the check impossible — no public key, unreachable JWKS — throws
RemoteAuthenticationException. Answering "guest" when the question could not be asked would log the whole application out and read as a login bug.
Auth::attempt() throws. Sign-in belongs to the OAuth flow (esanj/auth-bridge); this guard resolves who the
token belongs to.
Acting for a user
A write that originated in a person's request should travel with that person's identity, so Accounting can apply
their permissions on top of the application's. Set REMOTE_ELOQUENT_ACTOR_EXCHANGE_URL and it happens by itself:
the user signed in on the default guard is exchanged for a one-write actor token on every write and on
validateRemote(). In a job that has no request, pin the actor:
User::actingAsRemote($user); // ... User::forgetRemoteActor();
With no exchange URL configured, writes travel as the application alone and anything the server marks
actor-required comes back as 403 actor_denied — never performed anonymously.
Errors
Every failure is a typed exception. With errors.render on (the default) they answer the browser themselves.
| Status / code | Exception | Renders |
|---|---|---|
400 invalid_query, unknown_field, operator_not_allowed, field_not_writable, query_too_complex, 413 |
InvalidQueryException |
500 — it is a bug in the query, not in the request |
| pre-flight refusals | UnsupportedQueryException, UnboundedQueryException |
500 |
404 unknown_resource |
UnsupportedResourceException |
500 |
401 after one refresh |
RemoteAuthenticationException |
500 — the application's identity, not the user's |
403 permission_denied, privileged_account, actor_denied |
AccessDeniedException (requiredPermission()) |
403 |
404 not_found |
ModelNotFoundException, or null from find() |
404 |
404 user_merged |
followed once automatically, then UserMergedException (mergedInto()) |
409 |
409 stale_record, idempotency_mismatch, idempotency_in_progress |
ConflictException |
409 |
422 validation_failed, field_locked |
RemoteValidationException |
422 / back to the form |
429 rate_limited |
RateLimitedException (retryAfter()) |
429 + Retry-After |
503/504 query_timeout |
RemoteTimeoutException |
503 |
503 unavailable, connect or TLS failure |
TransportException |
503 |
A 429 is never retried and never re-sent. That was the headline bug of version 1. In a queued job, release it instead:
public function middleware(): array { return [new RemoteRateLimited()]; // release()s the job for exactly Retry-After seconds }
Turn rendering off with REMOTE_ELOQUENT_RENDER_ERRORS=false and every render() returns null, leaving your own
handler in control.
Testing
No HTTP, no fixtures, no mock expectations — rows in, rows out. The fake executes the QuerySpec, so a test that passes proves the query says what you meant:
use Esanj\RemoteEloquent\Testing\FakeResourceTransport; $fake = User::fake([ ['id' => 1, 'first_name' => 'Ada', 'is_active' => true], ['id' => 2, 'first_name' => 'Grace', 'is_active' => false], ]); $this->assertCount(1, User::query()->active()->limit(10)->get()); FakeResourceTransport::assertQueried('users', fn (array $spec) => $spec['limit'] === 10); FakeResourceTransport::assertRequestCount(1); // the N+1 guard
Or through the facade, which returns a fluent arrangement object:
RemoteResource::fake() ->seed('users', [['id' => 1, 'first_name' => 'Ada']]) ->grant(['users.read', 'users.update']) ->failWith('wallets', 429, retryAfter: 30);
FakeResourceTransport::reset() (or RemoteResourceFake::stop()) in tearDown — stopFaking() does not clear
seeded rows.
The fake pins semantics the server has to match, and they are stricter than one driver's defaults on purpose:
between is half-open, text matching is case sensitive, NULLs order last in both directions, and a
comparison against null is unknown — so neither where('x', 1) nor whereNot('x', 1) returns a null row. A test
that needed case folding fails loudly here instead of passing on MySQL and failing on PostgreSQL.
Artisan commands
| Command | What it does |
|---|---|
remote:schema {resource} [--diff] [--model=] |
Print the published contract, or diff it against your model. Exits non-zero on drift, so CI can hold the line. |
remote:access [--json] |
The permissions and quota Accounting grants this application. |
remote:model {resource} [--auth] [--model=] [--namespace=] [--path=] |
Generate a model from the schema. No --force: it will not overwrite. |
remote:doctor [--offline] |
Configuration, reachability, TLS, permissions, models vs. schemas, and exists:/unique: rules in string form. Non-zero on any problem. |
Limits
config/esanj/remote_eloquent.php, all under esanj.remote_eloquent:
| Setting | Default | Meaning |
|---|---|---|
limits.max_query_limit |
100 | The largest page. The server caps it too; this turns a certain 400 into an exception naming the builder call. |
limits.in_chunk |
500 | The longest in list one request may carry, capped at the server's own 500. A longer whereIn() is split across calls and merged. A not_in, a list with an orWhere() beside it, and a negated clause cannot be split and are refused by name — see Reading for why. |
rest.timeout / rest.connect_timeout |
5 / 2 s | Short on purpose. Waiting 30 seconds for an answer that is not coming turns a fast failure into a slow one. |
cache.identity_map |
true | Per-request memo, so resolving the same id twice is one call. Never shared between requests. |
cache.find_ttl |
0 | Records are not cached across requests. Accounting is the system of record for balances and permissions. |
cache.schema_ttl / cache.access_ttl |
3600 / 600 s | The two descriptive endpoints. Both are dropped early when the server reports a new X-Schema-Version or X-Permissions-Version. |
telemetry.call_warning_threshold |
20 | Log a warning once one request has made this many remote calls — that is the N+1 that used to be a join. |
fallback |
false | Not configurable. v1 replayed failed calls on a second transport and re-sent accepted writes. |
Further reading
docs/GUIDE.md — the long version: the QuerySpec itself, every refusal with a worked
alternative, relations across the boundary, the identity map, actor tokens, and how to debug a slow page.