Baselyra Docs

PHP

No dependencies beyond ext-curl and ext-json. This client acts either as a signed-in visitor — forward their token and row level security applies — or as a trusted server holding the service key.

The client

<?php
declare(strict_types=1);

final class BaselyraError extends RuntimeException
{
    public function __construct(
        public readonly string $errorCode,
        string $message,
        public readonly int $status,
    ) {
        parent::__construct($message, $status);
    }
}

final class Baselyra
{
    private ?string $accessToken = null;

    public function __construct(
        private readonly string $url,
        private readonly string $key,   // anon key in a browser-facing app,
                                        // service key only in trusted server code
    ) {}

    public function setAccessToken(?string $token): void
    {
        $this->accessToken = $token;
    }

    /** @return array{0: mixed, 1: array<string,string>} decoded body and response headers */
    private function request(string $method, string $path, mixed $body = null, array $extraHeaders = []): array
    {
        $headers = array_merge([
            'apikey: ' . $this->key,
            'authorization: Bearer ' . ($this->accessToken ?? $this->key),
            'content-type: application/json',
        ], $extraHeaders);

        $ch = curl_init($this->url . $path);
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST  => $method,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER     => $headers,
            CURLOPT_TIMEOUT        => 30,
            CURLOPT_HEADER         => true,
        ]);
        if ($body !== null) {
            curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_THROW_ON_ERROR));
        }

        $raw = curl_exec($ch);
        if ($raw === false) {
            $message = curl_error($ch);
            curl_close($ch);
            throw new BaselyraError('network_error', $message, 0);
        }

        $status     = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
        curl_close($ch);

        $responseHeaders = [];
        foreach (explode("\r\n", substr($raw, 0, $headerSize)) as $line) {
            if (str_contains($line, ':')) {
                [$name, $value] = explode(':', $line, 2);
                $responseHeaders[strtolower(trim($name))] = trim($value);
            }
        }

        $payload = substr($raw, $headerSize);
        $decoded = $payload === '' ? null : json_decode($payload, true);

        if ($status >= 400) {
            $error = is_array($decoded) && isset($decoded['error']) ? $decoded['error'] : [];
            throw new BaselyraError(
                (string) ($error['code'] ?? 'http_error'),
                (string) ($error['message'] ?? 'Request failed'),
                $status,
            );
        }

        return [$decoded, $responseHeaders];
    }

    public function signIn(string $email, string $password): array
    {
        [$session] = $this->request('POST', '/auth/v1/token?grant_type=password', [
            'email' => $email, 'password' => $password,
        ]);
        $this->accessToken = $session['access_token'];
        return $session;
    }

    /** @param array<string,string> $query e.g. ['select' => 'id,title', 'published' => 'eq.true'] */
    public function select(string $table, array $query = []): array
    {
        [$rows] = $this->request('GET', '/rest/v1/' . rawurlencode($table) . '?' . http_build_query($query));
        return $rows ?? [];
    }

    /** Total row count for a filter, without fetching the rows. */
    public function count(string $table, array $query = []): int
    {
        $query['limit'] = '1';
        [, $headers] = $this->request(
            'GET',
            '/rest/v1/' . rawurlencode($table) . '?' . http_build_query($query),
            null,
            ['Prefer: count=exact'],
        );
        $range = $headers['content-range'] ?? '*/0';
        return (int) substr($range, strpos($range, '/') + 1);
    }

    public function insert(string $table, array $row): array
    {
        [$rows] = $this->request('POST', '/rest/v1/' . rawurlencode($table), $row, ['Prefer: return=representation']);
        return $rows[0] ?? [];
    }

    /** @param array<string,string> $filters required — the server refuses an unfiltered write */
    public function update(string $table, array $filters, array $patch): array
    {
        if ($filters === []) {
            throw new InvalidArgumentException('a filter is required: an unfiltered PATCH rewrites the table');
        }
        [$rows] = $this->request(
            'PATCH',
            '/rest/v1/' . rawurlencode($table) . '?' . http_build_query($filters),
            $patch,
            ['Prefer: return=representation'],
        );
        return $rows ?? [];
    }

    public function delete(string $table, array $filters): void
    {
        if ($filters === []) {
            throw new InvalidArgumentException('a filter is required: an unfiltered DELETE empties the table');
        }
        $this->request('DELETE', '/rest/v1/' . rawurlencode($table) . '?' . http_build_query($filters));
    }

    public function rpc(string $fn, array $args = []): mixed
    {
        [$result] = $this->request('POST', '/rest/v1/rpc/' . rawurlencode($fn), $args);
        return $result;
    }
}

Using it

<?php
require 'Baselyra.php';

$bl = new Baselyra('https://api.example.com', getenv('BASELYRA_ANON_KEY'));

// Acting as the signed-in visitor: forward their token, and RLS applies.
$bl->setAccessToken($_SESSION['bl_access_token'] ?? null);

$articles = $bl->select('articles', [
    'select'       => 'id,title,published_at',
    'published_at' => 'not.is.null',
    'order'        => 'published_at.desc',
    'limit'        => '10',
]);

foreach ($articles as $article) {
    echo htmlspecialchars($article['title']), "\n";
}

$total = $bl->count('articles', ['published_at' => 'not.is.null']);
echo "$total published\n";

Handling the error

try {
    $bl->insert('articles', ['title' => 'Hello', 'slug' => 'hello']);
} catch (BaselyraError $e) {
    if ($e->errorCode === 'unique_violation') {
        // a duplicate slug — show it next to the field
    } elseif ($e->status === 401) {
        // the access token expired; refresh or send them to sign in
    } else {
        error_log("baselyra {$e->status} {$e->errorCode}: {$e->getMessage()}");
    }
}

Branch on errorCode, never on the message. The full list is on REST API.

Keys and sessions

Acting asConstruct withAnd
A visitorthe anon keysetAccessToken($visitorToken) — RLS applies to their own rows
Nobodythe anon keyno access token — the request runs as anon
The serverthe service keynever set an access token; every policy is bypassed

Storing the whole session in $_SESSION is fine. Store the refresh token too, and when a request comes back 401, exchange it once and retry:

/** Add to the class. A refresh token is single-use: store BOTH values it returns. */
public function refresh(string $refreshToken): array
{
    [$session] = $this->request('POST', '/auth/v1/token?grant_type=refresh_token', [
        'refresh_token' => $refreshToken,
    ]);
    $this->accessToken = $session['access_token'];
    return $session;
}
try {
    $rows = $bl->select('notes', ['select' => 'id,title']);
} catch (BaselyraError $e) {
    if ($e->status !== 401) throw $e;
    $session = $bl->refresh($_SESSION['bl_refresh_token']);
    $_SESSION['bl_access_token']  = $session['access_token'];
    $_SESSION['bl_refresh_token'] = $session['refresh_token'];   // the old one is now spent
    $rows = $bl->select('notes', ['select' => 'id,title']);
}

Failure modes

What you seeWhyFix
network_error with an empty messagecurl could not connect — TLS, DNS, or a firewallcurl -v the same URL from the same host
401 an hour after sign-inThe access token expiredRefresh with the stored refresh token and store both new values
[] where the Studio shows rowsRLS, or you forgot setAccessTokenCheck both, in that order
A filter value with + matches nothinghttp_build_query encodes it correctly; a hand-built string does notUse http_build_query
409 unique_violationA constraint refused the rowRead details.detail, which names the key
InvalidArgumentException: a filter is requiredThe guard above firedPass a filter — the server would have refused anyway

Edit this page Report a problem

Esc
navigate open Esc close