ntanduy/cloudflare-d1-database
Use Cloudflare D1 as a native Laravel database driver with full Eloquent ORM, Query Builder, and migration support. Choose REST API or Worker connections, batch queries, bulk inserts, read replication, import and schema dump commands, time travel, retries, and circuit breaking. Requires PHP 8.2+ and...
Use Cloudflare D1 as a native Laravel database driver — full Eloquent ORM, Query Builder, and Migration support.
php artisan d1:importphp artisan d1:schema-dumpphp artisan d1:time-travelphp artisan d1:infophp artisan d1:health to verify connection and measure latencycomposer require ntanduy/cloudflare-d1-database
php artisan vendor:publish --tag="d1-config"
This creates config/d1-database.php with all available options.
This package supports two drivers to connect Laravel with Cloudflare D1:
| Driver | How it works | Latency | Setup |
|---|---|---|---|
| REST (default) | Calls Cloudflare D1 REST API directly | Higher (extra HTTP hop) | API Token only |
| Worker | Routes queries through your own Cloudflare Worker | Lower (co-located with D1) | Requires deploying a Worker |
The simplest setup — no extra infrastructure needed. Queries are sent to Cloudflare's REST API.
Add to your .env:
CF_D1_API_TOKEN=your_api_token
CF_D1_ACCOUNT_ID=your_account_id
CF_D1_DATABASE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
How to get these values:
That's it! Your Laravel app can now use D1.
For production apps that need lower latency, deploy a Cloudflare Worker as a proxy between Laravel and D1.
Add to your .env:
CF_D1_DRIVER=worker
CF_D1_WORKER_URL=https://your-d1-worker.your-subdomain.workers.dev
CF_D1_WORKER_SECRET=a-strong-shared-secret
A ready-to-deploy Worker template is included in the Worker/ directory. To deploy:
cd Worker
npm install
npx wrangler secret put WORKER_SECRET
npm run deploy
Before deploying, update wrangler.jsonc with your D1 database binding:
name = "ntanduy-d1-worker"
main = "src/index.ts"
compatibility_date = "2026-03-10"
[[d1_databases]]
binding = "DB"
database_name = "your-database-name"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
Important: Set
WORKER_SECRETusingnpx wrangler secret put WORKER_SECRET— never put secrets inwrangler.jsonc. This secret must match theCF_D1_WORKER_SECRETin your Laravel.env.
For additional security, enable HMAC request signing. Each request gets a unique signature that prevents body tampering and provides per-isolate replay detection.
Laravel .env:
CF_D1_HMAC=true
Worker (optional enforcement):
npx wrangler secret put HMAC_REQUIRED # Set to "true" to reject unsigned requests
npx wrangler secret put HMAC_WINDOW_SECONDS # Replay window (default: 300 = 5 minutes)
When enabled, the PHP driver adds three headers to every request:
X-D1-Timestamp — current Unix timestampX-D1-Nonce — random 32-character hex string (unique per request)X-D1-Signature — HMAC-SHA256 of timestamp.nonce.body using the shared secretThe Worker verifies these when present, rejects expired timestamps, and tracks seen nonces to prevent replay within the same isolate. The per-request nonce ensures that two identical requests within the same second produce different signatures and are not falsely rejected as replays. Without HMAC_REQUIRED=true, unsigned requests still work (backward compatible).
Note: Replay detection is per-isolate — it resets on Worker cold starts and is separate per Cloudflare colo. For stricter guarantees, consider using Durable Objects for global nonce storage.
The Worker exposes these endpoints:
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/health |
GET | ❌ | Health check |
/query |
POST | ✅ Bearer | Execute a single SQL query |
/batch |
POST | ✅ Bearer | Execute multiple statements atomically |
/exec |
POST | ✅ Bearer | Execute raw DDL/migration SQL |
/raw |
POST | ✅ Bearer | Execute a query and return raw array-of-arrays |
To use D1 as the default database, add to your .env:
DB_CONNECTION=d1
Run the built-in health check to verify your setup:
php artisan d1:health
D1 Health Check
Connection : d1
Driver : worker
+-------------------------+---------+------------------------------------------+
| Check | Status | Detail |
+-------------------------+---------+------------------------------------------+
| worker_url configured | ✓ OK | https://d1-proxy.name.workers.dev |
| worker_secret configured| ✓ OK | ******cret |
| Query test passed | ✓ OK | SELECT 1 as ok |
| End-to-end latency | ✓ OK | 24 ms |
+-------------------------+---------+------------------------------------------+
Overall: HEALTHY ✓
php artisan migrate --database=d1
use App\Models\Post;
// Create
$post = Post::create([
'title' => 'Hello from D1',
'body' => 'This is stored in Cloudflare D1!',
]);
// Read
$posts = Post::where('published', true)->orderBy('created_at', 'desc')->get();
// Update
$post->update(['title' => 'Updated Title']);
// Delete
$post->delete();
use Illuminate\Support\Facades\DB;
// Select
$users = DB::connection('d1')->table('users')
->where('active', true)
->limit(10)
->get();
// Insert
DB::connection('d1')->table('users')->insert([
'name' => 'John Doe',
'email' => '[email protected]',
]);
// Raw queries
$results = DB::connection('d1')->select('SELECT * FROM users WHERE id = ?', [1]);
Monitor queries for debugging or performance analysis:
use Ntanduy\CFD1\D1\D1Connection;
/** @var D1Connection $connection */
$connection = DB::connection('d1');
$connection->d1()->setQueryLogger(function (
string $query,
array $params,
float $timeMs,
bool $success,
?array $error
) {
if (! $success) {
Log::error("D1 query failed: {$query}", [
'params' => $params,
'error' => $error,
'time_ms' => $timeMs,
]);
}
});
use Ntanduy\CFD1\D1\D1Connection;
/** @var D1Connection $connection */
$connection = DB::connection('d1');
$connection->getDriver(); // 'rest' or 'worker'
$connection->isWorkerDriver(); // true or false
Execute multiple SQL statements in a single HTTP round-trip. On the Worker driver, this uses D1's native batch() for atomic execution.
use Ntanduy\CFD1\D1\D1Connection;
/** @var D1Connection $connection */
$connection = DB::connection('d1');
$results = $connection->batch([
['sql' => 'SELECT * FROM users WHERE id = ?', 'params' => [1]],
['sql' => 'UPDATE stats SET views = views + 1 WHERE id = ?', 'params' => [5]],
['sql' => 'SELECT COUNT(*) as total FROM posts'],
]);
$user = $results[0]; // Result set from first statement
$stats = $results[1]; // Result set from second statement
$count = $results[2]; // Result set from third statement
If any statement fails, a D1BatchException is thrown with the index of the failing statement:
use Ntanduy\CFD1\D1\Exceptions\D1BatchException;
try {
$results = $connection->batch($statements);
} catch (D1BatchException $e) {
// $e->getMessage() includes the failing statement index
}
| Feature | REST | Worker |
|---|---|---|
| Bulk Insert | ✅ | ✅ |
| Sessions / Read Replication | ❌ Not supported | ✅ Full support |
| Auto Read/Write Splitting | ❌ Not supported | ✅ Full support |
Import (d1:import) |
✅ | ✅ (via REST credentials) |
Time Travel (d1:time-travel) |
✅ | ✅ (via REST credentials) |
| Schema Dump | ✅ | ✅ (via REST credentials) |
Database Info (d1:info) |
✅ Full metadata | ✅ Query test + REST metadata |
| Batch Queries | ✅ | ✅ |
| Circuit Breaker | ✅ | ✅ |
| Automatic Retries | ✅ | ✅ |
Insert multiple rows efficiently using D1 batch execution:
use Ntanduy\CFD1\D1\D1Connection;
/** @var D1Connection $connection */
$connection = DB::connection('d1');
$connection->bulkInsert('users', [
['name' => 'Alice', 'email' => '[email protected]'],
['name' => 'Bob', 'email' => '[email protected]'],
['name' => 'Charlie', 'email' => '[email protected]'],
]);
D1 supports global read replication — read queries can be served by nearby replicas for lower latency. The Sessions API ensures sequential consistency across queries.
Important: Sessions are only available with the Worker driver. The REST API does not support D1 Sessions — this is a Cloudflare platform limitation.
Add to your .env:
CF_D1_SESSION_ENABLED=true
CF_D1_SESSION_MODE=first-unconstrained # or 'first-primary'
This automatically enables sessions for all queries on the Worker driver.
use Ntanduy\CFD1\D1\D1Connection;
/** @var D1Connection $connection */
$connection = DB::connection('d1');
// Start a session — first query goes to any instance (fastest)
$connection->withSession('first-unconstrained');
// Or start with the latest data from primary
$connection->withSession('first-primary');
// Execute queries — bookmarks are tracked automatically
$users = DB::table('users')->get();
$posts = DB::table('posts')->get();
// Get the current bookmark (for passing to another request/session)
$bookmark = $connection->getBookmark();
// Start a new session from a previous bookmark
$connection->withSession($bookmark);
// End the session when done
$connection->endSession();
| Mode | First Query | Use When |
|---|---|---|
first-unconstrained |
Any instance (primary or replica) | Lowest latency, eventual consistency OK |
first-primary |
Primary database | Need the latest data for first query |
<bookmark> |
At least as fresh as the bookmark | Continuing from a previous session |
session parameter with each query to the Workerenv.DB.withSession(param) to create a D1 sessionbookmark in the responseThe Worker template in Worker/ already includes session support. If you're upgrading from a previous version, redeploy the Worker:
cd Worker && npm run deploy
Automatically route SELECT queries to D1 replicas and INSERT/UPDATE/DELETE to the primary — zero code changes required.
// config/database.php
'd1' => [
'driver' => 'd1',
'd1_driver' => 'worker',
'worker_url' => env('CF_D1_WORKER_URL'),
'worker_secret' => env('CF_D1_WORKER_SECRET'),
'read' => [
'session' => ['mode' => 'first-unconstrained'],
],
'write' => [
'session' => ['mode' => 'first-primary'],
],
'sticky' => true, // After write, reads use primary for consistency
],
Once configured, Laravel handles everything:
// Automatically goes to replica (fast, nearby)
$users = User::all();
// Automatically goes to primary
User::create(['name' => 'Alice', 'email' => '[email protected]']);
// With sticky=true, this read goes to primary (sees the new user)
$user = User::where('email', '[email protected]')->first();
sticky (default: true) — after a write, subsequent reads in the same request use the write connector's bookmark for sequential consistencywithSession() — R/W splitting handles the base routing, you can still use sessions for fine-grained controlread/write config — no sessions support, all queries go to primaryInspect your D1 database metadata and connection status:
php artisan d1:info
Displays database name, UUID, size, table count, read replication mode, R/W splitting status, circuit breaker state, and runs a query test.
# Specify a connection
php artisan d1:info --connection=d1
Uses the D1 REST API for metadata. Worker-only users see table count and query test but need REST credentials for full metadata.
Import a SQL file into your D1 database:
php artisan d1:import path/to/file.sql
The command handles the full import flow automatically:
init request to get a presigned upload URL# Specify connection
php artisan d1:import database/seeds/data.sql --connection=d1
Note: Like
d1:schema-dump, the import command always uses the REST API. Worker-only users must also setCF_D1_API_TOKEN,CF_D1_ACCOUNT_ID, andCF_D1_DATABASE_IDin their.env.
D1 automatically creates restore points (bookmarks) for up to 30 days. Use d1:time-travel to get the current bookmark or restore your database to any point in time:
# Get the current bookmark
php artisan d1:time-travel
# Get the bookmark at a specific timestamp
php artisan d1:time-travel --timestamp="2024-01-15T10:30:00+00:00"
# Unix timestamps also work
php artisan d1:time-travel --timestamp=1705312200
To restore the database to a previous state:
# Restore to a specific bookmark
php artisan d1:time-travel --restore --bookmark="00000085-0000024c-00004c6d-abc123"
# Restore to a timestamp
php artisan d1:time-travel --restore --timestamp="2024-01-15T10:30:00+00:00"
Warning: Restore is a destructive operation — it overwrites the database in place. In-flight queries will be cancelled. The command will prompt for confirmation before proceeding. The previous bookmark is shown after restore so you can undo if needed.
Export your D1 database schema (and optionally data) as a SQL file:
php artisan d1:schema-dump
This uses the D1 export REST API with polling mode. The dump is saved to database/schema/{connection}-schema.sql.
# Schema only (no data)
php artisan d1:schema-dump --no-data
# Custom output path
php artisan d1:schema-dump --path=./backup.sql
# Delete migration files after dumping (same as native schema:dump --prune)
php artisan d1:schema-dump --prune
# Specify connection name
php artisan d1:schema-dump --connection=d1
Note:
d1:schema-dumpalways uses the REST API for export, even when the Worker driver is your primary connection. Worker-only users must also setCF_D1_API_TOKEN,CF_D1_ACCOUNT_ID, andCF_D1_DATABASE_IDin their.envfor the dump command to work.
Prevents cascading failures when Cloudflare Workers experience cold starts or sustained outages. Instead of blocking for 30s+ on retries, the circuit breaker fails fast after consecutive failures.
States:
CLOSED → requests pass through normally
↓ (threshold consecutive failures)
OPEN → requests rejected immediately, no HTTP call
↓ (after cooldown seconds)
HALF_OPEN → one probe request allowed through
↓ success → CLOSED | failure → OPEN
Enable in your config (config/database.php or config/d1-database.php):
'd1' => [
// ... other options ...
'circuit_breaker' => [
'enabled' => env('CF_D1_CB_ENABLED', false),
'threshold' => env('CF_D1_CB_THRESHOLD', 5), // failures before opening
'cooldown' => env('CF_D1_CB_COOLDOWN', 30), // seconds before probe
'cache_driver' => env('CF_D1_CB_CACHE_DRIVER', 'file'),
],
],
Handle the exception:
use Ntanduy\CFD1\D1\Exceptions\CircuitBreakerOpenException;
try {
$users = DB::connection('d1')->table('users')->get();
} catch (CircuitBreakerOpenException $e) {
// Circuit is open — fail fast, use fallback or return cached data
}
Note: Use
fileorredisas thecache_driver. Avoiddatabaseto prevent a dependency loop when D1 itself is down.
The driver automatically retries failed requests with exponential backoff and jitter:
CF_D1_RETRIES=2 # Max retry attempts (default: 2)
CF_D1_RETRY_DELAY=100 # Base delay in ms (default: 100)
CF_D1_TIMEOUT=10 # Request timeout in seconds (default: 10)
CF_D1_CONNECT_TIMEOUT=5 # Connection timeout in seconds (default: 5)
Backoff formula: delay × 2^(attempt-1) + random jitter (0-100ms)
| Attempt | Base Delay (100ms) |
|---|---|
| 1 | ~100-200ms |
| 2 | ~200-300ms |
Instead of publishing the config, you can add the connection directly to config/database.php:
'connections' => [
'd1' => [
'driver' => 'd1',
'd1_driver' => env('CF_D1_DRIVER', 'rest'), // 'rest' or 'worker'
'prefix' => '',
'database' => env('CF_D1_DATABASE_ID', ''),
// REST driver credentials
'api' => 'https://api.cloudflare.com/client/v4',
'auth' => [
'token' => env('CF_D1_API_TOKEN', ''),
'account_id' => env('CF_D1_ACCOUNT_ID', ''),
],
// Worker driver credentials
'worker_url' => env('CF_D1_WORKER_URL', ''),
'worker_secret' => env('CF_D1_WORKER_SECRET', ''),
'hmac' => env('CF_D1_HMAC', false),
// Performance tuning
'timeout' => env('CF_D1_TIMEOUT', 10),
'connect_timeout' => env('CF_D1_CONNECT_TIMEOUT', 5),
'retries' => env('CF_D1_RETRIES', 2),
'retry_delay' => env('CF_D1_RETRY_DELAY', 100),
// Sessions / Read Replication (Worker driver only)
'session' => [
'enabled' => env('CF_D1_SESSION_ENABLED', false),
'mode' => env('CF_D1_SESSION_MODE', 'first-unconstrained'),
],
// Circuit breaker (optional)
'circuit_breaker' => [
'enabled' => env('CF_D1_CB_ENABLED', false),
'threshold' => env('CF_D1_CB_THRESHOLD', 5),
'cooldown' => env('CF_D1_CB_COOLDOWN', 30),
'cache_driver' => env('CF_D1_CB_CACHE_DRIVER', 'file'),
],
],
],
| Option | Default | Description |
|---|---|---|
d1_driver |
rest |
Connection driver: rest (Cloudflare REST API) or worker (custom Worker) |
database |
— | Your Cloudflare D1 Database ID |
api |
https://api.cloudflare.com/client/v4 |
Cloudflare API base URL (REST driver only) |
auth.token |
— | Cloudflare API Token (REST driver only) |
auth.account_id |
— | Cloudflare Account ID (REST driver only) |
worker_url |
— | Your Worker URL (Worker driver only) |
worker_secret |
— | Shared secret for Worker auth (Worker driver only) |
hmac |
false |
Enable HMAC request signing for body-tamper protection and replay detection (Worker driver only) |
timeout |
10 |
HTTP request timeout in seconds |
connect_timeout |
5 |
HTTP connection timeout in seconds |
retries |
2 |
Max retry attempts on 5xx/429 errors |
retry_delay |
100 |
Base delay between retries in milliseconds |
transaction_mode |
silent |
How transaction APIs are handled: silent (no-op), log (warn once), exception (throw) |
session.enabled |
false |
Enable D1 sessions for read replication (Worker driver only) |
session.mode |
first-unconstrained |
Session mode: first-primary or first-unconstrained |
read_write_splitting.enabled |
false |
Route SELECT to read replicas, writes to primary (Worker driver only) |
read_write_splitting.sticky |
true |
After a write, route subsequent reads to write connector for consistency |
read_write_splitting.read_mode |
first-unconstrained |
Session mode for the read connector |
read_write_splitting.write_mode |
first-primary |
Session mode for the write connector |
circuit_breaker.enabled |
false |
Enable circuit breaker for fail-fast behavior |
circuit_breaker.threshold |
5 |
Consecutive failures before opening the circuit |
circuit_breaker.cooldown |
30 |
Seconds before allowing a probe request |
circuit_breaker.cache_driver |
file |
Laravel cache driver for circuit state (file, redis) |
# Driver selection
CF_D1_DRIVER=rest # 'rest' or 'worker'
# REST driver
CF_D1_API_TOKEN=your_api_token
CF_D1_ACCOUNT_ID=your_account_id
CF_D1_DATABASE_ID=your_database_id
# Worker driver
CF_D1_WORKER_URL=https://your-worker.workers.dev
CF_D1_WORKER_SECRET=your_shared_secret
CF_D1_HMAC=false # Enable HMAC request signing
# Performance tuning (optional)
CF_D1_TIMEOUT=10
CF_D1_CONNECT_TIMEOUT=5
CF_D1_RETRIES=2
CF_D1_RETRY_DELAY=100
# Transaction behavior (optional)
CF_D1_TRANSACTION_MODE=silent # 'silent', 'log', or 'exception'
# Sessions / Read Replication (Worker driver only)
CF_D1_SESSION_ENABLED=false
CF_D1_SESSION_MODE=first-unconstrained
# Circuit breaker (optional)
CF_D1_CB_ENABLED=false
CF_D1_CB_THRESHOLD=5
CF_D1_CB_COOLDOWN=30
CF_D1_CB_CACHE_DRIVER=file
No real transactions — D1 is stateless over HTTP and doesn't support BEGIN/COMMIT/ROLLBACK. The driver makes these methods no-ops so Laravel internals (auth, sessions, middleware) work without crashing.
DB::transaction(Closure) will execute the closure, but provides no atomicity — each query runs immediately and cannot be rolled back on failure.
DB::transaction(Closure, attempts: N) retries the closure on any exception, but without real deadlock detection or isolation.
Manual beginTransaction(), commit(), and rollBack() calls are also no-ops. transaction_mode=log warns once per connection/request; transaction_mode=exception throws immediately.
For atomic multi-statement execution, use batch() which leverages D1's native batch API:
$connection->batch([
['sql' => 'INSERT INTO orders ...', 'params' => [...]],
['sql' => 'UPDATE inventory ...', 'params' => [...]],
]);
REST API latency — Each query is an HTTP request routed through the Cloudflare API. The Worker driver offers significantly lower latency because the Worker is co-located with your D1 database. Latency varies by region, database size, and query complexity.
Sessions / Read Replication — Worker driver only — The D1 Sessions API is only available via the Worker Binding. The REST API does not support sessions; all queries go to the primary database. This is a Cloudflare platform limitation.
Schema dump requires REST credentials — d1:schema-dump uses the D1 export REST API. Even Worker-only users must set CF_D1_API_TOKEN, CF_D1_ACCOUNT_ID, and CF_D1_DATABASE_ID.
Export blocks queries — During export, D1 may be unavailable for queries (Cloudflare limitation for large databases).
Bulk insert batch limit — D1 batch supports max 100 statements. bulkInsert() automatically chunks larger datasets but each chunk is a separate HTTP call.
No streaming — Large result sets are loaded entirely into memory.
vendor/bin/pest
cd Worker
npm ci
npm test
Start the built-in Worker to test against a local D1 instance:
cd Worker
npm ci
npm run dev
Contributions are welcome! Please open an issue or pull request on GitHub.
If you discover any security related issues, please email [email protected] instead of using the issue tracker.
How can I help you explore Laravel packages today?