darkterminal/turso-http
A lightweight PHP and Laravel client for the Turso HTTP API. Run SQL queries against your Turso/libSQL databases over plain HTTP, with no native extensions or PDO drivers required. Simple configuration, clean results, and a great fit for serverless and edge deployments.
Pull the package in with Composer — there is no Laravel service provider to publish, so this is the whole install:
composer require darkterminal/turso-http
Two credentials matter and they are not interchangeable:
turso db tokens create <db>). This is what you need to actually run SQL over HTTP.Put them in .env:
TURSO_DATABASE_URL=libsql://my-db-myorg.turso.io
TURSO_AUTH_TOKEN=eyJhbGciOi...
TURSO_PLATFORM_TOKEN=...
TURSO_ORG=my-org
First use case is almost always "run a query from a controller or artisan command to prove the round trip works":
$db = TursoHttp::init()
->url(config('services.turso.url'))
->token(config('services.turso.token'));
$result = $db->query('SELECT id, email FROM users LIMIT 5');
Where to look first: the src/core/ directory. The package splits into an endpoint client (the thing that runs SQL against libsql://) and a Platform API client (the thing that manages databases/tokens). Read those two classes before anything else — the README covers the happy path but the endpoint class is where you'll learn what the response payload actually looks like and which HTTP API version you're talking to.
Second stop: turso db show <db> --url and turso db tokens create <db> from the Turso CLI so you have a working URL/token pair to paste into .env. Don't try to debug the first query through the Platform API.
Wrap it in a single bound service, never call it from controllers. The package leans on mutable/static state for URL and token, which is fine in a one-off script and a hazard everywhere else. Bind a thin TursoClient in a service provider, read config inside it, and inject that:
$this->app->singleton(TursoClient::class, fn () => new TursoClient(
config('services.turso.url'),
config('services.turso.token'),
));
Repository pattern over raw payloads. The response shape is a JSON envelope with a results array; don't leak it upward. Have your repositories return collect() of plain arrays so the rest of the app never learns it isn't talking to Eloquent:
public function recentUsers(int $limit): Collection
{
$rows = $this->turso->query('SELECT id, email FROM users ORDER BY id DESC LIMIT ?', [$limit]);
return collect($rows)->map(fn ($r) => ['id' => (int) $r['id'], 'email' => $r['email']]);
}
That mapping step is not optional — see the typed-values gotcha below.
Batch aggressively. Every call is an HTTPS round trip. For seeders, imports, or anything resembling a loop, use the pipeline/batch capability of the HTTP endpoint rather than 500 individual query() calls. A seeder that takes 3 seconds against local SQLite will take minutes one-row-at-a-time against Turso from a laptop in the wrong region.
Multi-tenant provisioning is the real killer use case. The Platform API turns "spin up a database per customer" into a queued job: create the database, poll until ready, mint a token, store the URL and token on the tenant record, dispatch a migration job to create the schema. Do this in a job, not the signup request — provisioning takes seconds and you don't want it in the request lifecycle. Mirror it with a deprovision job on tenant deletion so you don't leave orphaned databases paying rent.
Read-path pattern: Turso as the shared source, cache in front. Because it's HTTP, a query costs 30–150ms depending on region. For anything hot, cache the result (Cache::remember) or keep the data in Redis. Turso is good at durable, globally-replicated storage; it is not a substitute for a connection-pooled local DB on a hot path.
Keep migrations outside the package. The HTTP endpoint has no DDL migration framework and Laravel's schema builder can't talk to it. Apply schema with turso db shell <db> < migration.sql in CI, or with the libSQL CLI, and treat the PHP package as runtime-query-only. Trying to build a migration runner on top of it is a rabbit hole.
Health check + fallback. Add a /health/turso endpoint that runs SELECT 1 with a short timeout. If your app degrades when Turso is unreachable, you want that visible in your uptime monitor, not in a support ticket.
It is not a PDO driver. No DB::connection('turso'), no Eloquent models, no relationships, no php artisan migrate. If you're planning a full Laravel app on it, budget for writing your own query builder wrapper — or reconsider and put Turso behind a sync job into Postgres/MySQL.
v2 results are typed JSON, not scalars. The libSQL HTTP v2 endpoint returns values as objects like {"type":"integer","value":"42"}, so $row['id'] may be a string or an array depending on the version and endpoint you hit. Always normalize in the repository layer. This is the single most common source of "works in curl, breaks in PHP" bugs.
HTTP API version matters. LibSQLEndpointAPI::V2 vs the v1/pipeline endpoint differ in both request shape and response shape. Pin one explicitly in your client wrapper and don't let it drift between call sites.
N+1 is expensive here — more than usual. A local SQLite N+1 is microseconds; an HTTP one is a network round trip. If you find yourself looping a query inside a Blade partial, that's a 40-second page load waiting to happen. Batch or cache.
Tokens: scope them down and rotate them. Mint read-only tokens for anything that only reads. Never ship the Platform API token anywhere but the server — it can delete every database in your org. Store per-tenant tokens encrypted (casts = ['turso_token' => 'encrypted']) and have a rotation job, since they don't expire on their own unless you set an expiration.
Retries and timeouts are your job, not the package's. Set a sane timeout (5s for interactive queries, more for batch jobs) and wrap transient failures with retry(3, ..., fn ($e) => $e instanceof ConnectionException). Back off on 429 and 5xx; don't retry on 4xx — a malformed SQL statement will just fail three times.
Long-running workers and Octane. Because the client holds URL/token as mutable state, any code path that mutates it mid-request can bleed into the next request under Octane or in a queued worker that processes multiple tenants. Instantiate a fresh client per tenant/job, or make the wrapper immutable (constructor-injected config, no setters exposed).
Testing: don't hit the network in your suite. Put your TursoClient behind an interface, bind a fake implementation in TestCase, and assert on the SQL string and bindings. For integration tests that need real SQL semantics, run sqld locally with Docker and point the URL at http://127.0.0.1:8080 — the package doesn't care whether the scheme is libsql:// or http://.
Large result sets don't stream. Everything comes back in one JSON body. Use keyset pagination (WHERE id > ? ORDER BY id LIMIT ?) rather than OFFSET — offsets get slow and Turso will happily serialize a 200MB response at you if you ask for it.
Escape hatches worth knowing. The endpoint client is a thin wrapper over the documented libSQL HTTP API, so anything it doesn't expose you can reach with a raw Http::withToken(...)->post(...) — useful if you need a feature the wrapper lags on. The Platform API client likewise maps to documented REST endpoints; check the package version against Turso's API changelog before assuming a missing method is unavailable.
How can I help you explore Laravel packages today?