phattarachai / laravel-sap-proxy
Laravel database driver that runs SAP HANA queries through an HTTP SQL proxy — retries a restarting proxy, base64-encodes SQL past IPS filters, and reports the failing SQL.
Requires
- php: ^8.4
- guzzlehttp/guzzle: ^7.0 || ^8.0
- illuminate/database: ^12.0 || ^13.0
- illuminate/http: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
Requires (Dev)
- larastan/larastan: ^3.10
- laravel/pint: ^1.24
- orchestra/testbench: ^10.8|^11.0
- pestphp/pest: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A Laravel database driver that runs SAP HANA queries through a small HTTP SQL proxy instead of a local ODBC driver. Your app keeps using Eloquent and the query builder; every statement is sent as one HTTP request to a proxy that holds the HANA connection.
It is built for hosts where installing the HANA ODBC client in every PHP app is impractical, and it handles what a proxy in the middle adds:
- Retries a restarting proxy. A refused, reset or empty connection (the proxy was restarted by a deploy or systemd) and a 502/503 from a fronting web server are retried with backoff. A timeout is not retried: it means a slow query, and retrying it would stretch the wait past any job timeout.
- Fails fast when SAP is down. A proxy with a circuit breaker answers a flagged 503 when SAP
rejects its login or can't be reached. The driver throws
SapUnavailableExceptionon the first answer instead of retrying, so an outage doesn't turn into a flood of logins. - Encodes SQL in base64, so a network IPS/DPI that resets connections carrying SQL-injection
signatures (
union … select) never sees the SQL text. - Reports the failing SQL. Successful statements fire
QueryExecuted, so they show up inDB::listen, Pulse and Sentry-style breadcrumbs. Failures carry the SQL incontext(), which Laravel logs and Sentry-compatible trackers attach asexception_context. - Escapes bound values (
'doubled,?inside a value never re-substituted, dates, bools, enums), because the proxy takes one plain SQL string.
Installation
composer require phattarachai/laravel-sap-proxy
The service provider is auto-discovered and registers the proxy database driver.
Configuration
Add a connection to config/database.php:
'sap' => [ 'driver' => 'proxy', 'proxy_url' => env('SAP_PROXY_URL'), // e.g. http://127.0.0.1:3100/api/query 'database' => 'sap', // connection name sent to the proxy 'timeout' => (int) env('SAP_PROXY_TIMEOUT', 120), // 'retry' => [500, 2000, 5000], // ms before each retry; [] disables retry // 'encode_sql' => true, // false sends plain `sql` for proxies that can't decode `sql_b64` ],
| Key | Default | Meaning |
|---|---|---|
proxy_url |
— (required) | The proxy's query endpoint. |
database / proxy_connection |
sap |
Which connection the proxy should run the SQL on. |
timeout |
120 |
Seconds per attempt. proxy_timeout is read as a fallback. |
retry |
[500, 2000, 5000] |
Milliseconds to wait before each retry. The default makes up to 4 attempts over about 7.5s. |
encode_sql |
true |
Send sql_b64 instead of sql. |
Then query it like any other connection:
DB::connection('sap')->table('OINV')->where('DocNum', 455065)->first(); class OINV extends Model { protected $connection = 'sap'; protected $table = 'OINV'; }
selectWithLimit() asks the proxy to cap the rows before it serializes them, which is useful for
interactive query editors:
['data' => $rows, 'total' => $total, 'truncated' => $truncated] = DB::connection('sap')->selectWithLimit($sql, 5000);
The proxy protocol
The driver POSTs JSON and expects JSON back:
// request { "sql_b64": "U0VMRUNUIC4uLg==", "connection": "sap", "maxRows": 5000 } // maxRows only from selectWithLimit // 200 { "data": [ { "DocNum": 455065 } ], "total": 1, "truncated": false } // 422: the database rejected the SQL { "error": "sql syntax error: incorrect syntax near \"FROM\"" } // 503 + `Retry-After: 300`: SAP is unavailable (optional; the proxy's circuit breaker is open) { "error": "authentication failed", "sap_unavailable": true, "reason": "auth", "retry_after": 300 }
reason is auth (SAP rejected the login) or connect (SAP could not be reached). retry_after
is the breaker's cooldown in seconds, or null when the breaker has latched and waits for a manual
reset. A 503 without sap_unavailable: true is treated as a restarting proxy and retried.
A Node.js proxy that speaks this protocol over the hdb
client is about 150 lines of code. Give it a SIGTERM handler that drains in-flight requests, so a
restart does not cut off queries that are still running.
Exceptions
| Exception | When | Notes |
|---|---|---|
Phattarachai\SapProxy\Exceptions\SapQueryException |
The proxy answered 422 | The message is the database's error text, safe to show to whoever wrote the SQL. ->sql holds the statement. |
Phattarachai\SapProxy\Exceptions\SapProxyException |
Unreachable, timed out, or a non-422 failure after every retry | The previous exception is the underlying ConnectionException / RequestException. ->sql and context() hold the statement that was running. |
Phattarachai\SapProxy\Exceptions\SapUnavailableException |
The proxy answered its flagged 503: SAP is unavailable | Extends SapProxyException. Never retried. ->reason is auth or connect, ->retryAfter the cooldown in seconds (null when latched); both are in context() too. |
Handling SapUnavailableException
The exception means SAP itself is down, not your query, and every request will get the same answer
until the proxy's cooldown ends. Because it extends SapProxyException, code that already catches
that keeps working. To do better:
-
Scheduled pollers (sync jobs that run every few minutes): stop the run quietly and don't retry it. The next scheduled run is the retry. Don't dispatch child jobs that would each fail the same way.
try { $rows = DB::connection('sap')->select($sql); } catch (SapUnavailableException) { return; // the next tick tries again }
-
Work that must happen (a report someone is waiting for, a document that must be sent): put the job back on the queue with jittered minutes, so a fleet of workers doesn't come back in the same second. Don't release it when the breaker has latched (
reason === 'auth'andretryAfter === null): SAP is rejecting the login and only a person can fix that, so let the job fail.public $tries = 3; public function handle(): void { try { $this->run(); } catch (SapUnavailableException $e) { if ($e->reason === 'auth' && $e->retryAfter === null) { throw $e; } $this->release(max($e->retryAfter ?? 0, random_int(300, 600))); } }
-
Reporting: one outage can raise thousands of these. Throttle them in
bootstrap/app.php, so the error tracker gets one event per outage:use Illuminate\Cache\RateLimiting\Limit; ->withExceptions(function (Exceptions $exceptions) { $exceptions->throttle(fn (Throwable $e) => match (true) { $e instanceof SapUnavailableException => Limit::perMinutes(30, 1)->by('sap-unavailable'), default => Limit::none(), }); })
The connection is read-only by nature: it has no PDO, so transactions and cursor() throw a
LogicException.
Testing
composer test
License
MIT. See LICENSE.md.
ผู้พัฒนา
พัฒนาและดูแลโดย บริษัท ภัทรชัย อาร์ทิซาน จำกัด (Phattarachai Artisan) บริษัทที่ปรึกษาและพัฒนาเว็บ ที่เรียนรู้และแบ่งปันกับชุมชน Laravel แพ็กเกจนี้แบ่งปันให้ชุมชนนำไปใช้และต่อยอดได้อย่างอิสระ
ดูแพ็กเกจอื่นของเราได้ที่ phattarachai.dev/open-source และติดต่อเราได้ที่ phattarachai.dev