esanj / remote-eloquent
Remote Eloquent models for Laravel — run native Eloquent queries against the Esanj Accounting service over gRPC or REST.
Package info
github.com/eSanjDev/ms-package-accounting-remote-eloquent
pkg:composer/esanj/remote-eloquent
Requires
- php: ^8.2|^8.3|^8.4
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.0|^11.0|^12.0
Suggests
- ext-grpc: Required for the gRPC transport (REMOTE_ELOQUENT_DRIVER=grpc).
- esanj/auth-bridge: Reuse an existing client-credentials token issuer instead of the built-in AccessTokenManager.
- google/protobuf: Required to (de)serialize the gRPC QueryRequest/QueryResponse messages.
- grpc/grpc: Provides the \Grpc\BaseStub client used by the gRPC transport.
README
Remote Eloquent lets a Laravel service talk to the Esanj Accounting database as if the tables were local.
You extend RemoteModel instead of Laravel's Model, and every Eloquent query you write is compiled to SQL and
executed on the Accounting service over gRPC or REST — you get rows back and hydrate real models. No local
tables, no schema copies, no hand-written API client.
use Esanj\RemoteEloquent\Eloquent\RemoteModel; class User extends RemoteModel { protected $table = 'users'; protected $casts = ['id' => 'int', 'email_verified_at' => 'datetime']; } User::where('email', 'ada@example.com')->first(); // SELECT ... over the wire User::query()->orderByDesc('id')->paginate(); // count + page, remotely $user->update(['name' => 'Ada L.']); // UPDATE ... over the wire
Built to pair with
esanj/auth-bridge. It reuses the same OAuth client-credentials model to authenticate, and defaults its credentials to the sameACCOUNTING_BRIDGE_*env vars.
How it works
Your service Remote Eloquent (this package) Accounting service
| User::where(...)->get() | |
|------------------------------------->| Eloquent builds the query |
| | MySQL grammar compiles -> SQL + ? |
| | attach cached Bearer token |
| |----- {sql, bindings} (gRPC/REST) -->| authorize by feature
| | | run single-table statement
| |<---- {rows, affected_rows} ---------| return string rows
|<-- hydrated Collection<User> ---------| rows -> stdClass -> models (casts) |
The heavy lifting is a custom database connection with no PDO. It reuses Laravel's MySQL query grammar and
processor to turn any Eloquent query into a single SQL statement, then hands that statement to a transport
(gRPC or REST) instead of a local driver. Because the whole query builder is reused, the entire Eloquent
read/write surface keeps working: where, whereIn, orderBy, limit/offset, aggregates (count, sum),
paginate, find, first, pluck, exists, create, update, delete, updateOrCreate, and so on.
Features
- Drop-in Eloquent — extend
RemoteModel; keep writing normal Eloquent. - Two transports, one contract —
rest(turnkey, zero extra deps) orgrpc. Switch the default with one env var, or pin an individual model to a transport withprotected $transport = 'grpc';. - Automatic transport fallback — when REST is unreachable the statement is replayed over gRPC (and the other
way round), logged, and never at the cost of correctness: server verdicts are not re-asked and writes are not
replayed blindly. Opt a model out with
protected $transportFallback = false;. - Automatic token caching — an OAuth client-credentials token is fetched once, cached, and transparently refreshed shortly before it expires (via the refresh-token grant when available, otherwise re-requested).
- Server-enforced authorization — every statement is gated on the Accounting side by the calling
application's capability features (per
table.operation). This package never bypasses that. - Typed exceptions —
InvalidQueryException(422),QueryAccessDeniedException(403),TransportException,TokenRequestException. RemoteQueryfacade — for raw one-off statements and token control without a model.
Requirements
- PHP 8.2+, Laravel 11 – 13 (tested on 13).
- Reachable Accounting service (REST base URL and/or gRPC endpoint) plus an OAuth client id/secret issued by it.
- gRPC only: the
ext-grpcPHP extension plus thegrpc/grpcandgoogle/protobufcomposer packages. The protobuf message classes ship with this package — no code generation needed. REST needs none of this.
Installation
This package lives in the Esanj monorepo.
composer require esanj/remote-eloquent
php artisan vendor:publish --tag="esanj-remote-eloquent-config" # optional
The service provider and the RemoteQuery facade are auto-discovered. The package registers its own remote
database connection — you do not touch config/database.php.
Configuration
Minimum .env (credentials fall back to the esanj/auth-bridge variables, so a service already wired for
Accounting needs almost nothing new):
# Where the Accounting service lives (REST). Defaults to ACCOUNTING_BRIDGE_BASE_URL. REMOTE_ELOQUENT_BASE_URL=https://accounting.example.com # OAuth client-credentials. Default to ACCOUNTING_BRIDGE_CLIENT_ID / _SECRET. REMOTE_ELOQUENT_CLIENT_ID=your-client-id REMOTE_ELOQUENT_CLIENT_SECRET=your-client-secret # Transport: rest (default) or grpc REMOTE_ELOQUENT_DRIVER=rest # Replay a statement on the other transport when this one is unreachable (default true) REMOTE_ELOQUENT_FALLBACK=true
For gRPC, install the stack (ext-grpc, grpc/grpc, google/protobuf) and set:
REMOTE_ELOQUENT_DRIVER=grpc REMOTE_ELOQUENT_GRPC_HOST=accounting.example.com:50051
The QueryRequest/QueryResponse protobuf classes ship with the package (namespace
Esanj\RemoteEloquent\Grpc) and are wired as the defaults — you do not need to run protoc. Only set
REMOTE_ELOQUENT_GRPC_REQUEST / REMOTE_ELOQUENT_GRPC_RESPONSE if you want to point at your own generated classes.
See src/config/remote_eloquent.php for every option (timeouts, token cache store &
buffer, connection name, table prefix, …).
Usage
1. Write a model
use Esanj\RemoteEloquent\Eloquent\RemoteModel; class Client extends RemoteModel { protected $table = 'clients'; // must match the remote table name exactly protected $guarded = []; // Remote values arrive as strings; casts restore real types on the way in. protected $casts = [ 'id' => 'int', 'revoked' => 'bool', 'created_at' => 'datetime', ]; }
2. Use Eloquent normally
Client::find($id); Client::where('revoked', false)->orderByDesc('id')->limit(20)->get(); Client::query()->paginate(15); Client::count();
3. Writes
$user = User::create(['name' => 'Grace', 'email' => 'grace@example.com']); $user->update(['name' => 'Grace H.']); $user->delete();
⚠️ Auto-increment ids on insert. The current server contract returns affected rows for writes, not the new id, so
create()cannot echo back a database-generatedidunless the server is extended to return one. Prefer client-generated keys (UUID/ULID viaHasUuids) for models you create remotely. Reads, updates and deletes are unaffected. See docs/GUIDE.md.
Per-model transport
REMOTE_ELOQUENT_DRIVER sets the default transport for every model. To pin a single model to a specific
transport — regardless of that default — declare $transport:
class Ledger extends RemoteModel { protected $table = 'ledgers'; protected $transport = 'grpc'; // this model always talks gRPC; others use the default }
Accepted values are 'rest' and 'grpc' (an unknown value throws InvalidArgumentException). Under the hood each
transport has its own auto-registered remote connection (remote, remote_rest, remote_grpc), so models using
different transports stay fully isolated. For dynamic decisions, override getTransportName(): ?string instead.
Transport fallback
If the transport a statement is running on turns out to be unreachable, misconfigured or answering with an
unexpected status, the statement is replayed on the other one — REST covers for gRPC and gRPC covers for REST —
and a warning is logged for every handover. It is on by default; turn it off globally with
REMOTE_ELOQUENT_FALLBACK=false.
Two things are deliberately not retried, because the fallback replaces a broken pipe, not a valid answer:
- Server verdicts. A rejected statement (422) or a denied table (403) is thrown straight through — the other transport would give the same verdict, and hiding it behind a second round-trip only delays the real error.
- Writes that may already have landed. A REST
INSERTthat timed out might have been applied server-side, so replaying it over gRPC could apply it twice. Writes therefore stay put unless the failure provably happened before anything was sent (a missing gRPC stack, say), or you accept the risk withREMOTE_ELOQUENT_FALLBACK_RETRY_WRITES=true.
To keep a single model on its own transport regardless of the global setting:
class User extends RemoteModel { protected $table = 'users'; protected $transportFallback = false; // never silently switch transport }
true forces fallback on for that model even when the package default is off, and null (the default) follows the
package setting. Each combination has its own auto-registered connection (remote_grpc_nofallback, …). For dynamic
decisions, override getTransportFallback(): ?bool. When every transport in the chain fails you get a single
TransportException naming each attempt, with the primary failure as getPrevious().
Raw queries (no model)
use Esanj\RemoteEloquent\Facades\RemoteQuery; $rows = RemoteQuery::select('select * from `users` where `id` = ?', [7]); // list<array<string,string>> $affected = RemoteQuery::affectingStatement('update `users` set `name` = ? where `id` = ?', ['Ada', 7]); RemoteQuery::forgetToken(); // drop the cached access token
Important constraints
These come from the Accounting server contract — Remote Eloquent surfaces them, it does not impose them:
| Constraint | What it means for you |
|---|---|
| Single table per query | No JOIN/UNION, no cross-table whereHas. Load related data with separate queries. Violations throw InvalidQueryException. |
| Feature-gated | Each table.operation must be permitted for your application on the Accounting side (e.g. USER_LIST → SELECT users). Otherwise QueryAccessDeniedException. |
| Values are strings | Every column comes back as a string, so declare $casts. Timestamps, ints and bools then hydrate correctly. |
NULL reads as "" |
The contract stringifies NULL to an empty string. A nullable column that is NULL arrives as ''; a nullable-int cast becomes 0. Keep this in mind for nullable columns. |
| No transactions | The remote connection cannot open a real DB transaction; DB::transaction() on it runs the callback without atomicity. |
Error handling
use Esanj\RemoteEloquent\Exceptions\QueryAccessDeniedException; use Esanj\RemoteEloquent\Exceptions\InvalidQueryException; use Esanj\RemoteEloquent\Exceptions\TransportException; try { User::create([...]); } catch (QueryAccessDeniedException $e) { // 403 — your app lacks the capability feature report($e); } catch (InvalidQueryException $e) { // 422 — the statement was rejected (e.g. a JOIN) report($e); } catch (TransportException $e) { // connectivity / auth / unexpected status report($e); }
All extend Esanj\RemoteEloquent\Exceptions\RemoteEloquentException (which carries getContext()). They are surfaced
unwrapped through Eloquent, so you catch them directly.
Documentation
For a step-by-step walkthrough — installation, a first model, both transports, generating the gRPC stubs, the write caveats and troubleshooting — see docs/GUIDE.md.
Credits
Developed and maintained by the Esanj Tech Team.