Search by

phattarachai / laravel-sap-proxy

phatchai

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.

Package info

github.com/phattarachai/laravel-sap-proxy

pkg:composer/phattarachai/laravel-sap-proxy

Statistics

Installs: 116

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.0 2026-10-25 17:56 UTC

This package is auto-updated.

Last update: 2026-10-30 03:09:45 UTC


README

Latest Version on Packagist Tests Code Style PHP Version Laravel Version Total Downloads

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 SapUnavailableException on 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 in DB::listen, Pulse and Sentry-style breadcrumbs. Failures carry the SQL in context(), which Laravel logs and Sentry-compatible trackers attach as exception_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' and retryAfter === 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