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 as | Construct with | And |
|---|---|---|
| A visitor | the anon key | setAccessToken($visitorToken) — RLS applies to their own rows |
| Nobody | the anon key | no access token — the request runs as anon |
| The server | the service key | never 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 see | Why | Fix |
|---|---|---|
network_error with an empty message | curl could not connect — TLS, DNS, or a firewall | curl -v the same URL from the same host |
401 an hour after sign-in | The access token expired | Refresh with the stored refresh token and store both new values |
[] where the Studio shows rows | RLS, or you forgot setAccessToken | Check both, in that order |
A filter value with + matches nothing | http_build_query encodes it correctly; a hand-built string does not | Use http_build_query |
409 unique_violation | A constraint refused the row | Read details.detail, which names the key |
InvalidArgumentException: a filter is required | The guard above fired | Pass a filter — the server would have refused anyway |